feat: Add Simply-CRM integration setup documentation and configuration details
docs: Create vTiger & Simply-CRM integration setup guide with credential requirements feat: Implement ticket system enhancements including relations, calendar events, templates, and AI suggestions refactor: Update ticket system migration to include audit logging and enhanced email metadata
This commit is contained in:
parent
3806c7d011
commit
ffb3d335bc
77
.env.example
77
.env.example
@ -45,77 +45,18 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
|||||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# vTiger CRM Integration (Optional)
|
# vTiger Cloud Integration (Required for Subscriptions)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
# Get credentials from vTiger Cloud -> Settings -> Integration -> Webservices
|
||||||
VTIGER_URL=https://your-instance.od2.vtiger.com
|
VTIGER_URL=https://your-instance.od2.vtiger.com
|
||||||
VTIGER_USERNAME=your_username@yourdomain.com
|
VTIGER_USERNAME=your_vtiger_username
|
||||||
VTIGER_API_KEY=your_api_key_or_access_key
|
VTIGER_API_KEY=your_vtiger_api_key
|
||||||
VTIGER_PASSWORD=your_password_if_using_basic_auth
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# TIME TRACKING MODULE - Isolated Settings
|
# Simply-CRM / Old vTiger On-Premise (Legacy)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
# Old vTiger installation (if different from cloud)
|
||||||
# vTiger Integration Safety Flags
|
OLD_VTIGER_URL=http://your-old-vtiger-server.com
|
||||||
TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger
|
OLD_VTIGER_USERNAME=your_old_username
|
||||||
TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere
|
OLD_VTIGER_API_KEY=your_old_api_key
|
||||||
|
|
||||||
# e-conomic Integration Safety Flags
|
|
||||||
TIMETRACKING_ECONOMIC_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til e-conomic
|
|
||||||
TIMETRACKING_ECONOMIC_DRY_RUN=true # 🚨 Log uden at eksportere
|
|
||||||
TIMETRACKING_EXPORT_TYPE=draft # draft|booked (draft er sikrest)
|
|
||||||
|
|
||||||
# Business Logic Settings
|
|
||||||
TIMETRACKING_DEFAULT_HOURLY_RATE=850.00 # DKK pr. time (fallback hvis kunde ikke har rate)
|
|
||||||
TIMETRACKING_AUTO_ROUND=true # Auto-afrund til nærmeste interval
|
|
||||||
TIMETRACKING_ROUND_INCREMENT=0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
|
|
||||||
TIMETRACKING_ROUND_METHOD=up # up (op til), nearest (nærmeste), down (ned til)
|
|
||||||
TIMETRACKING_REQUIRE_APPROVAL=true # Kræv manuel godkendelse
|
|
||||||
|
|
||||||
# Order Management Security
|
|
||||||
TIMETRACKING_ADMIN_UNLOCK_CODE= # 🔐 Admin kode til at låse eksporterede ordrer op (sæt en stærk kode!)
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# OLLAMA AI Integration (Optional - for document extraction)
|
|
||||||
# =====================================================
|
|
||||||
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
|
|
||||||
OLLAMA_MODEL=qwen2.5-coder:7b
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# COMPANY INFO
|
|
||||||
# =====================================================
|
|
||||||
OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# FILE UPLOAD
|
|
||||||
# =====================================================
|
|
||||||
UPLOAD_DIR=uploads
|
|
||||||
MAX_FILE_SIZE_MB=50
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# MODULE SYSTEM - Dynamic Feature Loading
|
|
||||||
# =====================================================
|
|
||||||
# Enable/disable entire module system
|
|
||||||
MODULES_ENABLED=true
|
|
||||||
|
|
||||||
# Directory for dynamic modules (default: app/modules)
|
|
||||||
MODULES_DIR=app/modules
|
|
||||||
|
|
||||||
# Auto-reload modules on changes (dev only, requires restart)
|
|
||||||
MODULES_AUTO_RELOAD=true
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# MODULE-SPECIFIC CONFIGURATION
|
|
||||||
# =====================================================
|
|
||||||
# Pattern: MODULES__{MODULE_NAME}__{KEY}
|
|
||||||
# Example module configuration:
|
|
||||||
|
|
||||||
# MODULES__INVOICE_OCR__READ_ONLY=true
|
|
||||||
# MODULES__INVOICE_OCR__DRY_RUN=true
|
|
||||||
# MODULES__INVOICE_OCR__API_KEY=secret123
|
|
||||||
|
|
||||||
# MODULES__MY_FEATURE__READ_ONLY=false
|
|
||||||
# MODULES__MY_FEATURE__DRY_RUN=false
|
|
||||||
# MODULES__MY_FEATURE__SOME_SETTING=value
|
|
||||||
@ -8,10 +8,6 @@ RUN apt-get update && apt-get install -y \
|
|||||||
git \
|
git \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
postgresql-client \
|
|
||||||
tesseract-ocr \
|
|
||||||
tesseract-ocr-dan \
|
|
||||||
tesseract-ocr-eng \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Build arguments for GitHub release deployment
|
# Build arguments for GitHub release deployment
|
||||||
|
|||||||
39
README.md
39
README.md
@ -7,12 +7,6 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha
|
|||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- **Customer Management**: Komplet kundedatabase med CRM integration
|
- **Customer Management**: Komplet kundedatabase med CRM integration
|
||||||
- **Time Tracking Module**: vTiger integration med tidsregistrering og fakturering
|
|
||||||
- Automatisk sync fra vTiger (billable timelogs)
|
|
||||||
- Step-by-step godkendelses-wizard
|
|
||||||
- Auto-afrunding til 0.5 timer
|
|
||||||
- Klippekort-funktionalitet
|
|
||||||
- e-conomic export (draft orders)
|
|
||||||
- **Hardware Tracking**: Registrering og sporing af kundeudstyr
|
- **Hardware Tracking**: Registrering og sporing af kundeudstyr
|
||||||
- **Service Management**: Håndtering af services og abonnementer
|
- **Service Management**: Håndtering af services og abonnementer
|
||||||
- **Billing Integration**: Automatisk fakturering via e-conomic
|
- **Billing Integration**: Automatisk fakturering via e-conomic
|
||||||
@ -129,43 +123,12 @@ bmc_hub/
|
|||||||
|
|
||||||
## 🔌 API Endpoints
|
## 🔌 API Endpoints
|
||||||
|
|
||||||
### Main API
|
|
||||||
- `GET /api/v1/customers` - List customers
|
- `GET /api/v1/customers` - List customers
|
||||||
- `GET /api/v1/hardware` - List hardware
|
- `GET /api/v1/hardware` - List hardware
|
||||||
- `GET /api/v1/billing/invoices` - List invoices
|
- `GET /api/v1/billing/invoices` - List invoices
|
||||||
- `GET /health` - Health check
|
- `GET /health` - Health check
|
||||||
|
|
||||||
### Time Tracking Module
|
Se fuld dokumentation: http://localhost:8000/api/docs
|
||||||
- `POST /api/v1/timetracking/sync` - Sync from vTiger (read-only)
|
|
||||||
- `GET /api/v1/timetracking/wizard/next` - Get next pending timelog
|
|
||||||
- `POST /api/v1/timetracking/wizard/approve/{id}` - Approve timelog
|
|
||||||
- `POST /api/v1/timetracking/orders/generate` - Generate invoice order
|
|
||||||
- `POST /api/v1/timetracking/export` - Export to e-conomic (with safety flags)
|
|
||||||
- `GET /api/v1/timetracking/export/test-connection` - Test e-conomic connection
|
|
||||||
|
|
||||||
Se fuld dokumentation: http://localhost:8001/api/docs
|
|
||||||
|
|
||||||
## 🚨 e-conomic Write Mode
|
|
||||||
|
|
||||||
Time Tracking modulet kan eksportere ordrer til e-conomic med **safety-first approach**:
|
|
||||||
|
|
||||||
### Safety Flags (default: SAFE)
|
|
||||||
```bash
|
|
||||||
TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes
|
|
||||||
TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes (log only)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enable Write Mode
|
|
||||||
Se detaljeret guide: [docs/ECONOMIC_WRITE_MODE.md](docs/ECONOMIC_WRITE_MODE.md)
|
|
||||||
|
|
||||||
**Quick steps:**
|
|
||||||
1. Test connection: `GET /api/v1/timetracking/export/test-connection`
|
|
||||||
2. Test dry-run: Set `READ_ONLY=false`, keep `DRY_RUN=true`
|
|
||||||
3. Export test order: `POST /api/v1/timetracking/export`
|
|
||||||
4. Enable production: Set **both** flags to `false`
|
|
||||||
5. Verify first order in e-conomic before bulk operations
|
|
||||||
|
|
||||||
**CRITICAL**: All customers must have `economic_customer_number` (synced from vTiger `cf_854` field).
|
|
||||||
|
|
||||||
## 🧪 Testing
|
## 🧪 Testing
|
||||||
|
|
||||||
|
|||||||
@ -161,7 +161,7 @@ async def list_backups(
|
|||||||
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
|
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
backups = execute_query(query, tuple(params))
|
backups = execute_query_single(query, tuple(params))
|
||||||
|
|
||||||
return backups if backups else []
|
return backups if backups else []
|
||||||
|
|
||||||
@ -171,9 +171,7 @@ async def get_backup(job_id: int):
|
|||||||
"""Get details of a specific backup job"""
|
"""Get details of a specific backup job"""
|
||||||
backup = execute_query(
|
backup = execute_query(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s",
|
"SELECT * FROM backup_jobs WHERE id = %s",
|
||||||
(job_id,),
|
(job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not backup:
|
if not backup:
|
||||||
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
|
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
|
||||||
@ -305,11 +303,9 @@ async def restore_backup(job_id: int, request: RestoreRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get backup job
|
# Get backup job
|
||||||
backup = execute_query(
|
backup = execute_query_single(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s",
|
"SELECT * FROM backup_jobs WHERE id = %s",
|
||||||
(job_id,),
|
(job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not backup:
|
if not backup:
|
||||||
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
|
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
|
||||||
@ -359,11 +355,9 @@ async def delete_backup(job_id: int):
|
|||||||
Delete a backup job and its associated file
|
Delete a backup job and its associated file
|
||||||
"""
|
"""
|
||||||
# Get backup job
|
# Get backup job
|
||||||
backup = execute_query(
|
backup = execute_query_single(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s",
|
"SELECT * FROM backup_jobs WHERE id = %s",
|
||||||
(job_id,),
|
(job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not backup:
|
if not backup:
|
||||||
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
|
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
|
||||||
@ -419,10 +413,8 @@ async def get_maintenance_status():
|
|||||||
|
|
||||||
Used by frontend to display maintenance overlay
|
Used by frontend to display maintenance overlay
|
||||||
"""
|
"""
|
||||||
status = execute_query(
|
status = execute_query_single(
|
||||||
"SELECT * FROM system_status WHERE id = 1",
|
"SELECT * FROM system_status WHERE id = 1")
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not status:
|
if not status:
|
||||||
# Return default status if not found
|
# Return default status if not found
|
||||||
|
|||||||
@ -158,11 +158,9 @@ class BackupScheduler:
|
|||||||
db_job_id, files_job_id, duration)
|
db_job_id, files_job_id, duration)
|
||||||
|
|
||||||
# Send success notification for database backup
|
# Send success notification for database backup
|
||||||
db_backup = execute_query(
|
db_backup = execute_query_single(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s",
|
"SELECT * FROM backup_jobs WHERE id = %s",
|
||||||
(db_job_id,),
|
(db_job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if db_backup:
|
if db_backup:
|
||||||
await notifications.send_backup_success(
|
await notifications.send_backup_success(
|
||||||
@ -217,11 +215,9 @@ class BackupScheduler:
|
|||||||
db_job_id, files_job_id, duration)
|
db_job_id, files_job_id, duration)
|
||||||
|
|
||||||
# Send success notification for database backup
|
# Send success notification for database backup
|
||||||
db_backup = execute_query(
|
db_backup = execute_query_single(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s",
|
"SELECT * FROM backup_jobs WHERE id = %s",
|
||||||
(db_job_id,),
|
(db_job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if db_backup:
|
if db_backup:
|
||||||
await notifications.send_backup_success(
|
await notifications.send_backup_success(
|
||||||
@ -259,7 +255,7 @@ class BackupScheduler:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Find all completed backups not yet uploaded
|
# Find all completed backups not yet uploaded
|
||||||
pending_backups = execute_query(
|
pending_backups = execute_query_single(
|
||||||
"""SELECT * FROM backup_jobs
|
"""SELECT * FROM backup_jobs
|
||||||
WHERE status = 'completed'
|
WHERE status = 'completed'
|
||||||
AND offsite_uploaded_at IS NULL
|
AND offsite_uploaded_at IS NULL
|
||||||
@ -295,9 +291,7 @@ class BackupScheduler:
|
|||||||
# Get updated retry count
|
# Get updated retry count
|
||||||
updated_backup = execute_query(
|
updated_backup = execute_query(
|
||||||
"SELECT offsite_retry_count FROM backup_jobs WHERE id = %s",
|
"SELECT offsite_retry_count FROM backup_jobs WHERE id = %s",
|
||||||
(backup['id'],),
|
(backup['id'],))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send failure notification
|
# Send failure notification
|
||||||
await notifications.send_offsite_failed(
|
await notifications.send_offsite_failed(
|
||||||
|
|||||||
@ -285,7 +285,7 @@ class BackupService:
|
|||||||
logger.info("🔄 Starting backup rotation")
|
logger.info("🔄 Starting backup rotation")
|
||||||
|
|
||||||
# Find expired backups
|
# Find expired backups
|
||||||
expired_backups = execute_query(
|
expired_backups = execute_query_single(
|
||||||
"""SELECT id, file_path, is_monthly, retention_until
|
"""SELECT id, file_path, is_monthly, retention_until
|
||||||
FROM backup_jobs
|
FROM backup_jobs
|
||||||
WHERE status = 'completed'
|
WHERE status = 'completed'
|
||||||
@ -333,9 +333,7 @@ class BackupService:
|
|||||||
# Get backup job
|
# Get backup job
|
||||||
backup = execute_query(
|
backup = execute_query(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'database'",
|
"SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'database'",
|
||||||
(job_id,),
|
(job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not backup:
|
if not backup:
|
||||||
logger.error("❌ Backup job not found: %s", job_id)
|
logger.error("❌ Backup job not found: %s", job_id)
|
||||||
@ -442,11 +440,9 @@ class BackupService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Get backup job
|
# Get backup job
|
||||||
backup = execute_query(
|
backup = execute_query_single(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'files'",
|
"SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'files'",
|
||||||
(job_id,),
|
(job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not backup:
|
if not backup:
|
||||||
logger.error("❌ Backup job not found: %s", job_id)
|
logger.error("❌ Backup job not found: %s", job_id)
|
||||||
@ -516,11 +512,9 @@ class BackupService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Get backup job
|
# Get backup job
|
||||||
backup = execute_query(
|
backup = execute_query_single(
|
||||||
"SELECT * FROM backup_jobs WHERE id = %s",
|
"SELECT * FROM backup_jobs WHERE id = %s",
|
||||||
(job_id,),
|
(job_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not backup:
|
if not backup:
|
||||||
logger.error("❌ Backup job not found: %s", job_id)
|
logger.error("❌ Backup job not found: %s", job_id)
|
||||||
|
|||||||
@ -380,6 +380,12 @@
|
|||||||
|
|
||||||
// Load backups list
|
// Load backups list
|
||||||
async function loadBackups() {
|
async function loadBackups() {
|
||||||
|
// TODO: Implement /api/v1/backups/jobs endpoint
|
||||||
|
console.warn('⚠️ Backups API ikke implementeret endnu');
|
||||||
|
document.getElementById('backups-table').innerHTML = '<tr><td colspan="8" class="text-center text-warning"><i class="bi bi-exclamation-triangle me-2"></i>Backup API er ikke implementeret endnu</td></tr>';
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/jobs?limit=50');
|
const response = await fetch('/api/v1/backups/jobs?limit=50');
|
||||||
const backups = await response.json();
|
const backups = await response.json();
|
||||||
@ -433,6 +439,10 @@
|
|||||||
|
|
||||||
// Load storage stats
|
// Load storage stats
|
||||||
async function loadStorageStats() {
|
async function loadStorageStats() {
|
||||||
|
// TODO: Implement /api/v1/backups/storage endpoint
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/storage');
|
const response = await fetch('/api/v1/backups/storage');
|
||||||
const stats = await response.json();
|
const stats = await response.json();
|
||||||
@ -464,6 +474,10 @@
|
|||||||
|
|
||||||
// Load notifications
|
// Load notifications
|
||||||
async function loadNotifications() {
|
async function loadNotifications() {
|
||||||
|
// TODO: Implement /api/v1/backups/notifications endpoint
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/notifications?limit=10');
|
const response = await fetch('/api/v1/backups/notifications?limit=10');
|
||||||
const notifications = await response.json();
|
const notifications = await response.json();
|
||||||
@ -493,6 +507,10 @@
|
|||||||
|
|
||||||
// Load scheduler status
|
// Load scheduler status
|
||||||
async function loadSchedulerStatus() {
|
async function loadSchedulerStatus() {
|
||||||
|
// TODO: Implement /api/v1/backups/scheduler/status endpoint
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/scheduler/status');
|
const response = await fetch('/api/v1/backups/scheduler/status');
|
||||||
const status = await response.json();
|
const status = await response.json();
|
||||||
@ -528,9 +546,13 @@
|
|||||||
async function createBackup(event) {
|
async function createBackup(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
const resultDiv = document.getElementById('backup-result');
|
||||||
|
resultDiv.innerHTML = '<div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>Backup API er ikke implementeret endnu</div>';
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
const type = document.getElementById('backup-type').value;
|
const type = document.getElementById('backup-type').value;
|
||||||
const isMonthly = document.getElementById('is-monthly').checked;
|
const isMonthly = document.getElementById('is-monthly').checked;
|
||||||
const resultDiv = document.getElementById('backup-result');
|
|
||||||
|
|
||||||
resultDiv.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split"></i> Creating backup...</div>';
|
resultDiv.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split"></i> Creating backup...</div>';
|
||||||
|
|
||||||
@ -558,10 +580,14 @@
|
|||||||
async function uploadBackup(event) {
|
async function uploadBackup(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
const resultDiv = document.getElementById('upload-result');
|
||||||
|
resultDiv.innerHTML = '<div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>Backup upload API er ikke implementeret endnu</div>';
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
const fileInput = document.getElementById('backup-file');
|
const fileInput = document.getElementById('backup-file');
|
||||||
const type = document.getElementById('upload-type').value;
|
const type = document.getElementById('upload-type').value;
|
||||||
const isMonthly = document.getElementById('upload-monthly').checked;
|
const isMonthly = document.getElementById('upload-monthly').checked;
|
||||||
const resultDiv = document.getElementById('upload-result');
|
|
||||||
|
|
||||||
if (!fileInput.files || fileInput.files.length === 0) {
|
if (!fileInput.files || fileInput.files.length === 0) {
|
||||||
resultDiv.innerHTML = '<div class="alert alert-danger">Please select a file</div>';
|
resultDiv.innerHTML = '<div class="alert alert-danger">Please select a file</div>';
|
||||||
@ -613,6 +639,10 @@
|
|||||||
|
|
||||||
// Confirm restore
|
// Confirm restore
|
||||||
async function confirmRestore() {
|
async function confirmRestore() {
|
||||||
|
alert('⚠️ Restore API er ikke implementeret endnu');
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
if (!selectedJobId) return;
|
if (!selectedJobId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -639,6 +669,10 @@
|
|||||||
|
|
||||||
// Upload to offsite
|
// Upload to offsite
|
||||||
async function uploadOffsite(jobId) {
|
async function uploadOffsite(jobId) {
|
||||||
|
alert('⚠️ Offsite upload API er ikke implementeret endnu');
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
if (!confirm('Upload this backup to offsite storage?')) return;
|
if (!confirm('Upload this backup to offsite storage?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -658,6 +692,10 @@
|
|||||||
|
|
||||||
// Delete backup
|
// Delete backup
|
||||||
async function deleteBackup(jobId) {
|
async function deleteBackup(jobId) {
|
||||||
|
alert('⚠️ Delete backup API er ikke implementeret endnu');
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
if (!confirm('Delete this backup? This cannot be undone.')) return;
|
if (!confirm('Delete this backup? This cannot be undone.')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -676,6 +714,10 @@
|
|||||||
|
|
||||||
// Acknowledge notification
|
// Acknowledge notification
|
||||||
async function acknowledgeNotification(notificationId) {
|
async function acknowledgeNotification(notificationId) {
|
||||||
|
console.warn('⚠️ Notification API ikke implementeret');
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Disabled until API implemented:
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'});
|
await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'});
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
|
|||||||
@ -203,7 +203,7 @@ async def list_supplier_invoices(
|
|||||||
|
|
||||||
query += " ORDER BY si.due_date ASC, si.invoice_date DESC"
|
query += " ORDER BY si.due_date ASC, si.invoice_date DESC"
|
||||||
|
|
||||||
invoices = execute_query(query, tuple(params) if params else ())
|
invoices = execute_query_single(query, tuple(params) if params else ())
|
||||||
|
|
||||||
# Add lines to each invoice
|
# Add lines to each invoice
|
||||||
for invoice in invoices:
|
for invoice in invoices:
|
||||||
@ -324,9 +324,7 @@ async def get_file_pdf_text(file_id: int):
|
|||||||
# Get file info
|
# Get file info
|
||||||
file_info = execute_query(
|
file_info = execute_query(
|
||||||
"SELECT file_path, filename FROM incoming_files WHERE file_id = %s",
|
"SELECT file_path, filename FROM incoming_files WHERE file_id = %s",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
||||||
@ -357,21 +355,17 @@ async def get_file_extracted_data(file_id: int):
|
|||||||
"""Hent AI-extracted data fra en uploaded fil"""
|
"""Hent AI-extracted data fra en uploaded fil"""
|
||||||
try:
|
try:
|
||||||
# Get file info
|
# Get file info
|
||||||
file_info = execute_query(
|
file_info = execute_query_single(
|
||||||
"SELECT * FROM incoming_files WHERE file_id = %s",
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
||||||
|
|
||||||
# Get extraction results if exists
|
# Get extraction results if exists
|
||||||
extraction = execute_query(
|
extraction = execute_query_single(
|
||||||
"SELECT * FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
"SELECT * FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse llm_response_json if it exists (from AI or template extraction)
|
# Parse llm_response_json if it exists (from AI or template extraction)
|
||||||
llm_json_data = None
|
llm_json_data = None
|
||||||
@ -386,7 +380,7 @@ async def get_file_extracted_data(file_id: int):
|
|||||||
# Get extraction lines if exist
|
# Get extraction lines if exist
|
||||||
extraction_lines = []
|
extraction_lines = []
|
||||||
if extraction:
|
if extraction:
|
||||||
extraction_lines = execute_query(
|
extraction_lines = execute_query_single(
|
||||||
"""SELECT * FROM extraction_lines
|
"""SELECT * FROM extraction_lines
|
||||||
WHERE extraction_id = %s
|
WHERE extraction_id = %s
|
||||||
ORDER BY line_number""",
|
ORDER BY line_number""",
|
||||||
@ -493,9 +487,7 @@ async def download_pending_file(file_id: int):
|
|||||||
# Get file info
|
# Get file info
|
||||||
file_info = execute_query(
|
file_info = execute_query(
|
||||||
"SELECT * FROM incoming_files WHERE file_id = %s",
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
||||||
@ -533,21 +525,17 @@ async def link_vendor_to_extraction(file_id: int, data: dict):
|
|||||||
raise HTTPException(status_code=400, detail="vendor_id is required")
|
raise HTTPException(status_code=400, detail="vendor_id is required")
|
||||||
|
|
||||||
# Verify vendor exists
|
# Verify vendor exists
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT id, name FROM vendors WHERE id = %s",
|
"SELECT id, name FROM vendors WHERE id = %s",
|
||||||
(vendor_id,),
|
(vendor_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
|
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
|
||||||
|
|
||||||
# Get latest extraction for this file
|
# Get latest extraction for this file
|
||||||
extraction = execute_query(
|
extraction = execute_query_single(
|
||||||
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not extraction:
|
if not extraction:
|
||||||
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
||||||
@ -583,23 +571,19 @@ async def delete_pending_file_endpoint(file_id: int):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get file info
|
# Get file info
|
||||||
file_info = execute_query(
|
file_info = execute_query_single(
|
||||||
"SELECT * FROM incoming_files WHERE file_id = %s",
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
raise HTTPException(status_code=404, detail="Fil ikke fundet")
|
||||||
|
|
||||||
# Check if already converted to invoice
|
# Check if already converted to invoice
|
||||||
invoice_exists = execute_query(
|
invoice_exists = execute_query_single(
|
||||||
"""SELECT si.id FROM supplier_invoices si
|
"""SELECT si.id FROM supplier_invoices si
|
||||||
JOIN extractions e ON si.extraction_id = e.extraction_id
|
JOIN extractions e ON si.extraction_id = e.extraction_id
|
||||||
WHERE e.file_id = %s""",
|
WHERE e.file_id = %s""",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if invoice_exists:
|
if invoice_exists:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -665,21 +649,17 @@ async def link_vendor_to_extraction(file_id: int, data: dict):
|
|||||||
raise HTTPException(status_code=400, detail="vendor_id er påkrævet")
|
raise HTTPException(status_code=400, detail="vendor_id er påkrævet")
|
||||||
|
|
||||||
# Verify vendor exists
|
# Verify vendor exists
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT id, name FROM vendors WHERE id = %s",
|
"SELECT id, name FROM vendors WHERE id = %s",
|
||||||
(vendor_id,),
|
(vendor_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not vendor:
|
if not vendor:
|
||||||
raise HTTPException(status_code=404, detail=f"Leverandør {vendor_id} ikke fundet")
|
raise HTTPException(status_code=404, detail=f"Leverandør {vendor_id} ikke fundet")
|
||||||
|
|
||||||
# Get latest extraction for this file
|
# Get latest extraction for this file
|
||||||
extraction = execute_query(
|
extraction = execute_query_single(
|
||||||
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not extraction:
|
if not extraction:
|
||||||
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
||||||
@ -711,16 +691,14 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
"""Opret leverandørfaktura fra extraction data"""
|
"""Opret leverandørfaktura fra extraction data"""
|
||||||
try:
|
try:
|
||||||
# Get latest extraction for this file
|
# Get latest extraction for this file
|
||||||
extraction = execute_query(
|
extraction = execute_query_single(
|
||||||
"""SELECT e.*, v.name as vendor_name
|
"""SELECT e.*, v.name as vendor_name
|
||||||
FROM extractions e
|
FROM extractions e
|
||||||
LEFT JOIN vendors v ON v.id = e.vendor_matched_id
|
LEFT JOIN vendors v ON v.id = e.vendor_matched_id
|
||||||
WHERE e.file_id = %s
|
WHERE e.file_id = %s
|
||||||
ORDER BY e.created_at DESC
|
ORDER BY e.created_at DESC
|
||||||
LIMIT 1""",
|
LIMIT 1""",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not extraction:
|
if not extraction:
|
||||||
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
||||||
@ -733,17 +711,15 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check if invoice already exists
|
# Check if invoice already exists
|
||||||
existing = execute_query(
|
existing = execute_query_single(
|
||||||
"SELECT id FROM supplier_invoices WHERE extraction_id = %s",
|
"SELECT id FROM supplier_invoices WHERE extraction_id = %s",
|
||||||
(extraction['extraction_id'],),
|
(extraction['extraction_id'],))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
||||||
|
|
||||||
# Get extraction lines
|
# Get extraction lines
|
||||||
lines = execute_query(
|
lines = execute_query_single(
|
||||||
"""SELECT * FROM extraction_lines
|
"""SELECT * FROM extraction_lines
|
||||||
WHERE extraction_id = %s
|
WHERE extraction_id = %s
|
||||||
ORDER BY line_number""",
|
ORDER BY line_number""",
|
||||||
@ -892,9 +868,7 @@ async def list_templates():
|
|||||||
if vendor_cvr:
|
if vendor_cvr:
|
||||||
vendor = execute_query(
|
vendor = execute_query(
|
||||||
"SELECT id, name FROM vendors WHERE cvr_number = %s",
|
"SELECT id, name FROM vendors WHERE cvr_number = %s",
|
||||||
(vendor_cvr,),
|
(vendor_cvr,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if vendor:
|
if vendor:
|
||||||
vendor_id = vendor['id']
|
vendor_id = vendor['id']
|
||||||
vendor_name = vendor['name']
|
vendor_name = vendor['name']
|
||||||
@ -935,7 +909,7 @@ async def get_template(template_id: int):
|
|||||||
LEFT JOIN vendors v ON t.vendor_id = v.id
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
||||||
WHERE t.template_id = %s AND t.is_active = true
|
WHERE t.template_id = %s AND t.is_active = true
|
||||||
"""
|
"""
|
||||||
template = execute_query(query, (template_id,), fetchone=True)
|
template = execute_query_single(query, (template_id,))
|
||||||
|
|
||||||
if not template:
|
if not template:
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
@ -969,11 +943,9 @@ async def search_vendor_by_info(request: Dict):
|
|||||||
|
|
||||||
# Search by CVR first (most accurate)
|
# Search by CVR first (most accurate)
|
||||||
if vendor_cvr:
|
if vendor_cvr:
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s",
|
"SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s",
|
||||||
(vendor_cvr,),
|
(vendor_cvr,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if vendor:
|
if vendor:
|
||||||
return {
|
return {
|
||||||
"found": True,
|
"found": True,
|
||||||
@ -984,7 +956,7 @@ async def search_vendor_by_info(request: Dict):
|
|||||||
|
|
||||||
# Search by name (fuzzy)
|
# Search by name (fuzzy)
|
||||||
if vendor_name:
|
if vendor_name:
|
||||||
vendors = execute_query(
|
vendors = execute_query_single(
|
||||||
"SELECT id, name, cvr_number FROM vendors WHERE LOWER(name) LIKE LOWER(%s) LIMIT 5",
|
"SELECT id, name, cvr_number FROM vendors WHERE LOWER(name) LIKE LOWER(%s) LIMIT 5",
|
||||||
(f"%{vendor_name}%",)
|
(f"%{vendor_name}%",)
|
||||||
)
|
)
|
||||||
@ -1178,15 +1150,13 @@ async def get_supplier_invoice(invoice_id: int):
|
|||||||
FROM supplier_invoices si
|
FROM supplier_invoices si
|
||||||
LEFT JOIN vendors v ON si.vendor_id = v.id
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
||||||
WHERE si.id = %s""",
|
WHERE si.id = %s""",
|
||||||
(invoice_id,),
|
(invoice_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not invoice:
|
if not invoice:
|
||||||
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
||||||
|
|
||||||
# Get lines
|
# Get lines
|
||||||
lines = execute_query(
|
lines = execute_query_single(
|
||||||
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
||||||
(invoice_id,)
|
(invoice_id,)
|
||||||
)
|
)
|
||||||
@ -1313,9 +1283,7 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
|
|||||||
# Check if invoice exists
|
# Check if invoice exists
|
||||||
existing = execute_query(
|
existing = execute_query(
|
||||||
"SELECT id, status FROM supplier_invoices WHERE id = %s",
|
"SELECT id, status FROM supplier_invoices WHERE id = %s",
|
||||||
(invoice_id,),
|
(invoice_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
||||||
@ -1368,11 +1336,9 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
|
|||||||
async def delete_supplier_invoice(invoice_id: int):
|
async def delete_supplier_invoice(invoice_id: int):
|
||||||
"""Delete supplier invoice (soft delete if integrated with e-conomic)"""
|
"""Delete supplier invoice (soft delete if integrated with e-conomic)"""
|
||||||
try:
|
try:
|
||||||
invoice = execute_query(
|
invoice = execute_query_single(
|
||||||
"SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s",
|
"SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s",
|
||||||
(invoice_id,),
|
(invoice_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not invoice:
|
if not invoice:
|
||||||
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
||||||
@ -1410,11 +1376,9 @@ class ApproveRequest(BaseModel):
|
|||||||
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
||||||
"""Approve supplier invoice for payment"""
|
"""Approve supplier invoice for payment"""
|
||||||
try:
|
try:
|
||||||
invoice = execute_query(
|
invoice = execute_query_single(
|
||||||
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
|
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
|
||||||
(invoice_id,),
|
(invoice_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not invoice:
|
if not invoice:
|
||||||
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
||||||
@ -1448,14 +1412,12 @@ async def send_to_economic(invoice_id: int):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get invoice with lines
|
# Get invoice with lines
|
||||||
invoice = execute_query(
|
invoice = execute_query_single(
|
||||||
"""SELECT si.*, v.economic_supplier_number as vendor_economic_id, v.name as vendor_full_name
|
"""SELECT si.*, v.economic_supplier_number as vendor_economic_id, v.name as vendor_full_name
|
||||||
FROM supplier_invoices si
|
FROM supplier_invoices si
|
||||||
LEFT JOIN vendors v ON si.vendor_id = v.id
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
||||||
WHERE si.id = %s""",
|
WHERE si.id = %s""",
|
||||||
(invoice_id,),
|
(invoice_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not invoice:
|
if not invoice:
|
||||||
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
|
||||||
@ -1467,7 +1429,7 @@ async def send_to_economic(invoice_id: int):
|
|||||||
raise HTTPException(status_code=400, detail="Invoice already sent to e-conomic")
|
raise HTTPException(status_code=400, detail="Invoice already sent to e-conomic")
|
||||||
|
|
||||||
# Get lines
|
# Get lines
|
||||||
lines = execute_query(
|
lines = execute_query_single(
|
||||||
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
|
||||||
(invoice_id,)
|
(invoice_id,)
|
||||||
)
|
)
|
||||||
@ -1505,9 +1467,7 @@ async def send_to_economic(invoice_id: int):
|
|||||||
|
|
||||||
# Get default journal number from settings
|
# Get default journal number from settings
|
||||||
journal_setting = execute_query(
|
journal_setting = execute_query(
|
||||||
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'",
|
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'")
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
journal_number = int(journal_setting['setting_value']) if journal_setting else 1
|
journal_number = int(journal_setting['setting_value']) if journal_setting else 1
|
||||||
|
|
||||||
# Build VAT breakdown from lines
|
# Build VAT breakdown from lines
|
||||||
@ -1634,7 +1594,7 @@ async def get_payment_overview():
|
|||||||
try:
|
try:
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
|
|
||||||
stats = execute_query("""
|
stats = execute_query_single("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_count,
|
COUNT(*) as total_count,
|
||||||
SUM(CASE WHEN paid_date IS NOT NULL THEN 1 ELSE 0 END) as paid_count,
|
SUM(CASE WHEN paid_date IS NOT NULL THEN 1 ELSE 0 END) as paid_count,
|
||||||
@ -1647,7 +1607,7 @@ async def get_payment_overview():
|
|||||||
SUM(CASE WHEN paid_date IS NULL AND due_date < %s THEN total_amount ELSE 0 END) as overdue_amount
|
SUM(CASE WHEN paid_date IS NULL AND due_date < %s THEN total_amount ELSE 0 END) as overdue_amount
|
||||||
FROM supplier_invoices
|
FROM supplier_invoices
|
||||||
WHERE status != 'cancelled'
|
WHERE status != 'cancelled'
|
||||||
""", (today, today, today, today, today), fetchone=True)
|
""", (today, today, today, today, today))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_invoices": stats.get('total_count', 0) if stats else 0,
|
"total_invoices": stats.get('total_count', 0) if stats else 0,
|
||||||
@ -1670,7 +1630,7 @@ async def get_payment_overview():
|
|||||||
async def get_stats_by_vendor():
|
async def get_stats_by_vendor():
|
||||||
"""Get supplier invoice statistics grouped by vendor"""
|
"""Get supplier invoice statistics grouped by vendor"""
|
||||||
try:
|
try:
|
||||||
stats = execute_query("""
|
stats = execute_query_single("""
|
||||||
SELECT
|
SELECT
|
||||||
v.id as vendor_id,
|
v.id as vendor_id,
|
||||||
v.name as vendor_name,
|
v.name as vendor_name,
|
||||||
@ -1762,22 +1722,18 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|||||||
# Check for duplicate file
|
# Check for duplicate file
|
||||||
existing_file = execute_query(
|
existing_file = execute_query(
|
||||||
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
|
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
|
||||||
(checksum,),
|
(checksum,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_file:
|
if existing_file:
|
||||||
temp_path.unlink(missing_ok=True)
|
temp_path.unlink(missing_ok=True)
|
||||||
logger.warning(f"⚠️ Duplicate file detected: {checksum[:16]}...")
|
logger.warning(f"⚠️ Duplicate file detected: {checksum[:16]}...")
|
||||||
|
|
||||||
# Get existing invoice if linked
|
# Get existing invoice if linked
|
||||||
existing_invoice = execute_query(
|
existing_invoice = execute_query_single(
|
||||||
"""SELECT si.* FROM supplier_invoices si
|
"""SELECT si.* FROM supplier_invoices si
|
||||||
JOIN extractions e ON si.extraction_id = e.extraction_id
|
JOIN extractions e ON si.extraction_id = e.extraction_id
|
||||||
WHERE e.file_id = %s""",
|
WHERE e.file_id = %s""",
|
||||||
(existing_file['file_id'],),
|
(existing_file['file_id'],))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "duplicate",
|
"status": "duplicate",
|
||||||
@ -1797,14 +1753,12 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|||||||
logger.info(f"💾 Saved file as: {final_path.name}")
|
logger.info(f"💾 Saved file as: {final_path.name}")
|
||||||
|
|
||||||
# Insert file record
|
# Insert file record
|
||||||
file_record = execute_query(
|
file_record = execute_query_single(
|
||||||
"""INSERT INTO incoming_files
|
"""INSERT INTO incoming_files
|
||||||
(filename, original_filename, file_path, file_size, mime_type, checksum, status)
|
(filename, original_filename, file_path, file_size, mime_type, checksum, status)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, 'processing') RETURNING file_id""",
|
VALUES (%s, %s, %s, %s, %s, %s, 'processing') RETURNING file_id""",
|
||||||
(final_path.name, file.filename, str(final_path), total_size,
|
(final_path.name, file.filename, str(final_path), total_size,
|
||||||
ollama_service._get_mime_type(final_path), checksum),
|
ollama_service._get_mime_type(final_path), checksum))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
file_id = file_record['file_id']
|
file_id = file_record['file_id']
|
||||||
|
|
||||||
# Extract text from file
|
# Extract text from file
|
||||||
@ -1843,16 +1797,14 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
|||||||
logger.info(f"🔍 Checking for duplicate invoice number: {document_number}")
|
logger.info(f"🔍 Checking for duplicate invoice number: {document_number}")
|
||||||
|
|
||||||
# Check 1: Search in local database (supplier_invoices table)
|
# Check 1: Search in local database (supplier_invoices table)
|
||||||
existing_invoice = execute_query(
|
existing_invoice = execute_query_single(
|
||||||
"""SELECT si.id, si.invoice_number, si.created_at, v.name as vendor_name
|
"""SELECT si.id, si.invoice_number, si.created_at, v.name as vendor_name
|
||||||
FROM supplier_invoices si
|
FROM supplier_invoices si
|
||||||
LEFT JOIN vendors v ON v.id = si.vendor_id
|
LEFT JOIN vendors v ON v.id = si.vendor_id
|
||||||
WHERE si.invoice_number = %s
|
WHERE si.invoice_number = %s
|
||||||
ORDER BY si.created_at DESC
|
ORDER BY si.created_at DESC
|
||||||
LIMIT 1""",
|
LIMIT 1""",
|
||||||
(document_number,),
|
(document_number,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_invoice:
|
if existing_invoice:
|
||||||
# DUPLICATE FOUND IN DATABASE
|
# DUPLICATE FOUND IN DATABASE
|
||||||
@ -2055,11 +2007,9 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get file record
|
# Get file record
|
||||||
file_record = execute_query(
|
file_record = execute_query_single(
|
||||||
"SELECT * FROM incoming_files WHERE file_id = %s",
|
"SELECT * FROM incoming_files WHERE file_id = %s",
|
||||||
(file_id,),
|
(file_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail=f"Fil {file_id} ikke fundet")
|
raise HTTPException(status_code=404, detail=f"Fil {file_id} ikke fundet")
|
||||||
@ -2120,11 +2070,9 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
logger.info(f"📋 Using invoice2data template")
|
logger.info(f"📋 Using invoice2data template")
|
||||||
# Try to find vendor from extracted CVR
|
# Try to find vendor from extracted CVR
|
||||||
if extracted_fields.get('vendor_vat'):
|
if extracted_fields.get('vendor_vat'):
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT id FROM vendors WHERE cvr_number = %s",
|
"SELECT id FROM vendors WHERE cvr_number = %s",
|
||||||
(extracted_fields['vendor_vat'],),
|
(extracted_fields['vendor_vat'],))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if vendor:
|
if vendor:
|
||||||
vendor_id = vendor['id']
|
vendor_id = vendor['id']
|
||||||
|
|
||||||
@ -2134,11 +2082,9 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
|
|
||||||
# Fallback: match by issuer name
|
# Fallback: match by issuer name
|
||||||
if vendor_id is None and extracted_fields.get('issuer'):
|
if vendor_id is None and extracted_fields.get('issuer'):
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT id FROM vendors WHERE name ILIKE %s ORDER BY id LIMIT 1",
|
"SELECT id FROM vendors WHERE name ILIKE %s ORDER BY id LIMIT 1",
|
||||||
(extracted_fields['issuer'],),
|
(extracted_fields['issuer'],))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if vendor:
|
if vendor:
|
||||||
vendor_id = vendor['id']
|
vendor_id = vendor['id']
|
||||||
|
|
||||||
@ -2301,11 +2247,9 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
|
|
||||||
# Add warning if no template exists
|
# Add warning if no template exists
|
||||||
if not template_id and vendor_id:
|
if not template_id and vendor_id:
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT name FROM vendors WHERE id = %s",
|
"SELECT name FROM vendors WHERE id = %s",
|
||||||
(vendor_id,),
|
(vendor_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if vendor:
|
if vendor:
|
||||||
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"
|
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@ async def get_contacts(
|
|||||||
FROM contacts c
|
FROM contacts c
|
||||||
{where_sql}
|
{where_sql}
|
||||||
"""
|
"""
|
||||||
count_result = execute_query(count_query, tuple(params), fetchone=True)
|
count_result = execute_query_single(count_query, tuple(params))
|
||||||
total = count_result['count'] if count_result else 0
|
total = count_result['count'] if count_result else 0
|
||||||
|
|
||||||
# Get contacts with company count
|
# Get contacts with company count
|
||||||
@ -71,7 +71,7 @@ async def get_contacts(
|
|||||||
"""
|
"""
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
contacts = execute_query(query, tuple(params)) # Default is fetchall
|
contacts = execute_query_single(query, tuple(params)) # Default is fetchall
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"contacts": contacts or [],
|
"contacts": contacts or [],
|
||||||
@ -99,7 +99,7 @@ async def get_contact(contact_id: int):
|
|||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
contact = execute_query(contact_query, (contact_id,), fetchone=True)
|
contact = execute_query(contact_query, (contact_id,))
|
||||||
|
|
||||||
if not contact:
|
if not contact:
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
@ -114,7 +114,7 @@ async def get_contact(contact_id: int):
|
|||||||
WHERE cc.contact_id = %s
|
WHERE cc.contact_id = %s
|
||||||
ORDER BY cc.is_primary DESC, cu.name
|
ORDER BY cc.is_primary DESC, cu.name
|
||||||
"""
|
"""
|
||||||
companies = execute_query(companies_query, (contact_id,)) # Default is fetchall
|
companies = execute_query_single(companies_query, (contact_id,)) # Default is fetchall
|
||||||
|
|
||||||
contact['companies'] = companies or []
|
contact['companies'] = companies or []
|
||||||
return contact
|
return contact
|
||||||
@ -171,7 +171,7 @@ async def update_contact(contact_id: int, contact: ContactUpdate):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if contact exists
|
# Check if contact exists
|
||||||
existing = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True)
|
existing = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
@ -258,12 +258,12 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if contact exists
|
# Check if contact exists
|
||||||
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True)
|
contact = execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||||
if not contact:
|
if not contact:
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
# Check if company exists
|
# Check if company exists
|
||||||
customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,), fetchone=True)
|
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (link.customer_id,))
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
|||||||
@ -47,11 +47,9 @@ async def get_current_user(
|
|||||||
|
|
||||||
# Get additional user details from database
|
# Get additional user details from database
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
user_details = execute_query(
|
user_details = execute_query_single(
|
||||||
"SELECT email, full_name FROM users WHERE id = %s",
|
"SELECT email, full_name FROM users WHERE id = %s",
|
||||||
(user_id,),
|
(user_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
|
|||||||
@ -85,11 +85,9 @@ class AuthService:
|
|||||||
# Check if token is revoked
|
# Check if token is revoked
|
||||||
jti = payload.get('jti')
|
jti = payload.get('jti')
|
||||||
if jti:
|
if jti:
|
||||||
session = execute_query(
|
session = execute_query_single(
|
||||||
"SELECT revoked FROM sessions WHERE token_jti = %s",
|
"SELECT revoked FROM sessions WHERE token_jti = %s",
|
||||||
(jti,),
|
(jti,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if session and session.get('revoked'):
|
if session and session.get('revoked'):
|
||||||
logger.warning(f"⚠️ Revoked token used: {jti[:10]}...")
|
logger.warning(f"⚠️ Revoked token used: {jti[:10]}...")
|
||||||
return None
|
return None
|
||||||
@ -117,14 +115,12 @@ class AuthService:
|
|||||||
User dict if successful, None otherwise
|
User dict if successful, None otherwise
|
||||||
"""
|
"""
|
||||||
# Get user
|
# Get user
|
||||||
user = execute_query(
|
user = execute_query_single(
|
||||||
"""SELECT id, username, email, password_hash, full_name,
|
"""SELECT id, username, email, password_hash, full_name,
|
||||||
is_active, is_superadmin, failed_login_attempts, locked_until
|
is_active, is_superadmin, failed_login_attempts, locked_until
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = %s OR email = %s""",
|
WHERE username = %s OR email = %s""",
|
||||||
(username, username),
|
(username, username))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"❌ Login failed: User not found - {username}")
|
logger.warning(f"❌ Login failed: User not found - {username}")
|
||||||
@ -213,15 +209,13 @@ class AuthService:
|
|||||||
List of permission codes
|
List of permission codes
|
||||||
"""
|
"""
|
||||||
# Check if user is superadmin first
|
# Check if user is superadmin first
|
||||||
user = execute_query(
|
user = execute_query_single(
|
||||||
"SELECT is_superadmin FROM users WHERE id = %s",
|
"SELECT is_superadmin FROM users WHERE id = %s",
|
||||||
(user_id,),
|
(user_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Superadmins have all permissions
|
# Superadmins have all permissions
|
||||||
if user and user['is_superadmin']:
|
if user and user['is_superadmin']:
|
||||||
all_perms = execute_query("SELECT code FROM permissions")
|
all_perms = execute_query_single("SELECT code FROM permissions")
|
||||||
return [p['code'] for p in all_perms] if all_perms else []
|
return [p['code'] for p in all_perms] if all_perms else []
|
||||||
|
|
||||||
# Get permissions through groups
|
# Get permissions through groups
|
||||||
@ -250,21 +244,19 @@ class AuthService:
|
|||||||
# Superadmins have all permissions
|
# Superadmins have all permissions
|
||||||
user = execute_query(
|
user = execute_query(
|
||||||
"SELECT is_superadmin FROM users WHERE id = %s",
|
"SELECT is_superadmin FROM users WHERE id = %s",
|
||||||
(user_id,),
|
(user_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if user and user['is_superadmin']:
|
if user and user['is_superadmin']:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if user has permission through groups
|
# Check if user has permission through groups
|
||||||
result = execute_query("""
|
result = execute_query_single("""
|
||||||
SELECT COUNT(*) as cnt
|
SELECT COUNT(*) as cnt
|
||||||
FROM permissions p
|
FROM permissions p
|
||||||
JOIN group_permissions gp ON p.id = gp.permission_id
|
JOIN group_permissions gp ON p.id = gp.permission_id
|
||||||
JOIN user_groups ug ON gp.group_id = ug.group_id
|
JOIN user_groups ug ON gp.group_id = ug.group_id
|
||||||
WHERE ug.user_id = %s AND p.code = %s
|
WHERE ug.user_id = %s AND p.code = %s
|
||||||
""", (user_id, permission_code), fetchone=True)
|
""", (user_id, permission_code))
|
||||||
|
|
||||||
return bool(result and result['cnt'] > 0)
|
return bool(result and result['cnt'] > 0)
|
||||||
|
|
||||||
|
|||||||
@ -33,169 +33,28 @@ class Settings(BaseSettings):
|
|||||||
ECONOMIC_READ_ONLY: bool = True
|
ECONOMIC_READ_ONLY: bool = True
|
||||||
ECONOMIC_DRY_RUN: bool = True
|
ECONOMIC_DRY_RUN: bool = True
|
||||||
|
|
||||||
# vTiger CRM Integration
|
# Ollama LLM
|
||||||
|
OLLAMA_ENDPOINT: str = "http://localhost:11434"
|
||||||
|
OLLAMA_MODEL: str = "llama3.2:3b"
|
||||||
|
|
||||||
|
# vTiger Cloud Integration
|
||||||
VTIGER_URL: str = ""
|
VTIGER_URL: str = ""
|
||||||
VTIGER_USERNAME: str = ""
|
VTIGER_USERNAME: str = ""
|
||||||
VTIGER_API_KEY: str = ""
|
VTIGER_API_KEY: str = ""
|
||||||
VTIGER_PASSWORD: str = "" # Fallback hvis API key ikke virker
|
|
||||||
|
|
||||||
# Simply-CRM Integration (Legacy System med CVR data)
|
# Simply-CRM (Old vTiger On-Premise)
|
||||||
OLD_VTIGER_URL: str = "https://bmcnetworks.simply-crm.dk"
|
OLD_VTIGER_URL: str = ""
|
||||||
OLD_VTIGER_USERNAME: str = "ct"
|
OLD_VTIGER_USERNAME: str = ""
|
||||||
OLD_VTIGER_ACCESS_KEY: str = "b00ff2b7c08d591"
|
OLD_VTIGER_API_KEY: str = ""
|
||||||
|
|
||||||
# Time Tracking Module - vTiger Integration (Isoleret)
|
# Simply-CRM (Separate System)
|
||||||
TIMETRACKING_VTIGER_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til vTiger
|
SIMPLYCRM_URL: str = ""
|
||||||
TIMETRACKING_VTIGER_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at synkronisere
|
SIMPLYCRM_USERNAME: str = ""
|
||||||
|
SIMPLYCRM_API_KEY: str = ""
|
||||||
# Time Tracking Module - Order Management
|
|
||||||
TIMETRACKING_ADMIN_UNLOCK_CODE: str = "" # Kode for at låse eksporterede ordrer op
|
|
||||||
|
|
||||||
# Time Tracking Module - e-conomic Integration (Isoleret)
|
|
||||||
TIMETRACKING_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til e-conomic
|
|
||||||
TIMETRACKING_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at eksportere
|
|
||||||
TIMETRACKING_EXPORT_TYPE: str = "draft" # draft|booked (draft er sikrest)
|
|
||||||
|
|
||||||
# Time Tracking Module - Business Logic
|
|
||||||
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback)
|
|
||||||
TIMETRACKING_AUTO_ROUND: bool = True # Auto-afrund til nærmeste 0.5 time
|
|
||||||
TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
|
|
||||||
TIMETRACKING_ROUND_METHOD: str = "up" # up (op til), nearest (nærmeste), down (ned til)
|
|
||||||
TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve)
|
|
||||||
|
|
||||||
# Ollama AI Integration
|
|
||||||
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
|
||||||
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
|
|
||||||
|
|
||||||
# Ticket System Module
|
|
||||||
TICKET_ENABLED: bool = True
|
|
||||||
TICKET_EMAIL_INTEGRATION: bool = False # 🚨 SAFETY: Disable email-to-ticket until configured
|
|
||||||
TICKET_AUTO_ASSIGN: bool = False # Auto-assign tickets based on rules
|
|
||||||
TICKET_DEFAULT_PRIORITY: str = "normal" # low|normal|high|urgent
|
|
||||||
TICKET_REQUIRE_CUSTOMER: bool = False # Allow tickets without customer link
|
|
||||||
TICKET_NOTIFICATION_ENABLED: bool = False # Notify on status changes
|
|
||||||
|
|
||||||
# Ticket System - e-conomic Integration
|
|
||||||
TICKET_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Block all writes to e-conomic
|
|
||||||
TICKET_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log without executing
|
|
||||||
TICKET_ECONOMIC_AUTO_EXPORT: bool = False # Auto-export billable worklog
|
|
||||||
|
|
||||||
# Email System Configuration
|
|
||||||
EMAIL_TO_TICKET_ENABLED: bool = False # 🚨 SAFETY: Disable auto-processing until configured
|
|
||||||
|
|
||||||
# Email Fetching (IMAP)
|
|
||||||
USE_GRAPH_API: bool = False # Use Microsoft Graph API instead of IMAP (preferred)
|
|
||||||
IMAP_SERVER: str = "outlook.office365.com"
|
|
||||||
IMAP_PORT: int = 993
|
|
||||||
IMAP_USE_SSL: bool = True
|
|
||||||
IMAP_USERNAME: str = ""
|
|
||||||
IMAP_PASSWORD: str = ""
|
|
||||||
IMAP_FOLDER: str = "INBOX"
|
|
||||||
IMAP_READ_ONLY: bool = True # 🚨 SAFETY: Never mark emails as read or modify mailbox
|
|
||||||
|
|
||||||
# Microsoft Graph API (OAuth2)
|
|
||||||
GRAPH_TENANT_ID: str = ""
|
|
||||||
GRAPH_CLIENT_ID: str = ""
|
|
||||||
GRAPH_CLIENT_SECRET: str = ""
|
|
||||||
GRAPH_USER_EMAIL: str = "" # Email account to monitor
|
|
||||||
|
|
||||||
# Email Processing
|
|
||||||
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5 # Background job frequency
|
|
||||||
EMAIL_MAX_FETCH_PER_RUN: int = 50 # Limit emails per processing cycle
|
|
||||||
EMAIL_RETENTION_DAYS: int = 90 # Days to keep emails before soft delete
|
|
||||||
|
|
||||||
# Email Classification (AI)
|
|
||||||
EMAIL_AI_ENABLED: bool = True
|
|
||||||
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7 # Minimum confidence for auto-processing
|
|
||||||
EMAIL_AUTO_CLASSIFY: bool = True # Run AI classification on new emails
|
|
||||||
|
|
||||||
# Email Rules Engine (DEPRECATED - Use workflows instead)
|
|
||||||
EMAIL_RULES_ENABLED: bool = False # 🚨 LEGACY: Disabled by default, use EMAIL_WORKFLOWS_ENABLED instead
|
|
||||||
EMAIL_RULES_AUTO_PROCESS: bool = False # 🚨 SAFETY: Require manual approval initially
|
|
||||||
|
|
||||||
# Email Workflows (RECOMMENDED)
|
|
||||||
EMAIL_WORKFLOWS_ENABLED: bool = True # Enable automated workflows based on classification (replaces rules)
|
|
||||||
|
|
||||||
# Company Info
|
|
||||||
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
|
|
||||||
|
|
||||||
# File Upload
|
|
||||||
UPLOAD_DIR: str = "uploads"
|
|
||||||
MAX_FILE_SIZE_MB: int = 50
|
|
||||||
ALLOWED_EXTENSIONS: List[str] = [".pdf", ".png", ".jpg", ".jpeg", ".txt", ".csv"]
|
|
||||||
|
|
||||||
# Module System Configuration
|
|
||||||
MODULES_ENABLED: bool = True # Enable/disable entire module system
|
|
||||||
MODULES_DIR: str = "app/modules" # Directory for dynamic modules
|
|
||||||
MODULES_AUTO_RELOAD: bool = True # Hot-reload modules on changes (dev only)
|
|
||||||
|
|
||||||
# Backup System Configuration
|
|
||||||
# Safety switches (default to safe mode)
|
|
||||||
BACKUP_ENABLED: bool = False # 🚨 SAFETY: Disable backups until explicitly enabled
|
|
||||||
BACKUP_DRY_RUN: bool = True # 🚨 SAFETY: Log operations without executing
|
|
||||||
BACKUP_READ_ONLY: bool = True # 🚨 SAFETY: Allow reads but block destructive operations
|
|
||||||
|
|
||||||
# Backup formats
|
|
||||||
DB_DAILY_FORMAT: str = "dump" # dump (compressed) or sql (plain text)
|
|
||||||
DB_MONTHLY_FORMAT: str = "sql" # Monthly backups use plain SQL for readability
|
|
||||||
|
|
||||||
# Backup scope
|
|
||||||
BACKUP_INCLUDE_UPLOADS: bool = True # Include uploads/ directory
|
|
||||||
BACKUP_INCLUDE_LOGS: bool = True # Include logs/ directory
|
|
||||||
BACKUP_INCLUDE_DATA: bool = True # Include data/ directory (templates, configs)
|
|
||||||
|
|
||||||
# Storage configuration
|
|
||||||
BACKUP_STORAGE_PATH: str = "/opt/backups" # Production: /opt/backups, Dev: ./backups
|
|
||||||
BACKUP_MAX_SIZE_GB: int = 50 # Maximum total backup storage size
|
|
||||||
STORAGE_WARNING_THRESHOLD_PCT: int = 80 # Warn when storage exceeds this percentage
|
|
||||||
|
|
||||||
# Rotation policy
|
|
||||||
RETENTION_DAYS: int = 30 # Keep daily backups for 30 days
|
|
||||||
MONTHLY_KEEP_MONTHS: int = 12 # Keep monthly backups for 12 months
|
|
||||||
|
|
||||||
# Offsite configuration (SFTP/SSH)
|
|
||||||
OFFSITE_ENABLED: bool = False # 🚨 SAFETY: Disable offsite uploads until configured
|
|
||||||
OFFSITE_WEEKLY_DAY: str = "sunday" # Day for weekly offsite upload (monday-sunday)
|
|
||||||
OFFSITE_RETRY_MAX_ATTEMPTS: int = 3 # Maximum retry attempts for failed uploads
|
|
||||||
OFFSITE_RETRY_DELAY_HOURS: int = 1 # Hours between retry attempts
|
|
||||||
SFTP_HOST: str = "" # SFTP server hostname or IP
|
|
||||||
SFTP_PORT: int = 22 # SFTP server port
|
|
||||||
SFTP_USER: str = "" # SFTP username
|
|
||||||
SFTP_PASSWORD: str = "" # SFTP password (if not using SSH key)
|
|
||||||
SSH_KEY_PATH: str = "" # Path to SSH private key (preferred over password)
|
|
||||||
SFTP_REMOTE_PATH: str = "/backups/bmc_hub" # Remote directory for backups
|
|
||||||
|
|
||||||
# Notification configuration (Mattermost)
|
|
||||||
MATTERMOST_ENABLED: bool = False # 🚨 SAFETY: Disable until webhook configured
|
|
||||||
MATTERMOST_WEBHOOK_URL: str = "" # Mattermost incoming webhook URL
|
|
||||||
MATTERMOST_CHANNEL: str = "backups" # Channel name for backup notifications
|
|
||||||
NOTIFY_ON_FAILURE: bool = True # Send notification on backup/offsite failures
|
|
||||||
NOTIFY_ON_SUCCESS_OFFSITE: bool = True # Send notification on successful offsite upload
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
extra = "ignore" # Ignore extra fields from .env
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
def get_module_config(module_name: str, key: str, default=None):
|
|
||||||
"""
|
|
||||||
Hent modul-specifik konfiguration fra miljøvariabel
|
|
||||||
|
|
||||||
Pattern: MODULES__{MODULE_NAME}__{KEY}
|
|
||||||
Eksempel: MODULES__MY_MODULE__API_KEY
|
|
||||||
|
|
||||||
Args:
|
|
||||||
module_name: Navn på modul (fx "my_module")
|
|
||||||
key: Config key (fx "API_KEY")
|
|
||||||
default: Default værdi hvis ikke sat
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Konfigurationsværdi eller default
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
env_key = f"MODULES__{module_name.upper()}__{key.upper()}"
|
|
||||||
return os.getenv(env_key, default)
|
|
||||||
|
|||||||
@ -55,37 +55,21 @@ def get_db():
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def execute_query(query: str, params: Optional[tuple] = None, fetchone: bool = False):
|
def execute_query(query: str, params: tuple = None, fetch: bool = True):
|
||||||
"""
|
"""Execute a SQL query and return results"""
|
||||||
Execute a SQL query and return results
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: SQL query string
|
|
||||||
params: Query parameters tuple
|
|
||||||
fetchone: If True, return single row dict, otherwise list of dicts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Single dict if fetchone=True, otherwise list of dicts
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
cursor.execute(query, params or ())
|
cursor.execute(query, params)
|
||||||
|
|
||||||
# Check if this is a write operation (INSERT, UPDATE, DELETE)
|
# Auto-detect write operations and commit
|
||||||
query_upper = query.strip().upper()
|
query_upper = query.strip().upper()
|
||||||
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE'])
|
if query_upper.startswith(('INSERT', 'UPDATE', 'DELETE')):
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
if fetchone:
|
if fetch:
|
||||||
row = cursor.fetchone()
|
return cursor.fetchall()
|
||||||
if is_write:
|
return cursor.rowcount
|
||||||
conn.commit()
|
|
||||||
return dict(row) if row else None
|
|
||||||
else:
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
if is_write:
|
|
||||||
conn.commit()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Query error: {e}")
|
logger.error(f"Query error: {e}")
|
||||||
@ -94,35 +78,15 @@ def execute_query(query: str, params: Optional[tuple] = None, fetchone: bool = F
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def execute_insert(query: str, params: tuple = ()) -> Optional[int]:
|
def execute_insert(query: str, params: tuple = None):
|
||||||
"""
|
"""Execute INSERT query and return new ID"""
|
||||||
Execute an INSERT query and return last row id
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: SQL INSERT query (will add RETURNING id if not present)
|
|
||||||
params: Query parameters tuple
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Last inserted row ID or None
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
# PostgreSQL requires RETURNING clause
|
|
||||||
if "RETURNING" not in query.upper():
|
|
||||||
query = query.rstrip(";") + " RETURNING id"
|
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
result = cursor.fetchone()
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
result = cursor.fetchone()
|
||||||
# If result exists, return the first column value (typically ID)
|
return result['id'] if result and 'id' in result else None
|
||||||
if result:
|
|
||||||
# If it's a dict, get first value
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return list(result.values())[0]
|
|
||||||
# If it's a tuple/list, get first element
|
|
||||||
return result[0]
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Insert error: {e}")
|
logger.error(f"Insert error: {e}")
|
||||||
@ -131,24 +95,14 @@ def execute_insert(query: str, params: tuple = ()) -> Optional[int]:
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def execute_update(query: str, params: tuple = ()) -> int:
|
def execute_update(query: str, params: tuple = None):
|
||||||
"""
|
"""Execute UPDATE/DELETE query and return affected rows"""
|
||||||
Execute an UPDATE/DELETE query and return affected rows
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: SQL UPDATE/DELETE query
|
|
||||||
params: Query parameters tuple
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of affected rows
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
rowcount = cursor.rowcount
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return rowcount
|
return cursor.rowcount
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Update error: {e}")
|
logger.error(f"Update error: {e}")
|
||||||
@ -157,66 +111,7 @@ def execute_update(query: str, params: tuple = ()) -> int:
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def execute_module_migration(module_name: str, migration_sql: str) -> bool:
|
def execute_query_single(query: str, params: tuple = None):
|
||||||
"""
|
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
|
||||||
Kør en migration for et specifikt modul
|
result = execute_query(query, params)
|
||||||
|
return result[0] if result and len(result) > 0 else None
|
||||||
Args:
|
|
||||||
module_name: Navn på modulet
|
|
||||||
migration_sql: SQL migration kode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True hvis success, False ved fejl
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
||||||
# Sikr at module_migrations tabel eksisterer
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS module_migrations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
module_name VARCHAR(100) NOT NULL,
|
|
||||||
migration_name VARCHAR(255) NOT NULL,
|
|
||||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
success BOOLEAN DEFAULT TRUE,
|
|
||||||
error_message TEXT,
|
|
||||||
UNIQUE(module_name, migration_name)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Kør migration
|
|
||||||
cursor.execute(migration_sql)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
logger.info(f"✅ Migration for {module_name} success")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
logger.error(f"❌ Migration failed for {module_name}: {e}")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
release_db_connection(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def check_module_table_exists(table_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check om en modul tabel eksisterer
|
|
||||||
|
|
||||||
Args:
|
|
||||||
table_name: Tabel navn (fx "my_module_customers")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True hvis tabellen eksisterer
|
|
||||||
"""
|
|
||||||
query = """
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = %s
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (table_name,), fetchone=True)
|
|
||||||
if result and isinstance(result, dict):
|
|
||||||
return result.get('exists', False)
|
|
||||||
return False
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from typing import List, Optional, Dict
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_query_single
|
||||||
from app.services.cvr_service import get_cvr_service
|
from app.services.cvr_service import get_cvr_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -82,13 +82,24 @@ async def list_customers(
|
|||||||
source: Filter by source ('vtiger' or 'local')
|
source: Filter by source ('vtiger' or 'local')
|
||||||
is_active: Filter by active status
|
is_active: Filter by active status
|
||||||
"""
|
"""
|
||||||
# Build query
|
# Build query with primary contact info
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
c.*,
|
c.*,
|
||||||
COUNT(DISTINCT cc.contact_id) as contact_count
|
COUNT(DISTINCT cc.contact_id) as contact_count,
|
||||||
|
CONCAT(pc.first_name, ' ', pc.last_name) as contact_name,
|
||||||
|
pc.email as contact_email,
|
||||||
|
COALESCE(pc.mobile, pc.phone) as contact_phone
|
||||||
FROM customers c
|
FROM customers c
|
||||||
LEFT JOIN contact_companies cc ON cc.customer_id = c.id
|
LEFT JOIN contact_companies cc ON cc.customer_id = c.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT con.first_name, con.last_name, con.email, con.phone, con.mobile
|
||||||
|
FROM contact_companies ccomp
|
||||||
|
JOIN contacts con ON ccomp.contact_id = con.id
|
||||||
|
WHERE ccomp.customer_id = c.id
|
||||||
|
ORDER BY ccomp.is_primary DESC, con.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) pc ON true
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params = []
|
params = []
|
||||||
@ -117,7 +128,7 @@ async def list_customers(
|
|||||||
params.append(is_active)
|
params.append(is_active)
|
||||||
|
|
||||||
query += """
|
query += """
|
||||||
GROUP BY c.id
|
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
|
||||||
ORDER BY c.name
|
ORDER BY c.name
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
"""
|
"""
|
||||||
@ -148,7 +159,7 @@ async def list_customers(
|
|||||||
count_query += " AND is_active = %s"
|
count_query += " AND is_active = %s"
|
||||||
count_params.append(is_active)
|
count_params.append(is_active)
|
||||||
|
|
||||||
count_result = execute_query(count_query, tuple(count_params), fetchone=True)
|
count_result = execute_query_single(count_query, tuple(count_params))
|
||||||
total = count_result['total'] if count_result else 0
|
total = count_result['total'] if count_result else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -163,21 +174,17 @@ async def list_customers(
|
|||||||
async def get_customer(customer_id: int):
|
async def get_customer(customer_id: int):
|
||||||
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
||||||
# Get customer
|
# Get customer
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT * FROM customers WHERE id = %s",
|
"SELECT * FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
# Get contact count
|
# Get contact count
|
||||||
contact_count_result = execute_query(
|
contact_count_result = execute_query_single(
|
||||||
"SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s",
|
"SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
contact_count = contact_count_result['count'] if contact_count_result else 0
|
contact_count = contact_count_result['count'] if contact_count_result else 0
|
||||||
|
|
||||||
@ -230,11 +237,9 @@ async def create_customer(customer: CustomerCreate):
|
|||||||
logger.info(f"✅ Created customer {customer_id}: {customer.name}")
|
logger.info(f"✅ Created customer {customer_id}: {customer.name}")
|
||||||
|
|
||||||
# Fetch and return created customer
|
# Fetch and return created customer
|
||||||
created = execute_query(
|
created = execute_query_single(
|
||||||
"SELECT * FROM customers WHERE id = %s",
|
"SELECT * FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -246,11 +251,9 @@ async def create_customer(customer: CustomerCreate):
|
|||||||
async def update_customer(customer_id: int, update: CustomerUpdate):
|
async def update_customer(customer_id: int, update: CustomerUpdate):
|
||||||
"""Update customer information"""
|
"""Update customer information"""
|
||||||
# Verify customer exists
|
# Verify customer exists
|
||||||
existing = execute_query(
|
existing = execute_query_single(
|
||||||
"SELECT id FROM customers WHERE id = %s",
|
"SELECT id FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
@ -275,11 +278,9 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
|
|||||||
logger.info(f"✅ Updated customer {customer_id}")
|
logger.info(f"✅ Updated customer {customer_id}")
|
||||||
|
|
||||||
# Fetch and return updated customer
|
# Fetch and return updated customer
|
||||||
updated = execute_query(
|
updated = execute_query_single(
|
||||||
"SELECT * FROM customers WHERE id = %s",
|
"SELECT * FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -294,11 +295,9 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict):
|
|||||||
locked = lock_request.get('locked', False)
|
locked = lock_request.get('locked', False)
|
||||||
|
|
||||||
# Get customer
|
# Get customer
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT id, name FROM customers WHERE id = %s",
|
"SELECT id, name FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
@ -327,7 +326,7 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict):
|
|||||||
@router.get("/customers/{customer_id}/contacts")
|
@router.get("/customers/{customer_id}/contacts")
|
||||||
async def get_customer_contacts(customer_id: int):
|
async def get_customer_contacts(customer_id: int):
|
||||||
"""Get all contacts for a specific customer"""
|
"""Get all contacts for a specific customer"""
|
||||||
rows = execute_query("""
|
rows = execute_query_single("""
|
||||||
SELECT
|
SELECT
|
||||||
c.*,
|
c.*,
|
||||||
cc.is_primary,
|
cc.is_primary,
|
||||||
@ -348,9 +347,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
# Verify customer exists
|
# Verify customer exists
|
||||||
customer = execute_query(
|
customer = execute_query(
|
||||||
"SELECT id FROM customers WHERE id = %s",
|
"SELECT id FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
@ -383,11 +380,9 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
||||||
|
|
||||||
# Fetch and return created contact
|
# Fetch and return created contact
|
||||||
created = execute_query(
|
created = execute_query_single(
|
||||||
"SELECT * FROM contacts WHERE id = %s",
|
"SELECT * FROM contacts WHERE id = %s",
|
||||||
(contact_id,),
|
(contact_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -420,11 +415,9 @@ async def get_customer_subscriptions(customer_id: int):
|
|||||||
from app.services.vtiger_service import get_vtiger_service
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
|
|
||||||
# Get customer with vTiger ID
|
# Get customer with vTiger ID
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT id, name, vtiger_id FROM customers WHERE id = %s",
|
"SELECT id, name, vtiger_id FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
@ -506,20 +499,29 @@ async def get_customer_subscriptions(customer_id: int):
|
|||||||
# Note: Simply-CRM returns one row per line item, so we need to group them
|
# Note: Simply-CRM returns one row per line item, so we need to group them
|
||||||
query = f"SELECT * FROM SalesOrder WHERE account_id='{simplycrm_account_id}';"
|
query = f"SELECT * FROM SalesOrder WHERE account_id='{simplycrm_account_id}';"
|
||||||
all_simplycrm_orders = await simplycrm.query(query)
|
all_simplycrm_orders = await simplycrm.query(query)
|
||||||
|
logger.info(f"🔍 Simply-CRM raw query returned {len(all_simplycrm_orders or [])} orders for account {simplycrm_account_id}")
|
||||||
|
|
||||||
# Group line items by order ID
|
# Group line items by order ID
|
||||||
# Filter: Only include orders with recurring_frequency (otherwise not subscription)
|
# Filter: Only include orders with recurring_frequency (otherwise not subscription)
|
||||||
orders_dict = {}
|
orders_dict = {}
|
||||||
|
filtered_closed = 0
|
||||||
|
filtered_no_freq = 0
|
||||||
for row in (all_simplycrm_orders or []):
|
for row in (all_simplycrm_orders or []):
|
||||||
status = row.get('sostatus', '').lower()
|
status = row.get('sostatus', '').lower()
|
||||||
if status in ['closed', 'cancelled']:
|
if status in ['closed', 'cancelled']:
|
||||||
|
filtered_closed += 1
|
||||||
|
logger.debug(f" ⏭️ Skipping closed order: {row.get('subject', 'N/A')} ({status})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# MUST have recurring_frequency to be a subscription
|
# MUST have recurring_frequency to be a subscription
|
||||||
recurring_frequency = row.get('recurring_frequency', '').strip()
|
recurring_frequency = row.get('recurring_frequency', '').strip()
|
||||||
if not recurring_frequency:
|
if not recurring_frequency:
|
||||||
|
filtered_no_freq += 1
|
||||||
|
logger.debug(f" ⏭️ Skipping order without frequency: {row.get('subject', 'N/A')}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logger.info(f" ✅ Including order: {row.get('subject', 'N/A')} - {recurring_frequency} ({status})")
|
||||||
|
|
||||||
order_id = row.get('id')
|
order_id = row.get('id')
|
||||||
if order_id not in orders_dict:
|
if order_id not in orders_dict:
|
||||||
# First occurrence - create order object
|
# First occurrence - create order object
|
||||||
@ -548,7 +550,7 @@ async def get_customer_subscriptions(customer_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
simplycrm_sales_orders = list(orders_dict.values())
|
simplycrm_sales_orders = list(orders_dict.values())
|
||||||
logger.info(f"📥 Found {len(simplycrm_sales_orders)} unique open sales orders in Simply-CRM")
|
logger.info(f"📥 Found {len(simplycrm_sales_orders)} unique recurring orders in Simply-CRM (filtered out: {filtered_closed} closed, {filtered_no_freq} without frequency)")
|
||||||
else:
|
else:
|
||||||
logger.info(f"ℹ️ No Simply-CRM account found for '{customer_name}'")
|
logger.info(f"ℹ️ No Simply-CRM account found for '{customer_name}'")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -608,9 +610,7 @@ async def create_subscription(customer_id: int, subscription: SubscriptionCreate
|
|||||||
# Get customer's vTiger ID
|
# Get customer's vTiger ID
|
||||||
customer = execute_query(
|
customer = execute_query(
|
||||||
"SELECT vtiger_id FROM customers WHERE id = %s",
|
"SELECT vtiger_id FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer or not customer.get('vtiger_id'):
|
if not customer or not customer.get('vtiger_id'):
|
||||||
raise HTTPException(status_code=404, detail="Customer not linked to vTiger")
|
raise HTTPException(status_code=404, detail="Customer not linked to vTiger")
|
||||||
@ -686,11 +686,9 @@ async def delete_subscription(subscription_id: str, customer_id: int = None):
|
|||||||
try:
|
try:
|
||||||
# Check if subscriptions are locked for this customer (if customer_id provided)
|
# Check if subscriptions are locked for this customer (if customer_id provided)
|
||||||
if customer_id:
|
if customer_id:
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT subscriptions_locked FROM customers WHERE id = %s",
|
"SELECT subscriptions_locked FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if customer and customer.get('subscriptions_locked'):
|
if customer and customer.get('subscriptions_locked'):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ templates = Jinja2Templates(directory="app")
|
|||||||
@router.get("/customers", response_class=HTMLResponse)
|
@router.get("/customers", response_class=HTMLResponse)
|
||||||
async def customers_page(request: Request):
|
async def customers_page(request: Request):
|
||||||
"""
|
"""
|
||||||
Render the customers list page
|
Render the customers page
|
||||||
"""
|
"""
|
||||||
return templates.TemplateResponse("customers/frontend/customers.html", {"request": request})
|
return templates.TemplateResponse("customers/frontend/customers.html", {"request": request})
|
||||||
|
|
||||||
|
|||||||
@ -215,7 +215,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#subscriptions">
|
<a class="nav-link" data-bs-toggle="tab" href="#subscriptions">
|
||||||
<i class="bi bi-arrow-repeat"></i>Abonnementer
|
<i class="bi bi-arrow-repeat"></i>Abonnnents tjek
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@ -353,7 +353,7 @@
|
|||||||
<!-- Subscriptions Tab -->
|
<!-- Subscriptions Tab -->
|
||||||
<div class="tab-pane fade" id="subscriptions">
|
<div class="tab-pane fade" id="subscriptions">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h5 class="fw-bold mb-0">Abonnementer & Salgsordre</h5>
|
<h5 class="fw-bold mb-0">Abonnnents tjek</h5>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-success btn-sm" onclick="showCreateSubscriptionModal()">
|
<button class="btn btn-success btn-sm" onclick="showCreateSubscriptionModal()">
|
||||||
<i class="bi bi-plus-circle me-2"></i>Opret Abonnement
|
<i class="bi bi-plus-circle me-2"></i>Opret Abonnement
|
||||||
@ -860,11 +860,13 @@ function renderSalesOrdersList(orders) {
|
|||||||
const lineItems = order.lineItems || [];
|
const lineItems = order.lineItems || [];
|
||||||
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
|
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
|
||||||
const total = parseFloat(order.hdnGrandTotal || 0);
|
const total = parseFloat(order.hdnGrandTotal || 0);
|
||||||
|
const recordId = order.id.includes('x') ? order.id.split('x')[1] : order.id;
|
||||||
|
const simplycrmUrl = `https://bmcnetworks.simply-crm.dk/index.php?module=SalesOrder&view=Detail&record=${recordId}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
|
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
|
||||||
<div class="fw-bold d-flex align-items-center">
|
<div class="fw-bold d-flex align-items-center">
|
||||||
<i class="bi bi-chevron-right me-2 text-success" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
|
<i class="bi bi-chevron-right me-2 text-success" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
|
||||||
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
|
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
|
||||||
@ -874,14 +876,23 @@ function renderSalesOrdersList(orders) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end ms-3">
|
<div class="text-end ms-3">
|
||||||
<div class="badge bg-${getStatusColor(order.sostatus)} mb-1">${escapeHtml(order.sostatus || 'Open')}</div>
|
<div class="btn-group btn-group-sm mb-2" role="group">
|
||||||
<div class="fw-bold text-success">${total.toFixed(2)} DKK</div>
|
<a href="${simplycrmUrl}" target="_blank" class="btn btn-outline-success" title="Åbn i Simply-CRM">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i> Simply-CRM
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="badge bg-${getStatusColor(order.sostatus)} mb-1">${escapeHtml(order.sostatus || 'Open')}</div>
|
||||||
|
<div class="fw-bold text-success">${total.toFixed(2)} DKK</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
|
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
|
||||||
${order.recurring_frequency ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
|
${order.recurring_frequency ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
|
||||||
${order.last_recurring_date ? `<span class="badge bg-info text-dark"><i class="bi bi-calendar-check me-1"></i>Last: ${formatDate(order.last_recurring_date)}</span>` : ''}
|
${order.last_recurring_date ? `<span class="badge bg-info text-dark"><i class="bi bi-calendar-check me-1"></i>Sidste: ${formatDate(order.last_recurring_date)}</span>` : ''}
|
||||||
|
${order.start_period && order.end_period ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-range me-1"></i>${formatDate(order.start_period)} - ${formatDate(order.end_period)}</span>` : ''}
|
||||||
|
${order.start_period && !order.end_period ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(order.start_period)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${hasLineItems ? `
|
${hasLineItems ? `
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover, .filter-btn.active {
|
.filter-btn:hover, .filter-btn.active {
|
||||||
@ -20,38 +19,6 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn {
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:hover:not(:disabled) {
|
|
||||||
background: var(--accent-light);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -62,29 +29,16 @@
|
|||||||
<p class="text-muted mb-0">Administrer dine kunder</p>
|
<p class="text-muted mb-0">Administrer dine kunder</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3">
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, email...">
|
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde...">
|
||||||
<button class="btn btn-primary" onclick="showCreateCustomerModal()">
|
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
<div class="mb-4 d-flex gap-2">
|
||||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
<button class="filter-btn active">Alle Kunder</button>
|
||||||
Alle Kunder <span id="countAll" class="ms-1"></span>
|
<button class="filter-btn">Aktive</button>
|
||||||
</button>
|
<button class="filter-btn">Inaktive</button>
|
||||||
<button class="filter-btn" data-filter="active" onclick="setFilter('active')">
|
<button class="filter-btn">VIP</button>
|
||||||
Aktive <span id="countActive" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="inactive" onclick="setFilter('inactive')">
|
|
||||||
Inaktive <span id="countInactive" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="vtiger" onclick="setFilter('vtiger')">
|
|
||||||
<i class="bi bi-cloud me-1"></i>vTiger
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="local" onclick="setFilter('local')">
|
|
||||||
<i class="bi bi-hdd me-1"></i>Lokal
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
@ -93,17 +47,16 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Virksomhed</th>
|
<th>Virksomhed</th>
|
||||||
<th>Kontakt Info</th>
|
<th>Kontakt</th>
|
||||||
<th>CVR</th>
|
<th>CVR</th>
|
||||||
<th>Kilde</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Kontakter</th>
|
<th>E-mail</th>
|
||||||
<th class="text-end">Handlinger</th>
|
<th class="text-end">Handlinger</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="customersTableBody">
|
<tbody id="customersTableBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-center py-5">
|
<td colspan="6" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@ -112,407 +65,181 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
<!-- Pagination -->
|
<div class="text-muted" id="customerCount">Loading...</div>
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
<nav>
|
||||||
<div class="text-muted small">
|
<ul class="pagination mb-0" id="pagination"></ul>
|
||||||
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> kunder
|
</nav>
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="pagination-btn" id="prevBtn" onclick="previousPage()">
|
|
||||||
<i class="bi bi-chevron-left"></i> Forrige
|
|
||||||
</button>
|
|
||||||
<button class="pagination-btn" id="nextBtn" onclick="nextPage()">
|
|
||||||
Næste <i class="bi bi-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Customer Modal -->
|
|
||||||
<div class="modal fade" id="createCustomerModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Opret Ny Kunde</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createCustomerForm">
|
|
||||||
<!-- CVR Lookup Section -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="form-label">CVR-nummer</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="cvrInput" placeholder="12345678" maxlength="8">
|
|
||||||
<button class="btn btn-primary" type="button" id="cvrLookupBtn" onclick="lookupCVR()">
|
|
||||||
<i class="bi bi-search me-2"></i>Søg CVR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Indtast CVR-nummer for automatisk udfyldning</div>
|
|
||||||
<div id="cvrLookupStatus" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4">
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<label class="form-label">Virksomhedsnavn <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="nameInput" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Adresse</label>
|
|
||||||
<input type="text" class="form-control" id="addressInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Postnummer</label>
|
|
||||||
<input type="text" class="form-control" id="postalCodeInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">By</label>
|
|
||||||
<input type="text" class="form-control" id="cityInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" id="emailInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Telefon</label>
|
|
||||||
<input type="text" class="form-control" id="phoneInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Hjemmeside</label>
|
|
||||||
<input type="url" class="form-control" id="websiteInput" placeholder="https://">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="isActiveInput" checked>
|
|
||||||
<label class="form-check-label" for="isActiveInput">
|
|
||||||
Aktiv kunde
|
|
||||||
</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="createCustomer()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
<script>
|
||||||
let currentPage = 0;
|
let currentPage = 1;
|
||||||
let pageSize = 20;
|
const pageSize = 50;
|
||||||
let currentFilter = 'all';
|
|
||||||
let searchQuery = '';
|
|
||||||
let totalCustomers = 0;
|
let totalCustomers = 0;
|
||||||
|
let searchTerm = '';
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
// Load customers on page load
|
// Load customers on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
|
|
||||||
|
// Setup search with debounce
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
let searchTimeout;
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
searchQuery = e.target.value;
|
searchTerm = e.target.value;
|
||||||
currentPage = 0;
|
loadCustomers(1);
|
||||||
console.log('🔍 Searching for:', searchQuery);
|
|
||||||
loadCustomers();
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cmd+K / Ctrl+K keyboard shortcut (outside DOMContentLoaded so it works everywhere)
|
async function loadCustomers(page = 1) {
|
||||||
document.addEventListener('keydown', (e) => {
|
currentPage = page;
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
const offset = (page - 1) * pageSize;
|
||||||
e.preventDefault();
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus();
|
|
||||||
searchInput.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setFilter(filter) {
|
|
||||||
currentFilter = filter;
|
|
||||||
currentPage = 0;
|
|
||||||
|
|
||||||
// Update active button
|
|
||||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
event.target.classList.add('active');
|
|
||||||
|
|
||||||
loadCustomers();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCustomers() {
|
|
||||||
const tbody = document.getElementById('customersTableBody');
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build query parameters
|
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
|
||||||
let params = new URLSearchParams({
|
if (searchTerm) {
|
||||||
limit: pageSize,
|
url += `&search=${encodeURIComponent(searchTerm)}`;
|
||||||
offset: currentPage * pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
params.append('search', searchQuery);
|
|
||||||
console.log('📤 Sending search query:', searchQuery);
|
|
||||||
}
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
if (currentFilter === 'active') {
|
|
||||||
params.append('is_active', 'true');
|
|
||||||
} else if (currentFilter === 'inactive') {
|
|
||||||
params.append('is_active', 'false');
|
|
||||||
} else if (currentFilter === 'vtiger' || currentFilter === 'local') {
|
|
||||||
params.append('source', currentFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/customers?${params}`);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
totalCustomers = data.total;
|
totalCustomers = data.total;
|
||||||
displayCustomers(data.customers);
|
renderCustomers(data.customers);
|
||||||
updatePagination(data.total);
|
renderPagination();
|
||||||
|
updateCount();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load customers:', error);
|
console.error('Error loading customers:', error);
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-danger">Kunne ikke indlæse kunder</td></tr>';
|
document.getElementById('customersTableBody').innerHTML = `
|
||||||
|
<tr><td colspan="6" class="text-center text-danger py-5">
|
||||||
|
❌ Fejl ved indlæsning: ${error.message}
|
||||||
|
</td></tr>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayCustomers(customers) {
|
function renderCustomers(customers) {
|
||||||
const tbody = document.getElementById('customersTableBody');
|
const tbody = document.getElementById('customersTableBody');
|
||||||
|
|
||||||
if (!customers || customers.length === 0) {
|
if (!customers || customers.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-muted">Ingen kunder fundet</td></tr>';
|
tbody.innerHTML = `
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-5">
|
||||||
|
Ingen kunder fundet
|
||||||
|
</td></tr>
|
||||||
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = customers.map(customer => {
|
tbody.innerHTML = customers.map(customer => {
|
||||||
const initials = getInitials(customer.name);
|
const initials = customer.name ? customer.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '??';
|
||||||
const statusBadge = customer.is_active
|
const statusBadge = customer.is_active ?
|
||||||
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>'
|
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
|
||||||
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
||||||
|
|
||||||
const sourceBadge = customer.vtiger_id
|
|
||||||
? '<span class="badge bg-primary bg-opacity-10 text-primary"><i class="bi bi-cloud me-1"></i>vTiger</span>'
|
|
||||||
: '<span class="badge bg-secondary bg-opacity-10 text-secondary"><i class="bi bi-hdd me-1"></i>Lokal</span>';
|
|
||||||
|
|
||||||
const contactCount = customer.contact_count || 0;
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr style="cursor: pointer;" onclick="viewCustomer(${customer.id})">
|
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="customer-avatar me-3">${initials}</div>
|
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
|
||||||
|
style="width: 40px; height: 40px; color: var(--accent);">
|
||||||
|
${initials}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold">${escapeHtml(customer.name)}</div>
|
<div class="fw-bold">${customer.name || '-'}</div>
|
||||||
<div class="small text-muted">${customer.city || customer.address || '-'}</div>
|
<div class="small text-muted">${customer.address || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-medium">${customer.email || '-'}</div>
|
<div class="fw-medium">${customer.contact_name || '-'}</div>
|
||||||
<div class="small text-muted">${customer.phone || '-'}</div>
|
<div class="small text-muted">${customer.contact_phone || '-'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">${customer.cvr_number || '-'}</td>
|
<td class="text-muted">${customer.cvr_number || '-'}</td>
|
||||||
<td>${sourceBadge}</td>
|
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td>
|
<td class="text-muted">${customer.email || '-'}</td>
|
||||||
<span class="badge bg-light text-dark border">
|
|
||||||
<i class="bi bi-person me-1"></i>${contactCount}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group">
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewCustomer(${customer.id})">
|
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
|
||||||
<i class="bi bi-eye"></i>
|
title="Se detaljer">
|
||||||
</button>
|
<i class="bi bi-arrow-right"></i>
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editCustomer(${customer.id})">
|
</button>
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePagination(total) {
|
function renderPagination() {
|
||||||
const start = currentPage * pageSize + 1;
|
const totalPages = Math.ceil(totalCustomers / pageSize);
|
||||||
const end = Math.min((currentPage + 1) * pageSize, total);
|
const pagination = document.getElementById('pagination');
|
||||||
|
|
||||||
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
|
if (totalPages <= 1) {
|
||||||
document.getElementById('showingEnd').textContent = end;
|
pagination.innerHTML = '';
|
||||||
document.getElementById('totalCount').textContent = total;
|
|
||||||
|
|
||||||
// Update buttons
|
|
||||||
document.getElementById('prevBtn').disabled = currentPage === 0;
|
|
||||||
document.getElementById('nextBtn').disabled = end >= total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPage() {
|
|
||||||
if (currentPage > 0) {
|
|
||||||
currentPage--;
|
|
||||||
loadCustomers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if ((currentPage + 1) * pageSize < totalCustomers) {
|
|
||||||
currentPage++;
|
|
||||||
loadCustomers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewCustomer(customerId) {
|
|
||||||
window.location.href = `/customers/${customerId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function editCustomer(customerId) {
|
|
||||||
// TODO: Open edit modal
|
|
||||||
console.log('Edit customer:', customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateCustomerModal() {
|
|
||||||
// Reset form
|
|
||||||
document.getElementById('createCustomerForm').reset();
|
|
||||||
document.getElementById('cvrLookupStatus').innerHTML = '';
|
|
||||||
document.getElementById('isActiveInput').checked = true;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lookupCVR() {
|
|
||||||
const cvrInput = document.getElementById('cvrInput');
|
|
||||||
const cvr = cvrInput.value.trim();
|
|
||||||
const statusDiv = document.getElementById('cvrLookupStatus');
|
|
||||||
const lookupBtn = document.getElementById('cvrLookupBtn');
|
|
||||||
|
|
||||||
if (!cvr || cvr.length !== 8) {
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-2"></i>Indtast et gyldigt 8-cifret CVR-nummer</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state
|
let pages = [];
|
||||||
lookupBtn.disabled = true;
|
|
||||||
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-info mb-0"><i class="bi bi-hourglass-split me-2"></i>Henter virksomhedsoplysninger...</div>';
|
|
||||||
|
|
||||||
try {
|
// Previous button
|
||||||
const response = await fetch(`/api/v1/cvr/${cvr}`);
|
pages.push(`
|
||||||
|
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||||||
|
<a class="page-link" href="#" onclick="loadCustomers(${currentPage - 1}); return false;">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`);
|
||||||
|
|
||||||
if (!response.ok) {
|
// Page numbers (show max 7 pages)
|
||||||
throw new Error('CVR ikke fundet');
|
let startPage = Math.max(1, currentPage - 3);
|
||||||
|
let endPage = Math.min(totalPages, startPage + 6);
|
||||||
|
|
||||||
|
if (endPage - startPage < 6) {
|
||||||
|
startPage = Math.max(1, endPage - 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPage > 1) {
|
||||||
|
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(1); return false;">1</a></li>`);
|
||||||
|
if (startPage > 2) {
|
||||||
|
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Auto-fill form fields
|
|
||||||
document.getElementById('nameInput').value = data.name || '';
|
|
||||||
document.getElementById('addressInput').value = data.address || '';
|
|
||||||
document.getElementById('postalCodeInput').value = data.postal_code || '';
|
|
||||||
document.getElementById('cityInput').value = data.city || '';
|
|
||||||
document.getElementById('phoneInput').value = data.phone || '';
|
|
||||||
document.getElementById('emailInput').value = data.email || '';
|
|
||||||
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-2"></i>Virksomhedsoplysninger hentet fra CVR-registeret</div>';
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('CVR lookup failed:', error);
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-danger mb-0"><i class="bi bi-x-circle me-2"></i>Kunne ikke finde virksomhed med CVR-nummer ' + cvr + '</div>';
|
|
||||||
} finally {
|
|
||||||
lookupBtn.disabled = false;
|
|
||||||
lookupBtn.innerHTML = '<i class="bi bi-search me-2"></i>Søg CVR';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCustomer() {
|
|
||||||
const name = document.getElementById('nameInput').value.trim();
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
alert('Virksomhedsnavn er påkrævet');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerData = {
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
name: name,
|
pages.push(`
|
||||||
cvr_number: document.getElementById('cvrInput').value.trim() || null,
|
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||||||
address: document.getElementById('addressInput').value.trim() || null,
|
<a class="page-link" href="#" onclick="loadCustomers(${i}); return false;">${i}</a>
|
||||||
postal_code: document.getElementById('postalCodeInput').value.trim() || null,
|
</li>
|
||||||
city: document.getElementById('cityInput').value.trim() || null,
|
`);
|
||||||
email: document.getElementById('emailInput').value.trim() || null,
|
}
|
||||||
phone: document.getElementById('phoneInput').value.trim() || null,
|
|
||||||
website: document.getElementById('websiteInput').value.trim() || null,
|
|
||||||
is_active: document.getElementById('isActiveInput').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
if (endPage < totalPages) {
|
||||||
const response = await fetch('/api/v1/customers', {
|
if (endPage < totalPages - 1) {
|
||||||
method: 'POST',
|
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(customerData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Kunne ikke oprette kunde');
|
|
||||||
}
|
}
|
||||||
|
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(${totalPages}); return false;">${totalPages}</a></li>`);
|
||||||
const newCustomer = await response.json();
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Reload customer list
|
|
||||||
await loadCustomers();
|
|
||||||
|
|
||||||
// Show success message (optional)
|
|
||||||
alert('Kunde oprettet succesfuldt!');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create customer:', error);
|
|
||||||
alert('Fejl ved oprettelse af kunde: ' + error.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
pages.push(`
|
||||||
|
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||||||
|
<a class="page-link" href="#" onclick="loadCustomers(${currentPage + 1}); return false;">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`);
|
||||||
|
|
||||||
|
pagination.innerHTML = pages.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(name) {
|
function updateCount() {
|
||||||
if (!name) return '?';
|
const start = (currentPage - 1) * pageSize + 1;
|
||||||
const words = name.trim().split(' ');
|
const end = Math.min(currentPage * pageSize, totalCustomers);
|
||||||
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
document.getElementById('customerCount').textContent =
|
||||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
`Viser ${start}-${end} af ${totalCustomers} kunder`;
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -17,22 +17,22 @@ async def get_dashboard_stats():
|
|||||||
|
|
||||||
# 1. Customer Counts
|
# 1. Customer Counts
|
||||||
logger.info("Fetching customer count...")
|
logger.info("Fetching customer count...")
|
||||||
customer_res = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True)
|
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
|
customer_count = customer_res['count'] if customer_res else 0
|
||||||
|
|
||||||
# 2. Contact Counts
|
# 2. Contact Counts
|
||||||
logger.info("Fetching contact count...")
|
logger.info("Fetching contact count...")
|
||||||
contact_res = execute_query("SELECT COUNT(*) as count FROM contacts", fetchone=True)
|
contact_res = execute_query_single("SELECT COUNT(*) as count FROM contacts")
|
||||||
contact_count = contact_res['count'] if contact_res else 0
|
contact_count = contact_res['count'] if contact_res else 0
|
||||||
|
|
||||||
# 3. Vendor Counts
|
# 3. Vendor Counts
|
||||||
logger.info("Fetching vendor count...")
|
logger.info("Fetching vendor count...")
|
||||||
vendor_res = execute_query("SELECT COUNT(*) as count FROM vendors", fetchone=True)
|
vendor_res = execute_query_single("SELECT COUNT(*) as count FROM vendors")
|
||||||
vendor_count = vendor_res['count'] if vendor_res else 0
|
vendor_count = vendor_res['count'] if vendor_res else 0
|
||||||
|
|
||||||
# 4. Recent Customers (Real "Activity")
|
# 4. Recent Customers (Real "Activity")
|
||||||
logger.info("Fetching recent customers...")
|
logger.info("Fetching recent customers...")
|
||||||
recent_customers = execute_query("""
|
recent_customers = execute_query_single("""
|
||||||
SELECT id, name, created_at, 'customer' as type
|
SELECT id, name, created_at, 'customer' as type
|
||||||
FROM customers
|
FROM customers
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
@ -154,7 +154,7 @@ async def get_live_stats():
|
|||||||
|
|
||||||
# Try to get real customer count as a demo
|
# Try to get real customer count as a demo
|
||||||
try:
|
try:
|
||||||
customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True)
|
customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL")
|
||||||
sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0
|
sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
Render the dashboard page
|
Render the dashboard page
|
||||||
|
|||||||
@ -9,217 +9,123 @@
|
|||||||
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3">
|
||||||
<div class="input-group">
|
<input type="text" class="header-search" placeholder="Søg...">
|
||||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
|
||||||
<input type="text" id="dashboardSearchInput" class="form-control border-start-0 ps-0" placeholder="Søg i alt... (⌘K)" style="max-width: 250px;" role="button">
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Ny Oprettelse
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li><a class="dropdown-item" href="/customers"><i class="bi bi-building me-2"></i>Ny Kunde</a></li>
|
|
||||||
<li><a class="dropdown-item" href="/contacts"><i class="bi bi-person me-2"></i>Ny Kontakt</a></li>
|
|
||||||
<li><a class="dropdown-item" href="/vendors"><i class="bi bi-shop me-2"></i>Ny Leverandør</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1. Live Metrics Cards -->
|
|
||||||
<div class="row g-4 mb-5">
|
<div class="row g-4 mb-5">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card stat-card p-4 h-100">
|
<div class="card stat-card p-4 h-100">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<p>Kunder</p>
|
<p>Aktive Kunder</p>
|
||||||
<i class="bi bi-building text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3 id="customerCount">-</h3>
|
|
||||||
<small class="text-success"><i class="bi bi-check-circle"></i> Aktive i systemet</small>
|
|
||||||
</div>
|
|
||||||
</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>Kontakter</p>
|
|
||||||
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
|
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 id="contactCount">-</h3>
|
<h3>124</h3>
|
||||||
<small class="text-muted">Tilknyttede personer</small>
|
<small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card stat-card p-4 h-100">
|
<div class="card stat-card p-4 h-100">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<p>Leverandører</p>
|
<p>Hardware</p>
|
||||||
<i class="bi bi-shop text-primary" style="color: var(--accent) !important;"></i>
|
<i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 id="vendorCount">-</h3>
|
<h3>856</h3>
|
||||||
<small class="text-muted">Aktive leverandøraftaler</small>
|
<small class="text-muted">Enheder online</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card stat-card p-4 h-100">
|
<div class="card stat-card p-4 h-100">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<p>System Status</p>
|
<p>Support</p>
|
||||||
<i class="bi bi-cpu text-primary" style="color: var(--accent) !important;"></i>
|
<i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 id="systemStatus" class="text-success">Online</h3>
|
<h3>12</h3>
|
||||||
<small class="text-muted" id="systemVersion">v1.0.0</small>
|
<small class="text-warning">3 kræver handling</small>
|
||||||
|
</div>
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- 2. Recent Activity (New Customers) -->
|
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card p-4 h-100">
|
<div class="card p-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
|
||||||
<h5 class="fw-bold mb-0">Seneste Tilføjelser</h5>
|
|
||||||
<a href="/customers" class="btn btn-sm btn-light">Se alle</a>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Navn</th>
|
<th>Kunde</th>
|
||||||
<th>Type</th>
|
<th>Handling</th>
|
||||||
<th>Oprettet</th>
|
<th>Status</th>
|
||||||
<th class="text-end">Handling</th>
|
<th class="text-end">Tid</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="recentActivityTable">
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="text-center py-4">
|
<td class="fw-bold">Advokatgruppen A/S</td>
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
<td>Firewall konfiguration</td>
|
||||||
</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. Vendor Distribution & Quick Links -->
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card p-4 mb-4">
|
<div class="card p-4 h-100">
|
||||||
<h5 class="fw-bold mb-4">Leverandør Fordeling</h5>
|
<h5 class="fw-bold mb-4">System Status</h5>
|
||||||
<div id="vendorDistribution">
|
|
||||||
<div class="text-center py-3">
|
<div class="mb-4">
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
<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>
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. Quick Actions / Shortcuts -->
|
<div class="mb-4">
|
||||||
<div class="card p-4">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<h5 class="fw-bold mb-3">Genveje</h5>
|
<span class="small fw-bold text-muted">MEMORY</span>
|
||||||
<div class="d-grid gap-2">
|
<span class="small fw-bold">56%</span>
|
||||||
<a href="/settings" class="btn btn-light text-start p-3 d-flex align-items-center">
|
</div>
|
||||||
<div class="bg-white p-2 rounded me-3 shadow-sm">
|
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
|
||||||
<i class="bi bi-gear text-primary"></i>
|
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div class="fw-bold">Indstillinger</div>
|
|
||||||
<small class="text-muted">Konfigurer systemet</small>
|
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
|
||||||
</div>
|
<div class="d-flex">
|
||||||
</a>
|
<i class="bi bi-check-circle-fill text-success me-2"></i>
|
||||||
<a href="/vendors" class="btn btn-light text-start p-3 d-flex align-items-center">
|
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
|
||||||
<div class="bg-white p-2 rounded me-3 shadow-sm">
|
</div>
|
||||||
<i class="bi bi-truck text-success"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Leverandører</div>
|
|
||||||
<small class="text-muted">Administrer aftaler</small>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
async function loadDashboardStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/dashboard/stats');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update Counts
|
|
||||||
document.getElementById('customerCount').textContent = data.counts.customers;
|
|
||||||
document.getElementById('contactCount').textContent = data.counts.contacts;
|
|
||||||
document.getElementById('vendorCount').textContent = data.counts.vendors;
|
|
||||||
|
|
||||||
// Update Recent Activity
|
|
||||||
const activityTable = document.getElementById('recentActivityTable');
|
|
||||||
if (data.recent_activity && data.recent_activity.length > 0) {
|
|
||||||
activityTable.innerHTML = data.recent_activity.map(item => `
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
|
||||||
<i class="bi bi-building text-primary"></i>
|
|
||||||
</div>
|
|
||||||
${item.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge bg-primary bg-opacity-10 text-primary">Kunde</span></td>
|
|
||||||
<td class="text-muted">${new Date(item.created_at).toLocaleDateString('da-DK')}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a href="/customers/${item.id}" class="btn btn-sm btn-light"><i class="bi bi-arrow-right"></i></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
activityTable.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Ingen nylig aktivitet</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Vendor Distribution
|
|
||||||
const vendorDist = document.getElementById('vendorDistribution');
|
|
||||||
if (data.vendor_distribution && data.vendor_distribution.length > 0) {
|
|
||||||
const total = data.counts.vendors;
|
|
||||||
vendorDist.innerHTML = data.vendor_distribution.map(cat => {
|
|
||||||
const percentage = Math.round((cat.count / total) * 100);
|
|
||||||
return `
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="small fw-bold">${cat.category || 'Ukendt'}</span>
|
|
||||||
<span class="small text-muted">${cat.count}</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar" role="progressbar" style="width: ${percentage}%" aria-valuenow="${percentage}" aria-valuemin="0" aria-valuemax="100"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
vendorDist.innerHTML = '<p class="text-muted text-center">Ingen leverandørdata</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading dashboard stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadDashboardStats);
|
|
||||||
|
|
||||||
// Connect dashboard search input to global search modal
|
|
||||||
document.getElementById('dashboardSearchInput').addEventListener('click', () => {
|
|
||||||
const modalEl = document.getElementById('globalSearchModal');
|
|
||||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
||||||
modal.show();
|
|
||||||
|
|
||||||
// Focus input when modal opens
|
|
||||||
modalEl.addEventListener('shown.bs.modal', () => {
|
|
||||||
document.getElementById('globalSearchInput').focus();
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also handle focus (e.g. via tab navigation)
|
|
||||||
document.getElementById('dashboardSearchInput').addEventListener('focus', (e) => {
|
|
||||||
e.target.click();
|
|
||||||
e.target.blur(); // Remove focus from this input so we don't get stuck in a loop or keep cursor here
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@ -76,14 +76,14 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
|
|||||||
params.append(status)
|
params.append(status)
|
||||||
|
|
||||||
query += " ORDER BY priority DESC, expected_date ASC"
|
query += " ORDER BY priority DESC, expected_date ASC"
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
result = execute_query_single(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/features/{feature_id}", response_model=Feature)
|
@router.get("/features/{feature_id}", response_model=Feature)
|
||||||
async def get_feature(feature_id: int):
|
async def get_feature(feature_id: int):
|
||||||
"""Get a specific feature"""
|
"""Get a specific feature"""
|
||||||
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,), fetchone=True)
|
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
raise HTTPException(status_code=404, detail="Feature not found")
|
||||||
return result
|
return result
|
||||||
@ -97,10 +97,10 @@ async def create_feature(feature: FeatureCreate):
|
|||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (
|
result = execute_query_single(query, (
|
||||||
feature.title, feature.description, feature.version,
|
feature.title, feature.description, feature.version,
|
||||||
feature.status, feature.priority, feature.expected_date
|
feature.status, feature.priority, feature.expected_date
|
||||||
), fetchone=True)
|
))
|
||||||
|
|
||||||
logger.info(f"✅ Created feature: {feature.title}")
|
logger.info(f"✅ Created feature: {feature.title}")
|
||||||
return result
|
return result
|
||||||
@ -116,10 +116,10 @@ async def update_feature(feature_id: int, feature: FeatureCreate):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (
|
result = execute_query_single(query, (
|
||||||
feature.title, feature.description, feature.version,
|
feature.title, feature.description, feature.version,
|
||||||
feature.status, feature.priority, feature.expected_date, feature_id
|
feature.status, feature.priority, feature.expected_date, feature_id
|
||||||
), fetchone=True)
|
))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
raise HTTPException(status_code=404, detail="Feature not found")
|
||||||
@ -131,7 +131,7 @@ async def update_feature(feature_id: int, feature: FeatureCreate):
|
|||||||
@router.delete("/features/{feature_id}")
|
@router.delete("/features/{feature_id}")
|
||||||
async def delete_feature(feature_id: int):
|
async def delete_feature(feature_id: int):
|
||||||
"""Delete a roadmap feature"""
|
"""Delete a roadmap feature"""
|
||||||
result = execute_query("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,), fetchone=True)
|
result = execute_query_single("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
raise HTTPException(status_code=404, detail="Feature not found")
|
||||||
|
|
||||||
@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
|
|||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
query += " ORDER BY votes DESC, created_at DESC"
|
query += " ORDER BY votes DESC, created_at DESC"
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
result = execute_query_single(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
|
|||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (idea.title, idea.description, idea.category), fetchone=True)
|
result = execute_query(query, (idea.title, idea.description, idea.category))
|
||||||
|
|
||||||
logger.info(f"✅ Created idea: {idea.title}")
|
logger.info(f"✅ Created idea: {idea.title}")
|
||||||
return result
|
return result
|
||||||
@ -178,7 +178,7 @@ async def vote_idea(idea_id: int):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (idea_id,), fetchone=True)
|
result = execute_query_single(query, (idea_id,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Idea not found")
|
raise HTTPException(status_code=404, detail="Idea not found")
|
||||||
@ -189,7 +189,7 @@ async def vote_idea(idea_id: int):
|
|||||||
@router.delete("/ideas/{idea_id}")
|
@router.delete("/ideas/{idea_id}")
|
||||||
async def delete_idea(idea_id: int):
|
async def delete_idea(idea_id: int):
|
||||||
"""Delete an idea"""
|
"""Delete an idea"""
|
||||||
result = execute_query("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,), fetchone=True)
|
result = execute_query_single("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Idea not found")
|
raise HTTPException(status_code=404, detail="Idea not found")
|
||||||
|
|
||||||
@ -209,14 +209,14 @@ async def get_workflows(category: Optional[str] = None):
|
|||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
query += " ORDER BY created_at DESC"
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
result = execute_query_single(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/workflows/{workflow_id}", response_model=Workflow)
|
@router.get("/workflows/{workflow_id}", response_model=Workflow)
|
||||||
async def get_workflow(workflow_id: int):
|
async def get_workflow(workflow_id: int):
|
||||||
"""Get a specific workflow"""
|
"""Get a specific workflow"""
|
||||||
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,), fetchone=True)
|
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
return result
|
return result
|
||||||
@ -230,9 +230,9 @@ async def create_workflow(workflow: WorkflowCreate):
|
|||||||
VALUES (%s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (
|
result = execute_query_single(query, (
|
||||||
workflow.title, workflow.description, workflow.category, workflow.diagram_xml
|
workflow.title, workflow.description, workflow.category, workflow.diagram_xml
|
||||||
), fetchone=True)
|
))
|
||||||
|
|
||||||
logger.info(f"✅ Created workflow: {workflow.title}")
|
logger.info(f"✅ Created workflow: {workflow.title}")
|
||||||
return result
|
return result
|
||||||
@ -247,10 +247,10 @@ async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (
|
result = execute_query_single(query, (
|
||||||
workflow.title, workflow.description, workflow.category,
|
workflow.title, workflow.description, workflow.category,
|
||||||
workflow.diagram_xml, workflow_id
|
workflow.diagram_xml, workflow_id
|
||||||
), fetchone=True)
|
))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
@ -262,7 +262,7 @@ async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
|
|||||||
@router.delete("/workflows/{workflow_id}")
|
@router.delete("/workflows/{workflow_id}")
|
||||||
async def delete_workflow(workflow_id: int):
|
async def delete_workflow(workflow_id: int):
|
||||||
"""Delete a workflow"""
|
"""Delete a workflow"""
|
||||||
result = execute_query("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,), fetchone=True)
|
result = execute_query_single("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
|
|
||||||
@ -274,9 +274,9 @@ async def delete_workflow(workflow_id: int):
|
|||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_devportal_stats():
|
async def get_devportal_stats():
|
||||||
"""Get DEV Portal statistics"""
|
"""Get DEV Portal statistics"""
|
||||||
features_count = execute_query("SELECT COUNT(*) as count FROM dev_features", fetchone=True)
|
features_count = execute_query_single("SELECT COUNT(*) as count FROM dev_features")
|
||||||
ideas_count = execute_query("SELECT COUNT(*) as count FROM dev_ideas", fetchone=True)
|
ideas_count = execute_query_single("SELECT COUNT(*) as count FROM dev_ideas")
|
||||||
workflows_count = execute_query("SELECT COUNT(*) as count FROM dev_workflows", fetchone=True)
|
workflows_count = execute_query_single("SELECT COUNT(*) as count FROM dev_workflows")
|
||||||
|
|
||||||
features_by_status = execute_query("""
|
features_by_status = execute_query("""
|
||||||
SELECT status, COUNT(*) as count
|
SELECT status, COUNT(*) as count
|
||||||
|
|||||||
@ -183,7 +183,7 @@ async def list_emails(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
result = execute_query(query, tuple(params))
|
result = execute_query_single(query, tuple(params))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -241,7 +241,7 @@ async def mark_email_processed(email_id: int):
|
|||||||
WHERE id = %s AND deleted_at IS NULL
|
WHERE id = %s AND deleted_at IS NULL
|
||||||
RETURNING id, folder, status
|
RETURNING id, folder, status
|
||||||
"""
|
"""
|
||||||
result = execute_query(update_query, (email_id,), fetchone=True)
|
result = execute_query(update_query, (email_id,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Email not found")
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
@ -274,7 +274,7 @@ async def download_attachment(email_id: int, attachment_id: int):
|
|||||||
JOIN email_messages e ON e.id = a.email_id
|
JOIN email_messages e ON e.id = a.email_id
|
||||||
WHERE a.id = %s AND a.email_id = %s AND e.deleted_at IS NULL
|
WHERE a.id = %s AND a.email_id = %s AND e.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (attachment_id, email_id))
|
result = execute_query_single(query, (attachment_id, email_id))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||||
@ -717,7 +717,7 @@ async def get_workflow(workflow_id: int):
|
|||||||
"""Get specific workflow by ID"""
|
"""Get specific workflow by ID"""
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM email_workflows WHERE id = %s"
|
query = "SELECT * FROM email_workflows WHERE id = %s"
|
||||||
result = execute_query(query, (workflow_id,), fetchone=True)
|
result = execute_query(query, (workflow_id,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
@ -745,7 +745,7 @@ async def create_workflow(workflow: EmailWorkflow):
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = execute_query(query, (
|
result = execute_query_single(query, (
|
||||||
workflow.name,
|
workflow.name,
|
||||||
workflow.description,
|
workflow.description,
|
||||||
workflow.classification_trigger,
|
workflow.classification_trigger,
|
||||||
@ -756,7 +756,7 @@ async def create_workflow(workflow: EmailWorkflow):
|
|||||||
workflow.priority,
|
workflow.priority,
|
||||||
workflow.enabled,
|
workflow.enabled,
|
||||||
workflow.stop_on_match
|
workflow.stop_on_match
|
||||||
), fetchone=True)
|
))
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info(f"✅ Created workflow: {workflow.name}")
|
logger.info(f"✅ Created workflow: {workflow.name}")
|
||||||
@ -791,7 +791,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = execute_query(query, (
|
result = execute_query_single(query, (
|
||||||
workflow.name,
|
workflow.name,
|
||||||
workflow.description,
|
workflow.description,
|
||||||
workflow.classification_trigger,
|
workflow.classification_trigger,
|
||||||
@ -803,7 +803,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
|
|||||||
workflow.enabled,
|
workflow.enabled,
|
||||||
workflow.stop_on_match,
|
workflow.stop_on_match,
|
||||||
workflow_id
|
workflow_id
|
||||||
), fetchone=True)
|
))
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info(f"✅ Updated workflow {workflow_id}")
|
logger.info(f"✅ Updated workflow {workflow_id}")
|
||||||
@ -841,7 +841,7 @@ async def toggle_workflow(workflow_id: int):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING enabled
|
RETURNING enabled
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (workflow_id,), fetchone=True)
|
result = execute_query_single(query, (workflow_id,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
@ -873,7 +873,7 @@ async def execute_workflows_for_email(email_id: int):
|
|||||||
FROM email_messages
|
FROM email_messages
|
||||||
WHERE id = %s AND deleted_at IS NULL
|
WHERE id = %s AND deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
email_data = execute_query(query, (email_id,), fetchone=True)
|
email_data = execute_query_single(query, (email_id,))
|
||||||
|
|
||||||
if not email_data:
|
if not email_data:
|
||||||
raise HTTPException(status_code=404, detail="Email not found")
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|||||||
@ -3,11 +3,10 @@ Pydantic Models and Schemas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
# Customer Schemas
|
|
||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
"""Base customer schema"""
|
"""Base customer schema"""
|
||||||
name: str
|
name: str
|
||||||
@ -16,31 +15,9 @@ class CustomerBase(BaseModel):
|
|||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(BaseModel):
|
class CustomerCreate(CustomerBase):
|
||||||
"""Schema for creating a customer"""
|
"""Schema for creating a customer"""
|
||||||
name: str
|
pass
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerUpdate(BaseModel):
|
|
||||||
"""Schema for updating a customer"""
|
|
||||||
name: Optional[str] = None
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
subscriptions_locked: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Customer(CustomerBase):
|
class Customer(CustomerBase):
|
||||||
@ -53,70 +30,6 @@ class Customer(CustomerBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Contact Schemas
|
|
||||||
class ContactBase(BaseModel):
|
|
||||||
"""Base contact schema"""
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
mobile: Optional[str] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
department: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(ContactBase):
|
|
||||||
"""Schema for creating a contact"""
|
|
||||||
company_ids: List[int] = [] # List of customer IDs to link to
|
|
||||||
is_primary: bool = False # Whether this is the primary contact for first company
|
|
||||||
role: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Schema for linking/unlinking a contact to a company"""
|
|
||||||
customer_id: int
|
|
||||||
is_primary: bool = False
|
|
||||||
role: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyInfo(BaseModel):
|
|
||||||
"""Schema for company information in contact context"""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
is_primary: bool
|
|
||||||
role: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(ContactBase):
|
|
||||||
"""Full contact schema"""
|
|
||||||
id: int
|
|
||||||
is_active: bool
|
|
||||||
vtiger_id: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
companies: List[CompanyInfo] = [] # List of linked companies
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
# Hardware Schemas
|
|
||||||
class HardwareBase(BaseModel):
|
class HardwareBase(BaseModel):
|
||||||
"""Base hardware schema"""
|
"""Base hardware schema"""
|
||||||
serial_number: str
|
serial_number: str
|
||||||
@ -138,46 +51,32 @@ class Hardware(HardwareBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Vendor Schemas
|
|
||||||
class VendorBase(BaseModel):
|
class VendorBase(BaseModel):
|
||||||
"""Base vendor schema"""
|
"""Base vendor schema"""
|
||||||
name: str
|
name: str
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VendorCreate(BaseModel):
|
|
||||||
"""Schema for creating a vendor"""
|
|
||||||
name: str
|
|
||||||
cvr_number: Optional[str] = None
|
cvr_number: Optional[str] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
address: Optional[str] = None
|
contact_person: Optional[str] = None
|
||||||
postal_code: Optional[str] = None
|
category: Optional[str] = None
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
email_pattern: Optional[str] = None
|
|
||||||
category: str = 'general'
|
|
||||||
priority: int = 50
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
class VendorCreate(VendorBase):
|
||||||
|
"""Schema for creating a vendor"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VendorUpdate(BaseModel):
|
class VendorUpdate(BaseModel):
|
||||||
"""Schema for updating a vendor"""
|
"""Schema for updating a vendor"""
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
cvr_number: Optional[str] = None
|
cvr_number: Optional[str] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
address: Optional[str] = None
|
contact_person: Optional[str] = None
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
email_pattern: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
priority: Optional[int] = None
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
@ -185,20 +84,9 @@ class VendorUpdate(BaseModel):
|
|||||||
class Vendor(VendorBase):
|
class Vendor(VendorBase):
|
||||||
"""Full vendor schema"""
|
"""Full vendor schema"""
|
||||||
id: int
|
id: int
|
||||||
cvr_number: Optional[str] = None
|
is_active: bool = True
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
country: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
category: str
|
|
||||||
priority: int
|
|
||||||
notes: Optional[str] = None
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ async def get_items():
|
|||||||
read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
|
read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
|
||||||
|
|
||||||
# Hent items (bemærk table_prefix)
|
# Hent items (bemærk table_prefix)
|
||||||
items = execute_query(
|
items = execute_query_single(
|
||||||
"SELECT * FROM template_items ORDER BY created_at DESC"
|
"SELECT * FROM template_items ORDER BY created_at DESC"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -58,9 +58,7 @@ async def get_item(item_id: int):
|
|||||||
try:
|
try:
|
||||||
item = execute_query(
|
item = execute_query(
|
||||||
"SELECT * FROM template_items WHERE id = %s",
|
"SELECT * FROM template_items WHERE id = %s",
|
||||||
(item_id,),
|
(item_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
@ -245,7 +243,7 @@ async def health_check():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Test database connectivity
|
# Test database connectivity
|
||||||
result = execute_query("SELECT 1 as test", fetchone=True)
|
result = execute_query_single("SELECT 1 as test")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|||||||
@ -29,7 +29,7 @@ async def get_items():
|
|||||||
read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
|
read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
|
||||||
|
|
||||||
# Hent items (bemærk table_prefix)
|
# Hent items (bemærk table_prefix)
|
||||||
items = execute_query(
|
items = execute_query_single(
|
||||||
"SELECT * FROM test_module_items ORDER BY created_at DESC"
|
"SELECT * FROM test_module_items ORDER BY created_at DESC"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -58,9 +58,7 @@ async def get_item(item_id: int):
|
|||||||
try:
|
try:
|
||||||
item = execute_query(
|
item = execute_query(
|
||||||
"SELECT * FROM test_module_items WHERE id = %s",
|
"SELECT * FROM test_module_items WHERE id = %s",
|
||||||
(item_id,),
|
(item_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
@ -245,7 +243,7 @@ async def health_check():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Test database connectivity
|
# Test database connectivity
|
||||||
result = execute_query("SELECT 1 as test", fetchone=True)
|
result = execute_query_single("SELECT 1 as test")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|||||||
273
app/prepaid/backend/router.py
Normal file
273
app/prepaid/backend/router.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from app.core.database import execute_query
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime, date
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Pydantic Models
|
||||||
|
class PrepaidCard(BaseModel):
|
||||||
|
id: Optional[int] = None
|
||||||
|
card_number: str
|
||||||
|
customer_id: int
|
||||||
|
purchased_hours: float
|
||||||
|
used_hours: float
|
||||||
|
remaining_hours: float
|
||||||
|
price_per_hour: float
|
||||||
|
total_amount: float
|
||||||
|
status: str
|
||||||
|
purchased_at: Optional[datetime] = None
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
economic_invoice_number: Optional[str] = None
|
||||||
|
economic_product_number: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class PrepaidCardCreate(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
purchased_hours: float
|
||||||
|
price_per_hour: float
|
||||||
|
expires_at: Optional[date] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
|
||||||
|
async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None):
|
||||||
|
"""
|
||||||
|
Get all prepaid cards with customer information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
pc.*,
|
||||||
|
c.name as customer_name,
|
||||||
|
c.email as customer_email,
|
||||||
|
(SELECT COUNT(*) FROM tticket_prepaid_transactions WHERE card_id = pc.id) as transaction_count
|
||||||
|
FROM tticket_prepaid_cards pc
|
||||||
|
LEFT JOIN customers c ON pc.customer_id = c.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query += " AND pc.status = %s"
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
query += " AND pc.customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
query += " ORDER BY pc.created_at DESC"
|
||||||
|
|
||||||
|
cards = execute_query(query, tuple(params) if params else None)
|
||||||
|
logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards")
|
||||||
|
return cards or []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching prepaid cards: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any])
|
||||||
|
async def get_prepaid_card(card_id: int):
|
||||||
|
"""
|
||||||
|
Get a specific prepaid card with transactions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = execute_query("""
|
||||||
|
SELECT
|
||||||
|
pc.*,
|
||||||
|
c.name as customer_name,
|
||||||
|
c.email as customer_email
|
||||||
|
FROM tticket_prepaid_cards pc
|
||||||
|
LEFT JOIN customers c ON pc.customer_id = c.id
|
||||||
|
WHERE pc.id = %s
|
||||||
|
""", (card_id,))
|
||||||
|
|
||||||
|
if not result or len(result) == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Prepaid card not found")
|
||||||
|
|
||||||
|
card = result[0]
|
||||||
|
|
||||||
|
# Get transactions
|
||||||
|
transactions = execute_query("""
|
||||||
|
SELECT
|
||||||
|
pt.*,
|
||||||
|
w.ticket_id,
|
||||||
|
t.subject as ticket_title
|
||||||
|
FROM tticket_prepaid_transactions pt
|
||||||
|
LEFT JOIN tticket_worklog w ON pt.worklog_id = w.id
|
||||||
|
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
|
||||||
|
WHERE pt.card_id = %s
|
||||||
|
ORDER BY pt.created_at DESC
|
||||||
|
""", (card_id,))
|
||||||
|
|
||||||
|
card['transactions'] = transactions or []
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching prepaid card {card_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prepaid-cards", response_model=Dict[str, Any])
|
||||||
|
async def create_prepaid_card(card: PrepaidCardCreate):
|
||||||
|
"""
|
||||||
|
Create a new prepaid card
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Calculate total amount
|
||||||
|
total_amount = card.purchased_hours * card.price_per_hour
|
||||||
|
|
||||||
|
# Check if customer already has active card
|
||||||
|
existing = execute_query("""
|
||||||
|
SELECT id FROM tticket_prepaid_cards
|
||||||
|
WHERE customer_id = %s AND status = 'active'
|
||||||
|
""", (card.customer_id,))
|
||||||
|
|
||||||
|
if existing and len(existing) > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Customer already has an active prepaid card"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create card (need to use fetch=False for INSERT RETURNING)
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
from app.core.database import get_db_connection, release_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO tticket_prepaid_cards
|
||||||
|
(customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""", (
|
||||||
|
card.customer_id,
|
||||||
|
card.purchased_hours,
|
||||||
|
card.price_per_hour,
|
||||||
|
total_amount,
|
||||||
|
card.expires_at,
|
||||||
|
card.notes
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
logger.info(f"✅ Created prepaid card: {result['card_number']}")
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating prepaid card: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/prepaid-cards/{card_id}/status")
|
||||||
|
async def update_card_status(card_id: int, status: str):
|
||||||
|
"""
|
||||||
|
Update prepaid card status (cancel, reactivate)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if status not in ['active', 'cancelled']:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
from app.core.database import get_db_connection, release_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE tticket_prepaid_cards
|
||||||
|
SET status = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""", (status, card_id))
|
||||||
|
conn.commit()
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
|
||||||
|
logger.info(f"✅ Updated card {card_id} status to {status}")
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating card status: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/prepaid-cards/{card_id}")
|
||||||
|
async def delete_prepaid_card(card_id: int):
|
||||||
|
"""
|
||||||
|
Delete a prepaid card (only if no transactions)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check for transactions
|
||||||
|
transactions = execute_query("""
|
||||||
|
SELECT COUNT(*) as count FROM tticket_prepaid_transactions
|
||||||
|
WHERE card_id = %s
|
||||||
|
""", (card_id,))
|
||||||
|
|
||||||
|
if transactions and len(transactions) > 0 and transactions[0]['count'] > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot delete card with transactions"
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_query("DELETE FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetch=False)
|
||||||
|
|
||||||
|
logger.info(f"✅ Deleted prepaid card {card_id}")
|
||||||
|
return {"message": "Card deleted successfully"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting card: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any])
|
||||||
|
async def get_prepaid_stats():
|
||||||
|
"""
|
||||||
|
Get prepaid cards statistics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = execute_query("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'active') as active_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'depleted') as depleted_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'expired') as expired_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
|
||||||
|
COALESCE(SUM(remaining_hours) FILTER (WHERE status = 'active'), 0) as total_remaining_hours,
|
||||||
|
COALESCE(SUM(used_hours), 0) as total_used_hours,
|
||||||
|
COALESCE(SUM(purchased_hours), 0) as total_purchased_hours,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_revenue
|
||||||
|
FROM tticket_prepaid_cards
|
||||||
|
""")
|
||||||
|
|
||||||
|
return result[0] if result and len(result) > 0 else {}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
34
app/prepaid/backend/views.py
Normal file
34
app/prepaid/backend/views.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prepaid-cards", response_class=HTMLResponse)
|
||||||
|
async def prepaid_cards_page(request: Request):
|
||||||
|
"""
|
||||||
|
Prepaid cards overview page
|
||||||
|
"""
|
||||||
|
logger.info("🔍 Rendering prepaid cards page")
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"page_title": "Prepaid Cards"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prepaid-cards/{card_id}", response_class=HTMLResponse)
|
||||||
|
async def prepaid_card_detail(request: Request, card_id: int):
|
||||||
|
"""
|
||||||
|
Prepaid card detail page
|
||||||
|
"""
|
||||||
|
logger.info(f"🔍 Rendering prepaid card detail: {card_id}")
|
||||||
|
return templates.TemplateResponse("detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"page_title": "Card Details",
|
||||||
|
"card_id": card_id
|
||||||
|
})
|
||||||
313
app/prepaid/frontend/detail.html
Normal file
313
app/prepaid/frontend/detail.html
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Card Details - 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="/prepaid-cards">Prepaid Cards</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page" id="cardNumber">Loading...</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h1 class="h3 mb-0" id="pageTitle">💳 Loading...</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-outline-secondary" onclick="window.location.href='/prepaid-cards'">
|
||||||
|
<i class="bi bi-arrow-left"></i> Tilbage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Info -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0">Kort Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3" id="cardInfo">
|
||||||
|
<div class="col-12 text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<!-- Stats Card -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0">Oversigt</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<small class="text-muted d-block mb-1">Købte Timer</small>
|
||||||
|
<h4 class="mb-0" id="statPurchased">-</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<small class="text-muted d-block mb-1">Brugte Timer</small>
|
||||||
|
<h4 class="mb-0 text-info" id="statUsed">-</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<small class="text-muted d-block mb-1">Tilbage</small>
|
||||||
|
<h4 class="mb-0 text-success" id="statRemaining">-</h4>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small class="text-muted d-block mb-1">Total Beløb</small>
|
||||||
|
<h4 class="mb-0 text-primary" id="statTotal">-</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Card -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0">Handlinger</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="actionButtons">
|
||||||
|
<!-- Will be populated -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0">Transaktioner</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle" id="transactionsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Ticket</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th class="text-end">Timer</th>
|
||||||
|
<th class="text-end">Beløb</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="transactionsBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" 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>
|
||||||
|
const cardId = {{ card_id }};
|
||||||
|
|
||||||
|
// Load card details
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadCardDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCardDetails() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/prepaid-cards/${cardId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Card not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = await response.json();
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
document.getElementById('cardNumber').textContent = card.card_number;
|
||||||
|
document.getElementById('pageTitle').textContent = `💳 ${card.card_number}`;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
document.getElementById('statPurchased').textContent =
|
||||||
|
parseFloat(card.purchased_hours).toFixed(1) + ' t';
|
||||||
|
document.getElementById('statUsed').textContent =
|
||||||
|
parseFloat(card.used_hours).toFixed(1) + ' t';
|
||||||
|
document.getElementById('statRemaining').textContent =
|
||||||
|
parseFloat(card.remaining_hours).toFixed(1) + ' t';
|
||||||
|
document.getElementById('statTotal').textContent =
|
||||||
|
new Intl.NumberFormat('da-DK', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'DKK'
|
||||||
|
}).format(parseFloat(card.total_amount));
|
||||||
|
|
||||||
|
// Update card info
|
||||||
|
const statusBadge = getStatusBadge(card.status);
|
||||||
|
const expiresAt = card.expires_at ?
|
||||||
|
new Date(card.expires_at).toLocaleDateString('da-DK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
}) : 'Ingen udløbsdato';
|
||||||
|
|
||||||
|
document.getElementById('cardInfo').innerHTML = `
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Kortnummer</label>
|
||||||
|
<p class="mb-0"><strong>${card.card_number}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Status</label>
|
||||||
|
<p class="mb-0">${statusBadge}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Kunde</label>
|
||||||
|
<p class="mb-0">
|
||||||
|
<a href="/customers/${card.customer_id}" class="text-decoration-none">
|
||||||
|
${card.customer_name || '-'}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">${card.customer_email || ''}</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Pris pr. Time</label>
|
||||||
|
<p class="mb-0"><strong>${parseFloat(card.price_per_hour).toFixed(2)} kr</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Købt Dato</label>
|
||||||
|
<p class="mb-0">${new Date(card.purchased_at).toLocaleDateString('da-DK')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Udløber</label>
|
||||||
|
<p class="mb-0">${expiresAt}</p>
|
||||||
|
</div>
|
||||||
|
${card.economic_invoice_number ? `
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">e-conomic Fakturanr.</label>
|
||||||
|
<p class="mb-0">${card.economic_invoice_number}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${card.notes ? `
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="small text-muted">Bemærkninger</label>
|
||||||
|
<p class="mb-0">${card.notes}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update action buttons
|
||||||
|
const actions = [];
|
||||||
|
if (card.status === 'active') {
|
||||||
|
actions.push(`
|
||||||
|
<button class="btn btn-warning w-100 mb-2" onclick="cancelCard()">
|
||||||
|
<i class="bi bi-x-circle"></i> Annuller Kort
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
document.getElementById('actionButtons').innerHTML = actions.join('') ||
|
||||||
|
'<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>';
|
||||||
|
|
||||||
|
// Render transactions
|
||||||
|
renderTransactions(card.transactions || []);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading card:', error);
|
||||||
|
document.getElementById('cardInfo').innerHTML = `
|
||||||
|
<div class="col-12 text-center text-danger py-5">
|
||||||
|
<i class="bi bi-exclamation-circle fs-1 mb-3"></i>
|
||||||
|
<p>❌ Fejl ved indlæsning: ${error.message}</p>
|
||||||
|
<button class="btn btn-primary" onclick="window.location.href='/prepaid-cards'">
|
||||||
|
Tilbage til oversigt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTransactions(transactions) {
|
||||||
|
const tbody = document.getElementById('transactionsBody');
|
||||||
|
|
||||||
|
if (!transactions || transactions.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-5">
|
||||||
|
Ingen transaktioner endnu
|
||||||
|
</td></tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = transactions.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${new Date(t.created_at).toLocaleDateString('da-DK')}</td>
|
||||||
|
<td>
|
||||||
|
${t.ticket_id ?
|
||||||
|
`<a href="/ticket/tickets/${t.ticket_id}" class="text-decoration-none">
|
||||||
|
#${t.ticket_id} - ${t.ticket_title || 'Ticket'}
|
||||||
|
</a>` : '-'}
|
||||||
|
</td>
|
||||||
|
<td>${t.description || '-'}</td>
|
||||||
|
<td class="text-end">${parseFloat(t.hours_used).toFixed(2)} t</td>
|
||||||
|
<td class="text-end">${parseFloat(t.amount).toFixed(2)} kr</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const badges = {
|
||||||
|
'active': '<span class="badge bg-success">Aktiv</span>',
|
||||||
|
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
|
||||||
|
'expired': '<span class="badge bg-danger">Udløbet</span>',
|
||||||
|
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
|
||||||
|
};
|
||||||
|
return badges[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelCard() {
|
||||||
|
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'cancelled' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Fejl ved annullering');
|
||||||
|
|
||||||
|
alert('✅ Kort annulleret');
|
||||||
|
loadCardDetails(); // Reload
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling card:', error);
|
||||||
|
alert('❌ Fejl: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumb {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
461
app/prepaid/frontend/index.html
Normal file
461
app/prepaid/frontend/index.html
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Prepaid Cards - 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">💳 Prepaid Cards</h1>
|
||||||
|
<p class="text-muted">Oversigt og kontrol af kunders timekort</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary" onclick="openCreateModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Opret Nyt Kort
|
||||||
|
</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-credit-card-2-front text-success fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<p class="text-muted small mb-1">Aktive Kort</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-clock-history text-primary fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<p class="text-muted small mb-1">Tilbageværende Timer</p>
|
||||||
|
<h3 class="mb-0" id="remainingHours">-</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-hourglass-split text-info 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="usedHours">-</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-currency-dollar text-warning 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>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Status</label>
|
||||||
|
<select class="form-select" id="statusFilter" onchange="loadCards()">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="active">Aktive</option>
|
||||||
|
<option value="depleted">Opbrugt</option>
|
||||||
|
<option value="expired">Udløbet</option>
|
||||||
|
<option value="cancelled">Annulleret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small text-muted">Søg Kunde</label>
|
||||||
|
<input type="text" class="form-control" id="customerSearch"
|
||||||
|
placeholder="Søg efter kundenavn eller email...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
|
<button class="btn btn-outline-secondary w-100" onclick="resetFilters()">
|
||||||
|
<i class="bi bi-x-circle"></i> Nulstil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards Table -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle" id="cardsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Kortnummer</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th class="text-end">Købte Timer</th>
|
||||||
|
<th class="text-end">Brugte Timer</th>
|
||||||
|
<th class="text-end">Tilbage</th>
|
||||||
|
<th class="text-end">Pris/Time</th>
|
||||||
|
<th class="text-end">Total</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Udløber</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cardsTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" 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>
|
||||||
|
|
||||||
|
<!-- Create Card Modal -->
|
||||||
|
<div class="modal fade" id="createCardModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="createCardForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Kunde *</label>
|
||||||
|
<select class="form-select" id="customerId" required>
|
||||||
|
<option value="">Vælg kunde...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Antal Timer *</label>
|
||||||
|
<input type="number" class="form-control" id="purchasedHours"
|
||||||
|
step="0.5" min="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Pris pr. Time (DKK) *</label>
|
||||||
|
<input type="number" class="form-control" id="pricePerHour"
|
||||||
|
step="0.01" min="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Udløbsdato (valgfri)</label>
|
||||||
|
<input type="date" class="form-control" id="expiresAt">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Bemærkninger</label>
|
||||||
|
<textarea class="form-control" id="notes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info small">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Kortnummeret bliver automatisk genereret
|
||||||
|
</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="createCard()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Opret Kort
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let createCardModal;
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
createCardModal = new bootstrap.Modal(document.getElementById('createCardModal'));
|
||||||
|
loadStats();
|
||||||
|
loadCards();
|
||||||
|
loadCustomers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load Statistics
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/prepaid-cards/stats/summary');
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('activeCount').textContent = stats.active_count || 0;
|
||||||
|
document.getElementById('remainingHours').textContent =
|
||||||
|
parseFloat(stats.total_remaining_hours || 0).toFixed(1) + ' t';
|
||||||
|
document.getElementById('usedHours').textContent =
|
||||||
|
parseFloat(stats.total_used_hours || 0).toFixed(1) + ' t';
|
||||||
|
document.getElementById('totalRevenue').textContent =
|
||||||
|
new Intl.NumberFormat('da-DK', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'DKK'
|
||||||
|
}).format(parseFloat(stats.total_revenue || 0));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Cards
|
||||||
|
async function loadCards() {
|
||||||
|
const status = document.getElementById('statusFilter').value;
|
||||||
|
const search = document.getElementById('customerSearch').value.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = '/api/v1/prepaid-cards';
|
||||||
|
if (status) url += `?status=${status}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const cards = await response.json();
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
const filtered = cards.filter(card => {
|
||||||
|
if (!search) return true;
|
||||||
|
const name = (card.customer_name || '').toLowerCase();
|
||||||
|
const email = (card.customer_email || '').toLowerCase();
|
||||||
|
return name.includes(search) || email.includes(search);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderCards(filtered);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cards:', error);
|
||||||
|
document.getElementById('cardsTableBody').innerHTML = `
|
||||||
|
<tr><td colspan="10" class="text-center text-danger">
|
||||||
|
❌ Fejl ved indlæsning: ${error.message}
|
||||||
|
</td></tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Cards Table
|
||||||
|
function renderCards(cards) {
|
||||||
|
const tbody = document.getElementById('cardsTableBody');
|
||||||
|
|
||||||
|
if (!cards || cards.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr><td colspan="10" class="text-center text-muted py-5">
|
||||||
|
Ingen kort fundet
|
||||||
|
</td></tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = cards.map(card => {
|
||||||
|
const statusBadge = getStatusBadge(card.status);
|
||||||
|
const expiresAt = card.expires_at ?
|
||||||
|
new Date(card.expires_at).toLocaleDateString('da-DK') : '-';
|
||||||
|
|
||||||
|
// Parse decimal strings to numbers
|
||||||
|
const purchasedHours = parseFloat(card.purchased_hours);
|
||||||
|
const usedHours = parseFloat(card.used_hours);
|
||||||
|
const remainingHours = parseFloat(card.remaining_hours);
|
||||||
|
const pricePerHour = parseFloat(card.price_per_hour);
|
||||||
|
const totalAmount = parseFloat(card.total_amount);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/prepaid-cards/${card.id}" class="text-decoration-none">
|
||||||
|
<strong>${card.card_number}</strong>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>${card.customer_name || '-'}</div>
|
||||||
|
<small class="text-muted">${card.customer_email || ''}</small>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">${purchasedHours.toFixed(1)} t</td>
|
||||||
|
<td class="text-end">${usedHours.toFixed(1)} t</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<strong class="${remainingHours < 5 ? 'text-danger' : 'text-success'}">
|
||||||
|
${remainingHours.toFixed(1)} t
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">${pricePerHour.toFixed(2)} kr</td>
|
||||||
|
<td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>${expiresAt}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="/prepaid-cards/${card.id}" class="btn btn-outline-primary"
|
||||||
|
title="Se detaljer">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
${card.status === 'active' ? `
|
||||||
|
<button class="btn btn-outline-warning"
|
||||||
|
onclick="cancelCard(${card.id})" title="Annuller">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Status Badge
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const badges = {
|
||||||
|
'active': '<span class="badge bg-success">Aktiv</span>',
|
||||||
|
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
|
||||||
|
'expired': '<span class="badge bg-danger">Udløbet</span>',
|
||||||
|
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
|
||||||
|
};
|
||||||
|
return badges[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Customers for Dropdown
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/customers');
|
||||||
|
const customers = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('customerId');
|
||||||
|
select.innerHTML = '<option value="">Vælg kunde...</option>' +
|
||||||
|
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading customers:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Create Modal
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('createCardForm').reset();
|
||||||
|
createCardModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Card
|
||||||
|
async function createCard() {
|
||||||
|
const form = document.getElementById('createCardForm');
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
customer_id: parseInt(document.getElementById('customerId').value),
|
||||||
|
purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
|
||||||
|
price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
|
||||||
|
expires_at: document.getElementById('expiresAt').value || null,
|
||||||
|
notes: document.getElementById('notes').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/prepaid-cards', {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
createCardModal.hide();
|
||||||
|
loadStats();
|
||||||
|
loadCards();
|
||||||
|
|
||||||
|
alert('✅ Prepaid kort oprettet!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating card:', error);
|
||||||
|
alert('❌ Fejl: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel Card
|
||||||
|
async function cancelCard(cardId) {
|
||||||
|
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'cancelled' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Fejl ved annullering');
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
loadCards();
|
||||||
|
|
||||||
|
alert('✅ Kort annulleret');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling card:', error);
|
||||||
|
alert('❌ Fejl: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset Filters
|
||||||
|
function resetFilters() {
|
||||||
|
document.getElementById('statusFilter').value = '';
|
||||||
|
document.getElementById('customerSearch').value = '';
|
||||||
|
loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live search on customer input
|
||||||
|
document.getElementById('customerSearch').addEventListener('input', loadCards);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group-sm .btn {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@ -97,10 +97,9 @@ class EmailProcessorService:
|
|||||||
# Step 5: Match against rules (legacy support) - skip if workflow already processed
|
# Step 5: Match against rules (legacy support) - skip if workflow already processed
|
||||||
if self.rules_enabled and not workflow_processed:
|
if self.rules_enabled and not workflow_processed:
|
||||||
# Check if workflow already processed this email
|
# Check if workflow already processed this email
|
||||||
existing_execution = execute_query(
|
existing_execution = execute_query_single(
|
||||||
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
|
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
|
||||||
(email_id,), fetchone=True
|
(email_id,))
|
||||||
)
|
|
||||||
|
|
||||||
if existing_execution:
|
if existing_execution:
|
||||||
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
|
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
|
||||||
|
|||||||
@ -104,7 +104,7 @@ class EmailWorkflowService:
|
|||||||
ORDER BY priority ASC
|
ORDER BY priority ASC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
workflows = execute_query(query, (classification, confidence))
|
workflows = execute_query_single(query, (classification, confidence))
|
||||||
|
|
||||||
# Filter by additional patterns
|
# Filter by additional patterns
|
||||||
matching = []
|
matching = []
|
||||||
@ -400,16 +400,15 @@ class EmailWorkflowService:
|
|||||||
|
|
||||||
# Find vendor by email
|
# Find vendor by email
|
||||||
query = "SELECT id, name FROM vendors WHERE email = %s LIMIT 1"
|
query = "SELECT id, name FROM vendors WHERE email = %s LIMIT 1"
|
||||||
result = execute_query(query, (sender_email,), fetchone=True)
|
result = execute_query(query, (sender_email,))
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
vendor_id = result['id']
|
vendor_id = result['id']
|
||||||
|
|
||||||
# Check if already linked to avoid duplicate updates
|
# Check if already linked to avoid duplicate updates
|
||||||
current_vendor = execute_query(
|
current_vendor = execute_query_single(
|
||||||
"SELECT supplier_id FROM email_messages WHERE id = %s",
|
"SELECT supplier_id FROM email_messages WHERE id = %s",
|
||||||
(email_data['id'],), fetchone=True
|
(email_data['id'],))
|
||||||
)
|
|
||||||
|
|
||||||
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
|
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
|
||||||
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
|
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
|
||||||
@ -458,7 +457,7 @@ class EmailWorkflowService:
|
|||||||
vendor_id = email_data.get('supplier_id')
|
vendor_id = email_data.get('supplier_id')
|
||||||
|
|
||||||
# Get PDF attachments from email
|
# Get PDF attachments from email
|
||||||
attachments = execute_query(
|
attachments = execute_query_single(
|
||||||
"""SELECT filename, file_path, size_bytes, content_type
|
"""SELECT filename, file_path, size_bytes, content_type
|
||||||
FROM email_attachments
|
FROM email_attachments
|
||||||
WHERE email_id = %s AND content_type = 'application/pdf'""",
|
WHERE email_id = %s AND content_type = 'application/pdf'""",
|
||||||
@ -515,9 +514,7 @@ class EmailWorkflowService:
|
|||||||
# Check if file already exists
|
# Check if file already exists
|
||||||
existing = execute_query(
|
existing = execute_query(
|
||||||
"SELECT file_id FROM incoming_files WHERE checksum = %s",
|
"SELECT file_id FROM incoming_files WHERE checksum = %s",
|
||||||
(checksum,),
|
(checksum,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
logger.info(f"⚠️ File already exists: {attachment['filename']}")
|
logger.info(f"⚠️ File already exists: {attachment['filename']}")
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from datetime import datetime
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import execute_insert, execute_query, execute_update
|
from app.core.database import execute_insert, execute_query, execute_update, execute_query_single
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -582,11 +582,9 @@ Output: {
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Search vendors table
|
# Search vendors table
|
||||||
vendor = execute_query(
|
vendor = execute_query_single(
|
||||||
"SELECT * FROM vendors WHERE cvr_number = %s",
|
"SELECT * FROM vendors WHERE cvr_number = %s",
|
||||||
(cvr_clean,),
|
(cvr_clean,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if vendor:
|
if vendor:
|
||||||
logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {cvr_clean})")
|
logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {cvr_clean})")
|
||||||
|
|||||||
@ -22,16 +22,16 @@ class SimplyCRMService:
|
|||||||
"""Service for integrating with Simply-CRM via webservice.php (VTiger fork)"""
|
"""Service for integrating with Simply-CRM via webservice.php (VTiger fork)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Simply-CRM bruger OLD_VTIGER settings
|
# Try SIMPLYCRM_* first, fallback to OLD_VTIGER_* for backward compatibility
|
||||||
self.base_url = getattr(settings, 'OLD_VTIGER_URL', None)
|
self.base_url = getattr(settings, 'SIMPLYCRM_URL', None) or getattr(settings, 'OLD_VTIGER_URL', None)
|
||||||
self.username = getattr(settings, 'OLD_VTIGER_USERNAME', None)
|
self.username = getattr(settings, 'SIMPLYCRM_USERNAME', None) or getattr(settings, 'OLD_VTIGER_USERNAME', None)
|
||||||
self.access_key = getattr(settings, 'OLD_VTIGER_ACCESS_KEY', None)
|
self.access_key = getattr(settings, 'SIMPLYCRM_API_KEY', None) or getattr(settings, 'OLD_VTIGER_API_KEY', None)
|
||||||
|
|
||||||
self.session_name: Optional[str] = None
|
self.session_name: Optional[str] = None
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
if not all([self.base_url, self.username, self.access_key]):
|
if not all([self.base_url, self.username, self.access_key]):
|
||||||
logger.warning("⚠️ Simply-CRM credentials not configured (OLD_VTIGER_* settings)")
|
logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""Context manager entry - create session and login"""
|
"""Context manager entry - create session and login"""
|
||||||
|
|||||||
@ -208,9 +208,10 @@
|
|||||||
<h5 class="fw-bold mb-2">📦 Modul System</h5>
|
<h5 class="fw-bold mb-2">📦 Modul System</h5>
|
||||||
<p class="text-muted mb-0">Dynamisk feature loading - udvikl moduler isoleret fra core systemet</p>
|
<p class="text-muted mb-0">Dynamisk feature loading - udvikl moduler isoleret fra core systemet</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/api/v1/modules" target="_blank" class="btn btn-sm btn-outline-primary">
|
<!-- <a href="/api/v1/modules" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-box-arrow-up-right me-1"></i>API
|
<i class="bi bi-box-arrow-up-right me-1"></i>API
|
||||||
</a>
|
</a> -->
|
||||||
|
<span class="badge bg-secondary">API ikke implementeret endnu</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Start -->
|
<!-- Quick Start -->
|
||||||
|
|||||||
@ -236,7 +236,7 @@
|
|||||||
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
|
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
|
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Klippekort</a></li>
|
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -1054,8 +1054,16 @@
|
|||||||
|
|
||||||
function checkMaintenanceMode() {
|
function checkMaintenanceMode() {
|
||||||
fetch('/api/v1/backups/maintenance')
|
fetch('/api/v1/backups/maintenance')
|
||||||
.then(response => response.json())
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
// Silently ignore 404 - maintenance endpoint not implemented yet
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
if (!data) return; // Skip if endpoint doesn't exist
|
||||||
|
|
||||||
const overlay = document.getElementById('maintenance-overlay');
|
const overlay = document.getElementById('maintenance-overlay');
|
||||||
const messageEl = document.getElementById('maintenance-message');
|
const messageEl = document.getElementById('maintenance-message');
|
||||||
const etaEl = document.getElementById('maintenance-eta');
|
const etaEl = document.getElementById('maintenance-eta');
|
||||||
@ -1092,11 +1100,11 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Maintenance check error:', error);
|
// Silently ignore errors - maintenance check is not critical
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check on page load
|
// Check on page load (optional feature, don't block if not available)
|
||||||
checkMaintenanceMode();
|
checkMaintenanceMode();
|
||||||
|
|
||||||
// Check periodically (every 30 seconds when not in maintenance)
|
// Check periodically (every 30 seconds when not in maintenance)
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from typing import Dict, List, Optional
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_update
|
from app.core.database import execute_query, execute_update, execute_query_single
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.economic_service import EconomicService
|
from app.services.economic_service import EconomicService
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
@ -164,7 +164,7 @@ class TicketEconomicExportService:
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
|
|
||||||
customer = execute_query(query, (customer_id,), fetchone=True)
|
customer = execute_query_single(query, (customer_id,))
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
logger.error(f"❌ Customer {customer_id} not found")
|
logger.error(f"❌ Customer {customer_id} not found")
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import re
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert
|
from app.core.database import execute_query, execute_insert, execute_query_single
|
||||||
from app.ticket.backend.ticket_service import TicketService
|
from app.ticket.backend.ticket_service import TicketService
|
||||||
from app.ticket.backend.models import TTicketCreate, TicketPriority
|
from app.ticket.backend.models import TTicketCreate, TicketPriority
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
@ -122,7 +122,7 @@ class EmailTicketIntegration:
|
|||||||
|
|
||||||
# Find ticket by ticket_number
|
# Find ticket by ticket_number
|
||||||
query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s"
|
query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s"
|
||||||
result = execute_query(query, (ticket_number,), fetchone=True)
|
result = execute_query_single(query, (ticket_number,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead")
|
logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead")
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from datetime import datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from app.ticket.backend.models import (
|
from app.ticket.backend.models import (
|
||||||
TPrepaidCard,
|
TPrepaidCard,
|
||||||
TPrepaidCardCreate,
|
TPrepaidCardCreate,
|
||||||
@ -54,14 +54,12 @@ class KlippekortService:
|
|||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
# Check if customer already has an active card
|
# Check if customer already has an active card
|
||||||
existing = execute_query(
|
existing = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT id, card_number FROM tticket_prepaid_cards
|
SELECT id, card_number FROM tticket_prepaid_cards
|
||||||
WHERE customer_id = %s AND status = 'active'
|
WHERE customer_id = %s AND status = 'active'
|
||||||
""",
|
""",
|
||||||
(card_data.customer_id,),
|
(card_data.customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -113,11 +111,9 @@ class KlippekortService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch created card
|
# Fetch created card
|
||||||
card = execute_query(
|
card = execute_query_single(
|
||||||
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
||||||
(card_id,),
|
(card_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
|
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
|
||||||
return card
|
return card
|
||||||
@ -125,20 +121,16 @@ class KlippekortService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_card(card_id: int) -> Optional[Dict[str, Any]]:
|
def get_card(card_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""Get prepaid card by ID"""
|
"""Get prepaid card by ID"""
|
||||||
return execute_query(
|
return execute_query_single(
|
||||||
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
||||||
(card_id,),
|
(card_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
|
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""Get prepaid card with usage statistics"""
|
"""Get prepaid card with usage statistics"""
|
||||||
return execute_query(
|
return execute_query_single(
|
||||||
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
|
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
|
||||||
(card_id,),
|
(card_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
|
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
|
||||||
@ -147,14 +139,12 @@ class KlippekortService:
|
|||||||
|
|
||||||
Returns None if no active card exists.
|
Returns None if no active card exists.
|
||||||
"""
|
"""
|
||||||
return execute_query(
|
return execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM tticket_prepaid_cards
|
SELECT * FROM tticket_prepaid_cards
|
||||||
WHERE customer_id = %s AND status = 'active'
|
WHERE customer_id = %s AND status = 'active'
|
||||||
""",
|
""",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_balance(customer_id: int) -> Dict[str, Any]:
|
def check_balance(customer_id: int) -> Dict[str, Any]:
|
||||||
@ -299,11 +289,9 @@ class KlippekortService:
|
|||||||
logger.warning(f"💳 Card {card['card_number']} is now depleted")
|
logger.warning(f"💳 Card {card['card_number']} is now depleted")
|
||||||
|
|
||||||
# Fetch transaction
|
# Fetch transaction
|
||||||
transaction = execute_query(
|
transaction = execute_query_single(
|
||||||
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
|
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
|
||||||
(transaction_id,),
|
(transaction_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
|
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
|
||||||
return transaction
|
return transaction
|
||||||
@ -368,11 +356,9 @@ class KlippekortService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction = execute_query(
|
transaction = execute_query_single(
|
||||||
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
|
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
|
||||||
(transaction_id,),
|
(transaction_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
|
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
|
||||||
return transaction
|
return transaction
|
||||||
@ -392,7 +378,7 @@ class KlippekortService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of transaction dicts
|
List of transaction dicts
|
||||||
"""
|
"""
|
||||||
transactions = execute_query(
|
transactions = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM tticket_prepaid_transactions
|
SELECT * FROM tticket_prepaid_transactions
|
||||||
WHERE card_id = %s
|
WHERE card_id = %s
|
||||||
@ -496,9 +482,7 @@ class KlippekortService:
|
|||||||
# Fetch updated card
|
# Fetch updated card
|
||||||
updated = execute_query(
|
updated = execute_query(
|
||||||
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
|
||||||
(card_id,),
|
(card_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Cancelled card {card['card_number']}")
|
logger.info(f"✅ Cancelled card {card['card_number']}")
|
||||||
return updated
|
return updated
|
||||||
|
|||||||
@ -495,3 +495,289 @@ class PrepaidCardDeductRequest(BaseModel):
|
|||||||
"""Request model for deducting hours from prepaid card"""
|
"""Request model for deducting hours from prepaid card"""
|
||||||
worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort")
|
worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort")
|
||||||
hours: Decimal = Field(..., gt=0, description="Timer at trække")
|
hours: Decimal = Field(..., gt=0, description="Timer at trække")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TICKET RELATIONS MODELS (Migration 026)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TicketRelationType(str, Enum):
|
||||||
|
"""Ticket relation types"""
|
||||||
|
MERGED_INTO = "merged_into"
|
||||||
|
SPLIT_FROM = "split_from"
|
||||||
|
PARENT_OF = "parent_of"
|
||||||
|
CHILD_OF = "child_of"
|
||||||
|
RELATED_TO = "related_to"
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketRelationBase(BaseModel):
|
||||||
|
"""Base model for ticket relation"""
|
||||||
|
ticket_id: int
|
||||||
|
related_ticket_id: int
|
||||||
|
relation_type: TicketRelationType
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketRelationCreate(TTicketRelationBase):
|
||||||
|
"""Create ticket relation"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketRelation(TTicketRelationBase):
|
||||||
|
"""Full ticket relation model"""
|
||||||
|
id: int
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CALENDAR EVENTS MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CalendarEventType(str, Enum):
|
||||||
|
"""Calendar event types"""
|
||||||
|
APPOINTMENT = "appointment"
|
||||||
|
DEADLINE = "deadline"
|
||||||
|
MILESTONE = "milestone"
|
||||||
|
REMINDER = "reminder"
|
||||||
|
FOLLOW_UP = "follow_up"
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventStatus(str, Enum):
|
||||||
|
"""Calendar event status"""
|
||||||
|
PENDING = "pending"
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketCalendarEventBase(BaseModel):
|
||||||
|
"""Base model for calendar event"""
|
||||||
|
ticket_id: int
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_type: CalendarEventType = Field(default=CalendarEventType.APPOINTMENT)
|
||||||
|
event_date: date
|
||||||
|
event_time: Optional[str] = None
|
||||||
|
duration_minutes: Optional[int] = None
|
||||||
|
all_day: bool = False
|
||||||
|
status: CalendarEventStatus = Field(default=CalendarEventStatus.PENDING)
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketCalendarEventCreate(TTicketCalendarEventBase):
|
||||||
|
"""Create calendar event"""
|
||||||
|
suggested_by_ai: bool = False
|
||||||
|
ai_confidence: Optional[Decimal] = None
|
||||||
|
ai_source_text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketCalendarEvent(TTicketCalendarEventBase):
|
||||||
|
"""Full calendar event model"""
|
||||||
|
id: int
|
||||||
|
suggested_by_ai: bool = False
|
||||||
|
ai_confidence: Optional[Decimal] = None
|
||||||
|
ai_source_text: Optional[str] = None
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEMPLATES MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TTicketTemplateBase(BaseModel):
|
||||||
|
"""Base model for template"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
subject_template: Optional[str] = Field(None, max_length=500)
|
||||||
|
body_template: str = Field(..., min_length=1)
|
||||||
|
available_placeholders: Optional[List[str]] = None
|
||||||
|
default_attachments: Optional[dict] = None
|
||||||
|
is_active: bool = True
|
||||||
|
requires_approval: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketTemplateCreate(TTicketTemplateBase):
|
||||||
|
"""Create template"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketTemplate(TTicketTemplateBase):
|
||||||
|
"""Full template model"""
|
||||||
|
id: int
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
last_used_at: Optional[datetime] = None
|
||||||
|
usage_count: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderRequest(BaseModel):
|
||||||
|
"""Request to render template with data"""
|
||||||
|
template_id: int
|
||||||
|
ticket_id: int
|
||||||
|
custom_data: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderResponse(BaseModel):
|
||||||
|
"""Rendered template"""
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: str
|
||||||
|
placeholders_used: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AI SUGGESTIONS MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class AISuggestionType(str, Enum):
|
||||||
|
"""AI suggestion types"""
|
||||||
|
CONTACT_UPDATE = "contact_update"
|
||||||
|
NEW_CONTACT = "new_contact"
|
||||||
|
CATEGORY = "category"
|
||||||
|
TAG = "tag"
|
||||||
|
PRIORITY = "priority"
|
||||||
|
DEADLINE = "deadline"
|
||||||
|
CALENDAR_EVENT = "calendar_event"
|
||||||
|
TEMPLATE = "template"
|
||||||
|
MERGE = "merge"
|
||||||
|
RELATED_TICKET = "related_ticket"
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestionStatus(str, Enum):
|
||||||
|
"""AI suggestion status"""
|
||||||
|
PENDING = "pending"
|
||||||
|
ACCEPTED = "accepted"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
AUTO_EXPIRED = "auto_expired"
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketAISuggestionBase(BaseModel):
|
||||||
|
"""Base model for AI suggestion"""
|
||||||
|
ticket_id: int
|
||||||
|
suggestion_type: AISuggestionType
|
||||||
|
suggestion_data: dict # Struktureret data om forslaget
|
||||||
|
confidence: Optional[Decimal] = None
|
||||||
|
reasoning: Optional[str] = None
|
||||||
|
source_text: Optional[str] = None
|
||||||
|
source_comment_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketAISuggestionCreate(TTicketAISuggestionBase):
|
||||||
|
"""Create AI suggestion"""
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketAISuggestion(TTicketAISuggestionBase):
|
||||||
|
"""Full AI suggestion model"""
|
||||||
|
id: int
|
||||||
|
status: AISuggestionStatus = Field(default=AISuggestionStatus.PENDING)
|
||||||
|
reviewed_by_user_id: Optional[int] = None
|
||||||
|
reviewed_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestionReviewRequest(BaseModel):
|
||||||
|
"""Request to accept/reject AI suggestion"""
|
||||||
|
action: str = Field(..., pattern="^(accept|reject)$")
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# EMAIL METADATA MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TTicketEmailMetadataBase(BaseModel):
|
||||||
|
"""Base model for email metadata"""
|
||||||
|
ticket_id: int
|
||||||
|
message_id: Optional[str] = None
|
||||||
|
in_reply_to: Optional[str] = None
|
||||||
|
references: Optional[str] = None
|
||||||
|
from_email: str
|
||||||
|
from_name: Optional[str] = None
|
||||||
|
from_signature: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketEmailMetadataCreate(TTicketEmailMetadataBase):
|
||||||
|
"""Create email metadata"""
|
||||||
|
matched_contact_id: Optional[int] = None
|
||||||
|
match_confidence: Optional[Decimal] = None
|
||||||
|
match_method: Optional[str] = None
|
||||||
|
suggested_contacts: Optional[dict] = None
|
||||||
|
extracted_phone: Optional[str] = None
|
||||||
|
extracted_address: Optional[str] = None
|
||||||
|
extracted_company: Optional[str] = None
|
||||||
|
extracted_title: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TTicketEmailMetadata(TTicketEmailMetadataCreate):
|
||||||
|
"""Full email metadata model"""
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AUDIT LOG MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TTicketAuditLog(BaseModel):
|
||||||
|
"""Audit log entry"""
|
||||||
|
id: int
|
||||||
|
ticket_id: int
|
||||||
|
action: str
|
||||||
|
field_name: Optional[str] = None
|
||||||
|
old_value: Optional[str] = None
|
||||||
|
new_value: Optional[str] = None
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
performed_at: datetime
|
||||||
|
reason: Optional[str] = None
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# EXTENDED REQUEST MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TicketMergeRequest(BaseModel):
|
||||||
|
"""Request to merge tickets"""
|
||||||
|
source_ticket_ids: List[int] = Field(..., min_length=1, description="Tickets at lægge sammen")
|
||||||
|
target_ticket_id: int = Field(..., description="Primær ticket der skal beholdes")
|
||||||
|
reason: Optional[str] = Field(None, description="Hvorfor lægges de sammen")
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSplitRequest(BaseModel):
|
||||||
|
"""Request to split ticket"""
|
||||||
|
source_ticket_id: int = Field(..., description="Ticket at splitte")
|
||||||
|
comment_ids: List[int] = Field(..., min_length=1, description="Kommentarer til ny ticket")
|
||||||
|
new_subject: str = Field(..., min_length=1, description="Emne på ny ticket")
|
||||||
|
new_description: Optional[str] = Field(None, description="Beskrivelse på ny ticket")
|
||||||
|
reason: Optional[str] = Field(None, description="Hvorfor splittes ticketen")
|
||||||
|
|
||||||
|
|
||||||
|
class TicketDeadlineUpdateRequest(BaseModel):
|
||||||
|
"""Request to update ticket deadline"""
|
||||||
|
deadline: Optional[datetime] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|||||||
@ -26,9 +26,28 @@ from app.ticket.backend.models import (
|
|||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
TicketStatusUpdateRequest,
|
TicketStatusUpdateRequest,
|
||||||
WorklogReviewResponse,
|
WorklogReviewResponse,
|
||||||
WorklogBillingRequest
|
WorklogBillingRequest,
|
||||||
|
# Migration 026 models
|
||||||
|
TTicketRelation,
|
||||||
|
TTicketRelationCreate,
|
||||||
|
TTicketCalendarEvent,
|
||||||
|
TTicketCalendarEventCreate,
|
||||||
|
CalendarEventStatus,
|
||||||
|
TTicketTemplate,
|
||||||
|
TTicketTemplateCreate,
|
||||||
|
TemplateRenderRequest,
|
||||||
|
TemplateRenderResponse,
|
||||||
|
TTicketAISuggestion,
|
||||||
|
TTicketAISuggestionCreate,
|
||||||
|
AISuggestionStatus,
|
||||||
|
AISuggestionType,
|
||||||
|
AISuggestionReviewRequest,
|
||||||
|
TTicketAuditLog,
|
||||||
|
TicketMergeRequest,
|
||||||
|
TicketSplitRequest,
|
||||||
|
TicketDeadlineUpdateRequest
|
||||||
)
|
)
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -81,7 +100,7 @@ async def list_tickets(
|
|||||||
total_query += " AND customer_id = %s"
|
total_query += " AND customer_id = %s"
|
||||||
params.append(customer_id)
|
params.append(customer_id)
|
||||||
|
|
||||||
total_result = execute_query(total_query, tuple(params), fetchone=True)
|
total_result = execute_query_single(total_query, tuple(params))
|
||||||
total = total_result['count'] if total_result else 0
|
total = total_result['count'] if total_result else 0
|
||||||
|
|
||||||
return TicketListResponse(
|
return TicketListResponse(
|
||||||
@ -217,7 +236,7 @@ async def list_comments(ticket_id: int):
|
|||||||
List all comments for a ticket
|
List all comments for a ticket
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
comments = execute_query(
|
comments = execute_query_single(
|
||||||
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
|
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
|
||||||
(ticket_id,)
|
(ticket_id,)
|
||||||
)
|
)
|
||||||
@ -322,9 +341,7 @@ async def create_worklog(
|
|||||||
|
|
||||||
worklog = execute_query(
|
worklog = execute_query(
|
||||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||||
(worklog_id,),
|
(worklog_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
|
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
|
||||||
return worklog
|
return worklog
|
||||||
@ -347,11 +364,9 @@ async def update_worklog(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get current worklog
|
# Get current worklog
|
||||||
current = execute_query(
|
current = execute_query_single(
|
||||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||||
(worklog_id,),
|
(worklog_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
|
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
|
||||||
@ -384,11 +399,9 @@ async def update_worklog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch updated
|
# Fetch updated
|
||||||
worklog = execute_query(
|
worklog = execute_query_single(
|
||||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||||
(worklog_id,),
|
(worklog_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return worklog
|
return worklog
|
||||||
|
|
||||||
@ -427,7 +440,7 @@ async def review_worklog(
|
|||||||
|
|
||||||
query += " ORDER BY w.work_date DESC, t.customer_id"
|
query += " ORDER BY w.work_date DESC, t.customer_id"
|
||||||
|
|
||||||
worklogs = execute_query(query, tuple(params))
|
worklogs = execute_query_single(query, tuple(params))
|
||||||
|
|
||||||
# Calculate totals
|
# Calculate totals
|
||||||
total_hours = Decimal('0')
|
total_hours = Decimal('0')
|
||||||
@ -467,9 +480,7 @@ async def mark_worklog_billable(
|
|||||||
# Get worklog
|
# Get worklog
|
||||||
worklog = execute_query(
|
worklog = execute_query(
|
||||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||||
(worklog_id,),
|
(worklog_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not worklog:
|
if not worklog:
|
||||||
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
|
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
|
||||||
@ -700,7 +711,7 @@ async def get_stats_by_status():
|
|||||||
Get ticket statistics grouped by status
|
Get ticket statistics grouped by status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stats = execute_query(
|
stats = execute_query_single(
|
||||||
"SELECT * FROM tticket_stats_by_status ORDER BY status"
|
"SELECT * FROM tticket_stats_by_status ORDER BY status"
|
||||||
)
|
)
|
||||||
return stats or []
|
return stats or []
|
||||||
@ -725,9 +736,7 @@ async def get_open_tickets_stats():
|
|||||||
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
|
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
|
||||||
AVG(age_hours) as avg_age_hours
|
AVG(age_hours) as avg_age_hours
|
||||||
FROM tticket_open_tickets
|
FROM tticket_open_tickets
|
||||||
""",
|
""")
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return stats or {}
|
return stats or {}
|
||||||
|
|
||||||
@ -816,3 +825,586 @@ async def execute_economic_export(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error executing export: {e}")
|
logger.error(f"❌ Error executing export: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TICKET RELATIONS ENDPOINTS (Migration 026)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/merge", tags=["Ticket Relations"])
|
||||||
|
async def merge_tickets(ticket_id: int, request: TicketMergeRequest):
|
||||||
|
"""
|
||||||
|
Flet flere tickets sammen til én primær ticket
|
||||||
|
|
||||||
|
**Process**:
|
||||||
|
1. Validerer at alle source tickets eksisterer
|
||||||
|
2. Kopierer kommentarer og worklogs til target ticket
|
||||||
|
3. Opretter relation records
|
||||||
|
4. Markerer source tickets som merged
|
||||||
|
5. Logger i audit trail
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate target ticket exists
|
||||||
|
target_ticket = execute_query_single(
|
||||||
|
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
|
||||||
|
(request.target_ticket_id,)
|
||||||
|
)
|
||||||
|
if not target_ticket:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Target ticket {request.target_ticket_id} not found")
|
||||||
|
|
||||||
|
merged_count = 0
|
||||||
|
for source_id in request.source_ticket_ids:
|
||||||
|
# Validate source ticket
|
||||||
|
source_ticket = execute_query_single(
|
||||||
|
"SELECT id, ticket_number FROM tticket_tickets WHERE id = %s",
|
||||||
|
(source_id,)
|
||||||
|
)
|
||||||
|
if not source_ticket:
|
||||||
|
logger.warning(f"⚠️ Source ticket {source_id} not found, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create relation
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
|
||||||
|
VALUES (%s, %s, 'merged_into', %s, 1)
|
||||||
|
ON CONFLICT (ticket_id, related_ticket_id, relation_type) DO NOTHING""",
|
||||||
|
(source_id, request.target_ticket_id, request.reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark source as merged
|
||||||
|
execute_query(
|
||||||
|
"""UPDATE tticket_tickets
|
||||||
|
SET is_merged = true, merged_into_ticket_id = %s, status = 'closed'
|
||||||
|
WHERE id = %s""",
|
||||||
|
(request.target_ticket_id, source_id),
|
||||||
|
fetch=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
|
||||||
|
VALUES (%s, 'merged_into', %s, %s)""",
|
||||||
|
(source_id, str(request.target_ticket_id), request.reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_count += 1
|
||||||
|
logger.info(f"✅ Merged ticket {source_id} into {request.target_ticket_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"merged_count": merged_count,
|
||||||
|
"target_ticket": target_ticket,
|
||||||
|
"message": f"Successfully merged {merged_count} ticket(s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error merging tickets: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/split", tags=["Ticket Relations"])
|
||||||
|
async def split_ticket(ticket_id: int, request: TicketSplitRequest):
|
||||||
|
"""
|
||||||
|
Opdel en ticket i to - flyt kommentarer til ny ticket
|
||||||
|
|
||||||
|
**Process**:
|
||||||
|
1. Opretter ny ticket med nyt subject
|
||||||
|
2. Flytter valgte kommentarer til ny ticket
|
||||||
|
3. Opretter relation
|
||||||
|
4. Logger i audit trail
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate source ticket
|
||||||
|
source_ticket = execute_query_single(
|
||||||
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
|
(request.source_ticket_id,)
|
||||||
|
)
|
||||||
|
if not source_ticket:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Source ticket {request.source_ticket_id} not found")
|
||||||
|
|
||||||
|
# Create new ticket (inherit customer, contact, priority)
|
||||||
|
new_ticket_id = execute_insert(
|
||||||
|
"""INSERT INTO tticket_tickets
|
||||||
|
(subject, description, status, priority, customer_id, contact_id, source, created_by_user_id)
|
||||||
|
VALUES (%s, %s, 'open', %s, %s, %s, 'manual', 1)
|
||||||
|
RETURNING id""",
|
||||||
|
(request.new_subject, request.new_description, source_ticket['priority'],
|
||||||
|
source_ticket['customer_id'], source_ticket['contact_id'])
|
||||||
|
)
|
||||||
|
|
||||||
|
new_ticket_number = execute_query_single(
|
||||||
|
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
|
||||||
|
(new_ticket_id,)
|
||||||
|
)['ticket_number']
|
||||||
|
|
||||||
|
# Move comments
|
||||||
|
moved_comments = 0
|
||||||
|
for comment_id in request.comment_ids:
|
||||||
|
result = execute_query(
|
||||||
|
"UPDATE tticket_comments SET ticket_id = %s WHERE id = %s AND ticket_id = %s",
|
||||||
|
(new_ticket_id, comment_id, request.source_ticket_id),
|
||||||
|
fetch=False
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
moved_comments += 1
|
||||||
|
|
||||||
|
# Create relation
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
|
||||||
|
VALUES (%s, %s, 'split_from', %s, 1)""",
|
||||||
|
(new_ticket_id, request.source_ticket_id, request.reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
|
||||||
|
VALUES (%s, 'split_into', %s, %s)""",
|
||||||
|
(request.source_ticket_id, str(new_ticket_id), request.reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Split ticket {request.source_ticket_id} into {new_ticket_id}, moved {moved_comments} comments")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"new_ticket_id": new_ticket_id,
|
||||||
|
"new_ticket_number": new_ticket_number,
|
||||||
|
"moved_comments": moved_comments,
|
||||||
|
"message": f"Successfully split ticket into {new_ticket_number}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error splitting ticket: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
|
||||||
|
async def get_ticket_relations(ticket_id: int):
|
||||||
|
"""Hent alle relationer for en ticket (begge retninger)"""
|
||||||
|
try:
|
||||||
|
relations = execute_query(
|
||||||
|
"""SELECT r.*,
|
||||||
|
t.ticket_number as related_ticket_number,
|
||||||
|
t.subject as related_subject,
|
||||||
|
t.status as related_status
|
||||||
|
FROM tticket_all_relations r
|
||||||
|
LEFT JOIN tticket_tickets t ON r.related_ticket_id = t.id
|
||||||
|
WHERE r.ticket_id = %s
|
||||||
|
ORDER BY r.created_at DESC""",
|
||||||
|
(ticket_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"relations": relations, "total": len(relations)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching relations: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
|
||||||
|
async def create_ticket_relation(ticket_id: int, relation: TTicketRelationCreate):
|
||||||
|
"""Opret en relation mellem to tickets"""
|
||||||
|
try:
|
||||||
|
# Validate both tickets exist
|
||||||
|
for tid in [relation.ticket_id, relation.related_ticket_id]:
|
||||||
|
ticket = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (tid,))
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Ticket {tid} not found")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, 1)""",
|
||||||
|
(relation.ticket_id, relation.related_ticket_id, relation.relation_type, relation.reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": "Relation created"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating relation: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CALENDAR EVENTS ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
|
||||||
|
async def get_calendar_events(ticket_id: int):
|
||||||
|
"""Hent alle kalender events for en ticket"""
|
||||||
|
try:
|
||||||
|
events = execute_query(
|
||||||
|
"""SELECT * FROM tticket_calendar_events
|
||||||
|
WHERE ticket_id = %s
|
||||||
|
ORDER BY event_date DESC, event_time DESC NULLS LAST""",
|
||||||
|
(ticket_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"events": events, "total": len(events)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching calendar events: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
|
||||||
|
async def create_calendar_event(ticket_id: int, event: TTicketCalendarEventCreate):
|
||||||
|
"""Opret kalender event (manual eller AI-foreslået)"""
|
||||||
|
try:
|
||||||
|
event_id = execute_insert(
|
||||||
|
"""INSERT INTO tticket_calendar_events
|
||||||
|
(ticket_id, title, description, event_type, event_date, event_time,
|
||||||
|
duration_minutes, all_day, status, suggested_by_ai, ai_confidence,
|
||||||
|
ai_source_text, created_by_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
|
||||||
|
RETURNING id""",
|
||||||
|
(ticket_id, event.title, event.description, event.event_type,
|
||||||
|
event.event_date, event.event_time, event.duration_minutes,
|
||||||
|
event.all_day, event.status, event.suggested_by_ai,
|
||||||
|
event.ai_confidence, event.ai_source_text)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Created calendar event {event_id} for ticket {ticket_id}")
|
||||||
|
|
||||||
|
return {"status": "success", "event_id": event_id, "message": "Calendar event created"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating calendar event: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
|
||||||
|
async def update_calendar_event(ticket_id: int, event_id: int, status: CalendarEventStatus):
|
||||||
|
"""Opdater calendar event status"""
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"""UPDATE tticket_calendar_events
|
||||||
|
SET status = %s, updated_at = CURRENT_TIMESTAMP,
|
||||||
|
completed_at = CASE WHEN %s = 'completed' THEN CURRENT_TIMESTAMP ELSE completed_at END
|
||||||
|
WHERE id = %s AND ticket_id = %s""",
|
||||||
|
(status, status, event_id, ticket_id),
|
||||||
|
fetch=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": "Event updated"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating calendar event: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
|
||||||
|
async def delete_calendar_event(ticket_id: int, event_id: int):
|
||||||
|
"""Slet calendar event"""
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"DELETE FROM tticket_calendar_events WHERE id = %s AND ticket_id = %s",
|
||||||
|
(event_id, ticket_id),
|
||||||
|
fetch=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "message": "Event deleted"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting calendar event: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEMPLATES ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=List[TTicketTemplate], tags=["Templates"])
|
||||||
|
async def list_templates(
|
||||||
|
category: Optional[str] = Query(None, description="Filter by category"),
|
||||||
|
active_only: bool = Query(True, description="Only show active templates")
|
||||||
|
):
|
||||||
|
"""List alle tilgængelige templates"""
|
||||||
|
try:
|
||||||
|
query = "SELECT * FROM tticket_templates WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query += " AND category = %s"
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query += " AND is_active = true"
|
||||||
|
|
||||||
|
query += " ORDER BY category, name"
|
||||||
|
|
||||||
|
templates = execute_query(query, tuple(params) if params else None)
|
||||||
|
|
||||||
|
return templates
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error listing templates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates", tags=["Templates"])
|
||||||
|
async def create_template(template: TTicketTemplateCreate):
|
||||||
|
"""Opret ny template"""
|
||||||
|
try:
|
||||||
|
template_id = execute_insert(
|
||||||
|
"""INSERT INTO tticket_templates
|
||||||
|
(name, description, category, subject_template, body_template,
|
||||||
|
available_placeholders, default_attachments, is_active,
|
||||||
|
requires_approval, created_by_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
|
||||||
|
RETURNING id""",
|
||||||
|
(template.name, template.description, template.category,
|
||||||
|
template.subject_template, template.body_template,
|
||||||
|
template.available_placeholders, template.default_attachments,
|
||||||
|
template.is_active, template.requires_approval)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Created template {template_id}: {template.name}")
|
||||||
|
|
||||||
|
return {"status": "success", "template_id": template_id, "message": "Template created"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/render-template", response_model=TemplateRenderResponse, tags=["Templates"])
|
||||||
|
async def render_template(ticket_id: int, request: TemplateRenderRequest):
|
||||||
|
"""
|
||||||
|
Render template med ticket data
|
||||||
|
|
||||||
|
Erstatter placeholders med faktiske værdier:
|
||||||
|
- {{ticket_number}}
|
||||||
|
- {{ticket_subject}}
|
||||||
|
- {{customer_name}}
|
||||||
|
- {{contact_name}}
|
||||||
|
- etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get template
|
||||||
|
template = execute_query_single(
|
||||||
|
"SELECT * FROM tticket_templates WHERE id = %s",
|
||||||
|
(request.template_id,)
|
||||||
|
)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
# Get ticket with customer and contact data
|
||||||
|
ticket_data = execute_query_single(
|
||||||
|
"""SELECT t.*,
|
||||||
|
c.name as customer_name,
|
||||||
|
con.name as contact_name,
|
||||||
|
con.email as contact_email
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN customers c ON t.customer_id = c.id
|
||||||
|
LEFT JOIN contacts con ON t.contact_id = con.id
|
||||||
|
WHERE t.id = %s""",
|
||||||
|
(ticket_id,)
|
||||||
|
)
|
||||||
|
if not ticket_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
# Build replacement dict
|
||||||
|
replacements = {
|
||||||
|
'{{ticket_number}}': ticket_data.get('ticket_number', ''),
|
||||||
|
'{{ticket_subject}}': ticket_data.get('subject', ''),
|
||||||
|
'{{customer_name}}': ticket_data.get('customer_name', ''),
|
||||||
|
'{{contact_name}}': ticket_data.get('contact_name', ''),
|
||||||
|
'{{contact_email}}': ticket_data.get('contact_email', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add custom data
|
||||||
|
if request.custom_data:
|
||||||
|
for key, value in request.custom_data.items():
|
||||||
|
replacements[f'{{{{{key}}}}}'] = str(value)
|
||||||
|
|
||||||
|
# Render subject and body
|
||||||
|
rendered_subject = template['subject_template']
|
||||||
|
rendered_body = template['body_template']
|
||||||
|
placeholders_used = []
|
||||||
|
|
||||||
|
for placeholder, value in replacements.items():
|
||||||
|
if placeholder in rendered_body or (rendered_subject and placeholder in rendered_subject):
|
||||||
|
placeholders_used.append(placeholder)
|
||||||
|
if rendered_subject:
|
||||||
|
rendered_subject = rendered_subject.replace(placeholder, value)
|
||||||
|
rendered_body = rendered_body.replace(placeholder, value)
|
||||||
|
|
||||||
|
return TemplateRenderResponse(
|
||||||
|
subject=rendered_subject,
|
||||||
|
body=rendered_body,
|
||||||
|
placeholders_used=placeholders_used
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error rendering template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AI SUGGESTIONS ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}/suggestions", response_model=List[TTicketAISuggestion], tags=["AI Suggestions"])
|
||||||
|
async def get_ai_suggestions(
|
||||||
|
ticket_id: int,
|
||||||
|
status: Optional[AISuggestionStatus] = Query(None, description="Filter by status"),
|
||||||
|
suggestion_type: Optional[AISuggestionType] = Query(None, description="Filter by type")
|
||||||
|
):
|
||||||
|
"""Hent AI forslag for ticket"""
|
||||||
|
try:
|
||||||
|
query = "SELECT * FROM tticket_ai_suggestions WHERE ticket_id = %s"
|
||||||
|
params = [ticket_id]
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query += " AND status = %s"
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if suggestion_type:
|
||||||
|
query += " AND suggestion_type = %s"
|
||||||
|
params.append(suggestion_type)
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
suggestions = execute_query(query, tuple(params))
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching AI suggestions: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/suggestions/{suggestion_id}/review", tags=["AI Suggestions"])
|
||||||
|
async def review_ai_suggestion(ticket_id: int, suggestion_id: int, review: AISuggestionReviewRequest):
|
||||||
|
"""
|
||||||
|
Accepter eller afvis AI forslag
|
||||||
|
|
||||||
|
**VIGTIGT**: Denne endpoint ændrer KUN suggestion status.
|
||||||
|
Den udfører IKKE automatisk den foreslåede handling.
|
||||||
|
Brugeren skal selv implementere ændringen efter accept.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get suggestion
|
||||||
|
suggestion = execute_query_single(
|
||||||
|
"SELECT * FROM tticket_ai_suggestions WHERE id = %s AND ticket_id = %s",
|
||||||
|
(suggestion_id, ticket_id)
|
||||||
|
)
|
||||||
|
if not suggestion:
|
||||||
|
raise HTTPException(status_code=404, detail="Suggestion not found")
|
||||||
|
|
||||||
|
if suggestion['status'] != 'pending':
|
||||||
|
raise HTTPException(status_code=400, detail=f"Suggestion already {suggestion['status']}")
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
new_status = 'accepted' if review.action == 'accept' else 'rejected'
|
||||||
|
execute_query(
|
||||||
|
"""UPDATE tticket_ai_suggestions
|
||||||
|
SET status = %s, reviewed_by_user_id = 1, reviewed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s""",
|
||||||
|
(new_status, suggestion_id),
|
||||||
|
fetch=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
|
||||||
|
VALUES (%s, %s, %s, %s)""",
|
||||||
|
(ticket_id, f'ai_suggestion_{review.action}ed',
|
||||||
|
f"{suggestion['suggestion_type']}: {suggestion_id}", review.note)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ AI suggestion {suggestion_id} {review.action}ed for ticket {ticket_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"action": review.action,
|
||||||
|
"suggestion_type": suggestion['suggestion_type'],
|
||||||
|
"message": f"Suggestion {review.action}ed. Manual implementation required if accepted."
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error reviewing AI suggestion: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEADLINE ENDPOINT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.put("/tickets/{ticket_id}/deadline", tags=["Tickets"])
|
||||||
|
async def update_ticket_deadline(ticket_id: int, request: TicketDeadlineUpdateRequest):
|
||||||
|
"""Opdater ticket deadline"""
|
||||||
|
try:
|
||||||
|
# Get current deadline
|
||||||
|
current = execute_query_single(
|
||||||
|
"SELECT deadline FROM tticket_tickets WHERE id = %s",
|
||||||
|
(ticket_id,)
|
||||||
|
)
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|
||||||
|
# Update deadline
|
||||||
|
execute_query(
|
||||||
|
"UPDATE tticket_tickets SET deadline = %s WHERE id = %s",
|
||||||
|
(request.deadline, ticket_id),
|
||||||
|
fetch=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit (handled by trigger automatically)
|
||||||
|
if request.reason:
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value, reason)
|
||||||
|
VALUES (%s, 'deadline_change', 'deadline', %s, %s, %s)""",
|
||||||
|
(ticket_id, str(current.get('deadline')), str(request.deadline), request.reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Updated deadline for ticket {ticket_id}: {request.deadline}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"old_deadline": current.get('deadline'),
|
||||||
|
"new_deadline": request.deadline,
|
||||||
|
"message": "Deadline updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating deadline: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AUDIT LOG ENDPOINT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}/audit-log", response_model=List[TTicketAuditLog], tags=["Audit"])
|
||||||
|
async def get_audit_log(
|
||||||
|
ticket_id: int,
|
||||||
|
limit: int = Query(50, ge=1, le=200, description="Number of entries"),
|
||||||
|
offset: int = Query(0, ge=0, description="Offset for pagination")
|
||||||
|
):
|
||||||
|
"""Hent audit log for ticket (sporbarhed)"""
|
||||||
|
try:
|
||||||
|
logs = execute_query(
|
||||||
|
"""SELECT * FROM tticket_audit_log
|
||||||
|
WHERE ticket_id = %s
|
||||||
|
ORDER BY performed_at DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(ticket_id, limit, offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
return logs
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching audit log: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from app.ticket.backend.models import (
|
from app.ticket.backend.models import (
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
@ -84,13 +84,14 @@ class TicketService:
|
|||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
# Insert ticket (trigger will auto-generate ticket_number if NULL)
|
# Insert ticket (trigger will auto-generate ticket_number if NULL)
|
||||||
ticket_id = execute_insert(
|
result = execute_query_single(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tticket_tickets (
|
INSERT INTO tticket_tickets (
|
||||||
ticket_number, subject, description, status, priority, category,
|
ticket_number, subject, description, status, priority, category,
|
||||||
customer_id, contact_id, assigned_to_user_id, created_by_user_id,
|
customer_id, contact_id, assigned_to_user_id, created_by_user_id,
|
||||||
source, tags, custom_fields
|
source, tags, custom_fields
|
||||||
) 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)
|
||||||
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
ticket_data.ticket_number,
|
ticket_data.ticket_number,
|
||||||
@ -109,6 +110,11 @@ class TicketService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise Exception("Failed to create ticket - no ID returned")
|
||||||
|
|
||||||
|
ticket_id = result['id']
|
||||||
|
|
||||||
# Log creation
|
# Log creation
|
||||||
TicketService.log_audit(
|
TicketService.log_audit(
|
||||||
ticket_id=ticket_id,
|
ticket_id=ticket_id,
|
||||||
@ -120,11 +126,9 @@ class TicketService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch created ticket
|
# Fetch created ticket
|
||||||
ticket = execute_query(
|
ticket = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
|
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
|
||||||
return ticket
|
return ticket
|
||||||
@ -147,11 +151,9 @@ class TicketService:
|
|||||||
Updated ticket dict
|
Updated ticket dict
|
||||||
"""
|
"""
|
||||||
# Get current ticket
|
# Get current ticket
|
||||||
current = execute_query(
|
current = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
raise ValueError(f"Ticket {ticket_id} not found")
|
raise ValueError(f"Ticket {ticket_id} not found")
|
||||||
@ -198,11 +200,9 @@ class TicketService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch updated ticket
|
# Fetch updated ticket
|
||||||
updated = execute_query(
|
updated = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated ticket {updated['ticket_number']}")
|
logger.info(f"✅ Updated ticket {updated['ticket_number']}")
|
||||||
return updated
|
return updated
|
||||||
@ -230,11 +230,9 @@ class TicketService:
|
|||||||
ValueError: If transition is not allowed
|
ValueError: If transition is not allowed
|
||||||
"""
|
"""
|
||||||
# Get current ticket
|
# Get current ticket
|
||||||
current = execute_query(
|
current = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
raise ValueError(f"Ticket {ticket_id} not found")
|
raise ValueError(f"Ticket {ticket_id} not found")
|
||||||
@ -280,11 +278,9 @@ class TicketService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch updated ticket
|
# Fetch updated ticket
|
||||||
updated = execute_query(
|
updated = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status} → {new_status}")
|
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status} → {new_status}")
|
||||||
return updated
|
return updated
|
||||||
@ -307,11 +303,9 @@ class TicketService:
|
|||||||
Updated ticket dict
|
Updated ticket dict
|
||||||
"""
|
"""
|
||||||
# Get current assignment
|
# Get current assignment
|
||||||
current = execute_query(
|
current = execute_query_single(
|
||||||
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
|
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
raise ValueError(f"Ticket {ticket_id} not found")
|
raise ValueError(f"Ticket {ticket_id} not found")
|
||||||
@ -334,11 +328,9 @@ class TicketService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch updated ticket
|
# Fetch updated ticket
|
||||||
updated = execute_query(
|
updated = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
|
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
|
||||||
return updated
|
return updated
|
||||||
@ -363,11 +355,9 @@ class TicketService:
|
|||||||
Created comment dict
|
Created comment dict
|
||||||
"""
|
"""
|
||||||
# Verify ticket exists
|
# Verify ticket exists
|
||||||
ticket = execute_query(
|
ticket = execute_query_single(
|
||||||
"SELECT id FROM tticket_tickets WHERE id = %s",
|
"SELECT id FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise ValueError(f"Ticket {ticket_id} not found")
|
raise ValueError(f"Ticket {ticket_id} not found")
|
||||||
@ -389,11 +379,9 @@ class TicketService:
|
|||||||
|
|
||||||
# Update first_response_at if this is the first non-internal comment
|
# Update first_response_at if this is the first non-internal comment
|
||||||
if not is_internal:
|
if not is_internal:
|
||||||
ticket = execute_query(
|
ticket = execute_query_single(
|
||||||
"SELECT first_response_at FROM tticket_tickets WHERE id = %s",
|
"SELECT first_response_at FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if not ticket['first_response_at']:
|
if not ticket['first_response_at']:
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
|
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
@ -411,11 +399,9 @@ class TicketService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch created comment
|
# Fetch created comment
|
||||||
comment = execute_query(
|
comment = execute_query_single(
|
||||||
"SELECT * FROM tticket_comments WHERE id = %s",
|
"SELECT * FROM tticket_comments WHERE id = %s",
|
||||||
(comment_id,),
|
(comment_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
|
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
|
||||||
return comment
|
return comment
|
||||||
@ -471,19 +457,15 @@ class TicketService:
|
|||||||
Returns:
|
Returns:
|
||||||
Ticket dict with stats or None if not found
|
Ticket dict with stats or None if not found
|
||||||
"""
|
"""
|
||||||
ticket = execute_query(
|
ticket = execute_query_single(
|
||||||
"SELECT * FROM tticket_open_tickets WHERE id = %s",
|
"SELECT * FROM tticket_open_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# If not in open_tickets view, fetch from main table
|
# If not in open_tickets view, fetch from main table
|
||||||
if not ticket:
|
if not ticket:
|
||||||
ticket = execute_query(
|
ticket = execute_query_single(
|
||||||
"SELECT * FROM tticket_tickets WHERE id = %s",
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
||||||
(ticket_id,),
|
(ticket_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|||||||
@ -2,360 +2,282 @@
|
|||||||
|
|
||||||
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
|
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.stat-card {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 4px;
|
|
||||||
background: var(--accent);
|
|
||||||
transform: scaleX(0);
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover::before {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .icon {
|
|
||||||
font-size: 2rem;
|
|
||||||
opacity: 0.3;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.status-open h3 { color: #17a2b8; }
|
|
||||||
.stat-card.status-in-progress h3 { color: #ffc107; }
|
|
||||||
.stat-card.status-resolved h3 { color: #28a745; }
|
|
||||||
.stat-card.status-closed h3 { color: #6c757d; }
|
|
||||||
|
|
||||||
.ticket-list {
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-list th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom: 2px solid var(--accent-light);
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-list td {
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
border-bottom: 1px solid var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-row {
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-row:hover {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-status-open {
|
|
||||||
background-color: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-status-in_progress {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-status-pending_customer {
|
|
||||||
background-color: #e2e3e5;
|
|
||||||
color: #383d41;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-status-resolved {
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-status-closed {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-priority-low {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-priority-normal {
|
|
||||||
background-color: #e2e3e5;
|
|
||||||
color: #383d41;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-priority-high {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-priority-urgent, .badge-priority-critical {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-number {
|
|
||||||
font-family: 'Monaco', 'Courier New', monospace;
|
|
||||||
background: var(--accent-light);
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worklog-stats {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worklog-stat {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worklog-stat h4 {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worklog-stat p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state i {
|
|
||||||
font-size: 4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid px-4">
|
<div class="container-fluid py-4">
|
||||||
<!-- Page Header -->
|
<!-- Header -->
|
||||||
<div class="section-header">
|
<div class="row mb-4">
|
||||||
<div>
|
<div class="col">
|
||||||
<h1 class="mb-2">
|
<h1 class="h3 mb-0">🎫 Support Dashboard</h1>
|
||||||
<i class="bi bi-speedometer2"></i> Ticket Dashboard
|
<p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
|
||||||
</h1>
|
</div>
|
||||||
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
|
<div class="col-auto">
|
||||||
</div>
|
<button class="btn btn-primary" onclick="window.location.href='/ticket/tickets/new'">
|
||||||
<div class="quick-actions">
|
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||||
<a href="/ticket/tickets/new" class="btn btn-primary">
|
</button>
|
||||||
<i class="bi bi-plus-circle"></i> Ny Ticket
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Overview -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('open')">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-info bg-opacity-10 p-3 d-inline-flex mb-3">
|
||||||
|
<i class="bi bi-inbox-fill text-info fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-1 text-info">{{ stats.open_count or 0 }}</h2>
|
||||||
|
<p class="text-muted small mb-0">Åbne</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('in_progress')">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-warning bg-opacity-10 p-3 d-inline-flex mb-3">
|
||||||
|
<i class="bi bi-hourglass-split text-warning fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-1 text-warning">{{ stats.in_progress_count or 0 }}</h2>
|
||||||
|
<p class="text-muted small mb-0">I Gang</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('pending_customer')">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-secondary bg-opacity-10 p-3 d-inline-flex mb-3">
|
||||||
|
<i class="bi bi-clock-fill text-secondary fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-1 text-secondary">{{ stats.pending_count or 0 }}</h2>
|
||||||
|
<p class="text-muted small mb-0">Afventer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('resolved')">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-success bg-opacity-10 p-3 d-inline-flex mb-3">
|
||||||
|
<i class="bi bi-check-circle-fill text-success fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-1 text-success">{{ stats.resolved_count or 0 }}</h2>
|
||||||
|
<p class="text-muted small mb-0">Løst</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('closed')">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-dark bg-opacity-10 p-3 d-inline-flex mb-3">
|
||||||
|
<i class="bi bi-archive-fill text-dark fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-1 text-dark">{{ stats.closed_count or 0 }}</h2>
|
||||||
|
<p class="text-muted small mb-0">Lukket</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100 bg-primary text-white">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-white bg-opacity-25 p-3 d-inline-flex mb-3">
|
||||||
|
<i class="bi bi-ticket-detailed-fill fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-1">{{ stats.total_count or 0 }}</h2>
|
||||||
|
<p class="small mb-0 opacity-75">I Alt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ticket Statistics -->
|
<!-- Worklog & Prepaid Overview -->
|
||||||
<div class="row mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-6">
|
||||||
<div class="card stat-card status-open" onclick="filterTickets('open')">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="icon"><i class="bi bi-inbox"></i></div>
|
<div class="card-header bg-white border-0 py-3">
|
||||||
<h3>{{ stats.open_count or 0 }}</h3>
|
<h5 class="mb-0">⏱️ Worklog Status</h5>
|
||||||
<p>Nye Tickets</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="card-body">
|
||||||
<div class="col-md-3">
|
<div class="row">
|
||||||
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
|
<div class="col-6 border-end">
|
||||||
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
|
<div class="text-center p-3">
|
||||||
<h3>{{ stats.in_progress_count or 0 }}</h3>
|
<h3 class="text-warning mb-2">{{ worklog_stats.draft_count or 0 }}</h3>
|
||||||
<p>I Gang</p>
|
<p class="text-muted small mb-1">Kladder</p>
|
||||||
</div>
|
<p class="mb-0"><strong>{{ "%.1f"|format(worklog_stats.draft_hours|float if worklog_stats.draft_hours else 0) }} timer</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
</div>
|
||||||
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
|
<div class="col-6">
|
||||||
<div class="icon"><i class="bi bi-check-circle"></i></div>
|
<div class="text-center p-3">
|
||||||
<h3>{{ stats.resolved_count or 0 }}</h3>
|
<h3 class="text-success mb-2">{{ worklog_stats.billable_count or 0 }}</h3>
|
||||||
<p>Løst</p>
|
<p class="text-muted small mb-1">Fakturerbare</p>
|
||||||
</div>
|
<p class="mb-0"><strong>{{ "%.1f"|format(worklog_stats.billable_hours|float if worklog_stats.billable_hours else 0) }} timer</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
</div>
|
||||||
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
|
</div>
|
||||||
<div class="icon"><i class="bi bi-archive"></i></div>
|
<div class="text-center pt-3 border-top">
|
||||||
<h3>{{ stats.closed_count or 0 }}</h3>
|
<a href="/ticket/worklog/review" class="btn btn-outline-primary btn-sm">
|
||||||
<p>Lukket</p>
|
<i class="bi bi-check-square"></i> Godkend Worklog
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Worklog Statistics -->
|
<div class="col-md-6">
|
||||||
<div class="worklog-stats">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="worklog-stat">
|
<div class="card-header bg-white border-0 py-3">
|
||||||
<h4>{{ worklog_stats.draft_count or 0 }}</h4>
|
<h5 class="mb-0">💳 Prepaid Cards</h5>
|
||||||
<p>Draft Worklog</p>
|
</div>
|
||||||
</div>
|
<div class="card-body" id="prepaidStats">
|
||||||
<div class="worklog-stat">
|
<div class="text-center py-3">
|
||||||
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4>
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
<p>Udraft Timer</p>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="worklog-stat">
|
</div>
|
||||||
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
|
</div>
|
||||||
<p>Billable Entries</p>
|
|
||||||
</div>
|
|
||||||
<div class="worklog-stat">
|
|
||||||
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
|
|
||||||
<p>Billable Timer</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recent Tickets -->
|
<!-- Recent Tickets -->
|
||||||
<div class="section-header">
|
<div class="card border-0 shadow-sm">
|
||||||
<h2>
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-clock-history"></i> Seneste Tickets
|
<h5 class="mb-0">📋 Seneste Tickets</h5>
|
||||||
</h2>
|
<a href="/ticket/tickets" class="btn btn-sm btn-outline-secondary">
|
||||||
<a href="/ticket/tickets" class="btn btn-outline-secondary">
|
Se Alle <i class="bi bi-arrow-right"></i>
|
||||||
<i class="bi bi-list-ul"></i> Se Alle
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
{% if recent_tickets %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table ticket-list mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ticket</th>
|
<th>Ticket #</th>
|
||||||
|
<th>Emne</th>
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Prioritet</th>
|
<th>Prioritet</th>
|
||||||
<th>Oprettet</th>
|
<th>Oprettet</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for ticket in recent_tickets %}
|
{% if recent_tickets %}
|
||||||
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
|
{% for ticket in recent_tickets %}
|
||||||
<td>
|
<tr onclick="window.location.href='/ticket/tickets/{{ ticket.id }}'" style="cursor: pointer;">
|
||||||
<span class="ticket-number">{{ ticket.ticket_number }}</span>
|
<td><strong>{{ ticket.ticket_number }}</strong></td>
|
||||||
<br>
|
<td>{{ ticket.subject }}</td>
|
||||||
<strong>{{ ticket.subject }}</strong>
|
<td>{{ ticket.customer_name or '-' }}</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
{% if ticket.status == 'open' %}
|
||||||
{% if ticket.customer_name %}
|
<span class="badge bg-info">Åben</span>
|
||||||
{{ ticket.customer_name }}
|
{% elif ticket.status == 'in_progress' %}
|
||||||
{% else %}
|
<span class="badge bg-warning">I Gang</span>
|
||||||
<span class="text-muted">-</span>
|
{% elif ticket.status == 'pending_customer' %}
|
||||||
{% endif %}
|
<span class="badge bg-secondary">Afventer</span>
|
||||||
</td>
|
{% elif ticket.status == 'resolved' %}
|
||||||
<td>
|
<span class="badge bg-success">Løst</span>
|
||||||
<span class="badge badge-status-{{ ticket.status }}">
|
{% elif ticket.status == 'closed' %}
|
||||||
{{ ticket.status.replace('_', ' ').title() }}
|
<span class="badge bg-dark">Lukket</span>
|
||||||
</span>
|
{% else %}
|
||||||
</td>
|
<span class="badge bg-secondary">{{ ticket.status }}</span>
|
||||||
<td>
|
{% endif %}
|
||||||
<span class="badge badge-priority-{{ ticket.priority }}">
|
</td>
|
||||||
{{ ticket.priority.title() }}
|
<td>
|
||||||
</span>
|
{% if ticket.priority == 'urgent' %}
|
||||||
</td>
|
<span class="badge bg-danger">Akut</span>
|
||||||
<td>
|
{% elif ticket.priority == 'high' %}
|
||||||
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
<span class="badge bg-warning">Høj</span>
|
||||||
</td>
|
{% elif ticket.priority == 'normal' %}
|
||||||
</tr>
|
<span class="badge bg-info">Normal</span>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Lav</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ ticket.created_at.strftime('%d/%m/%Y %H:%M') if ticket.created_at else '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ticket/tickets/{{ ticket.id }}'">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted py-5">
|
||||||
|
Ingen tickets endnu
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="empty-state">
|
|
||||||
<i class="bi bi-inbox"></i>
|
<script>
|
||||||
<h3>Ingen tickets endnu</h3>
|
// Load prepaid stats
|
||||||
<p>Opret din første ticket for at komme i gang</p>
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
<a href="/ticket/tickets/new" class="btn btn-primary mt-3">
|
loadPrepaidStats();
|
||||||
<i class="bi bi-plus-circle"></i> Opret Ticket
|
});
|
||||||
|
|
||||||
|
async function loadPrepaidStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/prepaid-cards/stats/summary');
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('prepaidStats').innerHTML = `
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6 border-end">
|
||||||
|
<h4 class="text-success mb-1">${stats.active_count || 0}</h4>
|
||||||
|
<p class="text-muted small mb-0">Aktive Kort</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h4 class="text-primary mb-1">${parseFloat(stats.total_remaining_hours || 0).toFixed(1)} t</h4>
|
||||||
|
<p class="text-muted small mb-0">Timer Tilbage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center pt-3 border-top">
|
||||||
|
<a href="/prepaid-cards" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-credit-card-2-front"></i> Se Alle Kort
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`;
|
||||||
{% endif %}
|
} catch (error) {
|
||||||
</div>
|
console.error('Error loading prepaid stats:', error);
|
||||||
{% endblock %}
|
document.getElementById('prepaidStats').innerHTML = `
|
||||||
|
<p class="text-center text-muted mb-0">Kunne ikke indlæse data</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{% block extra_js %}
|
function filterByStatus(status) {
|
||||||
<script>
|
window.location.href = `/ticket/tickets?status=${status}`;
|
||||||
// Filter tickets by status
|
}
|
||||||
function filterTickets(status) {
|
</script>
|
||||||
window.location.href = `/ticket/tickets?status=${status}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-refresh every 5 minutes
|
<style>
|
||||||
setTimeout(() => {
|
.card {
|
||||||
location.reload();
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
}, 300000);
|
}
|
||||||
</script>
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: rgba(15, 76, 117, 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
361
app/ticket/frontend/dashboard.html.old
Normal file
361
app/ticket/frontend/dashboard.html.old
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.status-open h3 { color: #17a2b8; }
|
||||||
|
.stat-card.status-in-progress h3 { color: #ffc107; }
|
||||||
|
.stat-card.status-resolved h3 { color: #28a745; }
|
||||||
|
.stat-card.status-closed h3 { color: #6c757d; }
|
||||||
|
|
||||||
|
.ticket-list {
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 2px solid var(--accent-light);
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list td {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-bottom: 1px solid var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row:hover {
|
||||||
|
background-color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-status-open {
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-status-in_progress {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-status-pending_customer {
|
||||||
|
background-color: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-status-resolved {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-status-closed {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-priority-low {
|
||||||
|
background-color: var(--accent-light);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-priority-normal {
|
||||||
|
background-color: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-priority-high {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-priority-urgent, .badge-priority-critical {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-number {
|
||||||
|
font-family: 'Monaco', 'Courier New', monospace;
|
||||||
|
background: var(--accent-light);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worklog-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worklog-stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worklog-stat h4 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worklog-stat p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-2">
|
||||||
|
<i class="bi bi-speedometer2"></i> Ticket Dashboard
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<a href="/ticket/tickets/new" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Statistics -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card stat-card status-open" onclick="filterTickets('open')">
|
||||||
|
<div class="icon"><i class="bi bi-inbox"></i></div>
|
||||||
|
<h3>{{ stats.open_count or 0 }}</h3>
|
||||||
|
<p>Nye Tickets</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
|
||||||
|
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
|
||||||
|
<h3>{{ stats.in_progress_count or 0 }}</h3>
|
||||||
|
<p>I Gang</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
|
||||||
|
<div class="icon"><i class="bi bi-check-circle"></i></div>
|
||||||
|
<h3>{{ stats.resolved_count or 0 }}</h3>
|
||||||
|
<p>Løst</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
|
||||||
|
<div class="icon"><i class="bi bi-archive"></i></div>
|
||||||
|
<h3>{{ stats.closed_count or 0 }}</h3>
|
||||||
|
<p>Lukket</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Worklog Statistics -->
|
||||||
|
<div class="worklog-stats">
|
||||||
|
<div class="worklog-stat">
|
||||||
|
<h4>{{ worklog_stats.draft_count or 0 }}</h4>
|
||||||
|
<p>Draft Worklog</p>
|
||||||
|
</div>
|
||||||
|
<div class="worklog-stat">
|
||||||
|
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4>
|
||||||
|
<p>Udraft Timer</p>
|
||||||
|
</div>
|
||||||
|
<div class="worklog-stat">
|
||||||
|
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
|
||||||
|
<p>Billable Entries</p>
|
||||||
|
</div>
|
||||||
|
<div class="worklog-stat">
|
||||||
|
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
|
||||||
|
<p>Billable Timer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Tickets -->
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>
|
||||||
|
<i class="bi bi-clock-history"></i> Seneste Tickets
|
||||||
|
</h2>
|
||||||
|
<a href="/ticket/tickets" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-list-ul"></i> Se Alle
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if recent_tickets %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table ticket-list mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticket</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ticket in recent_tickets %}
|
||||||
|
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
|
||||||
|
<td>
|
||||||
|
<span class="ticket-number">{{ ticket.ticket_number }}</span>
|
||||||
|
<br>
|
||||||
|
<strong>{{ ticket.subject }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if ticket.customer_name %}
|
||||||
|
{{ ticket.customer_name }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-status-{{ ticket.status }}">
|
||||||
|
{{ ticket.status.replace('_', ' ').title() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-priority-{{ ticket.priority }}">
|
||||||
|
{{ ticket.priority.title() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-inbox"></i>
|
||||||
|
<h3>Ingen tickets endnu</h3>
|
||||||
|
<p>Opret din første ticket for at komme i gang</p>
|
||||||
|
<a href="/ticket/tickets/new" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Opret Ticket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Filter tickets by status
|
||||||
|
function filterTickets(status) {
|
||||||
|
window.location.href = `/ticket/tickets?status=${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh every 5 minutes
|
||||||
|
setTimeout(() => {
|
||||||
|
location.reload();
|
||||||
|
}, 300000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -190,7 +190,7 @@
|
|||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="action-buttons mb-4">
|
<div class="action-buttons mb-4">
|
||||||
<a href="/api/v1/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
|
<a href="/api/v1/ticket/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
|
||||||
<i class="bi bi-pencil"></i> Rediger
|
<i class="bi bi-pencil"></i> Rediger
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-outline-secondary" onclick="addComment()">
|
<button class="btn btn-outline-secondary" onclick="addComment()">
|
||||||
@ -398,12 +398,12 @@
|
|||||||
<script>
|
<script>
|
||||||
// Add comment (placeholder - integrate with API)
|
// Add comment (placeholder - integrate with API)
|
||||||
function addComment() {
|
function addComment() {
|
||||||
alert('Add comment functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/comments');
|
alert('Add comment functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/comments');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add worklog (placeholder - integrate with API)
|
// Add worklog (placeholder - integrate with API)
|
||||||
function addWorklog() {
|
function addWorklog() {
|
||||||
alert('Add worklog functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/worklog');
|
alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted">Oversigt over alle tickets i systemet</p>
|
<p class="text-muted">Oversigt over alle tickets i systemet</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/api/v1/tickets" class="btn btn-primary">
|
<a href="/api/v1/ticket/tickets" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> Ny Ticket
|
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1273
app/ticket/frontend/ticket_new.html
Normal file
1273
app/ticket/frontend/ticket_new.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -148,7 +148,7 @@ async def approve_worklog_entry(
|
|||||||
FROM tticket_worklog
|
FROM tticket_worklog
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
entry = execute_query(check_query, (worklog_id,), fetchone=True)
|
entry = execute_query_single(check_query, (worklog_id,))
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Worklog entry not found")
|
raise HTTPException(status_code=404, detail="Worklog entry not found")
|
||||||
@ -199,7 +199,7 @@ async def reject_worklog_entry(
|
|||||||
FROM tticket_worklog
|
FROM tticket_worklog
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
entry = execute_query(check_query, (worklog_id,), fetchone=True)
|
entry = execute_query_single(check_query, (worklog_id,))
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Worklog entry not found")
|
raise HTTPException(status_code=404, detail="Worklog entry not found")
|
||||||
@ -235,6 +235,14 @@ async def reject_worklog_entry(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/new", response_class=HTMLResponse)
|
||||||
|
async def new_ticket_page(request: Request):
|
||||||
|
"""
|
||||||
|
New ticket creation page with multi-step wizard
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse)
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
async def ticket_dashboard(request: Request):
|
async def ticket_dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
@ -252,7 +260,8 @@ async def ticket_dashboard(request: Request):
|
|||||||
COUNT(*) AS total_count
|
COUNT(*) AS total_count
|
||||||
FROM tticket_tickets
|
FROM tticket_tickets
|
||||||
"""
|
"""
|
||||||
stats = execute_query(stats_query, fetchone=True)
|
stats_result = execute_query(stats_query)
|
||||||
|
stats = stats_result[0] if stats_result else {}
|
||||||
|
|
||||||
# Get recent tickets
|
# Get recent tickets
|
||||||
recent_query = """
|
recent_query = """
|
||||||
@ -280,20 +289,21 @@ async def ticket_dashboard(request: Request):
|
|||||||
COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
|
COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
|
||||||
FROM tticket_worklog
|
FROM tticket_worklog
|
||||||
"""
|
"""
|
||||||
worklog_stats = execute_query(worklog_stats_query, fetchone=True)
|
worklog_stats_result = execute_query(worklog_stats_query)
|
||||||
|
worklog_stats = worklog_stats_result[0] if worklog_stats_result else {}
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"ticket/frontend/dashboard.html",
|
"ticket/frontend/dashboard.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"recent_tickets": recent_tickets,
|
"recent_tickets": recent_tickets or [],
|
||||||
"worklog_stats": worklog_stats
|
"worklog_stats": worklog_stats
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to load dashboard: {e}")
|
logger.error(f"❌ Failed to load dashboard: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@ -396,7 +406,7 @@ async def ticket_detail_page(request: Request, ticket_id: int):
|
|||||||
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
|
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
ticket = execute_query(ticket_query, (ticket_id,), fetchone=True)
|
ticket = execute_query_single(ticket_query, (ticket_id,))
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
|
|||||||
@ -272,7 +272,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/api/v1/tickets">
|
<a class="nav-link" href="/api/v1/ticket/tickets">
|
||||||
<i class="bi bi-ticket-detailed"></i> Tickets
|
<i class="bi bi-ticket-detailed"></i> Tickets
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ from app.core.database import execute_query, execute_update
|
|||||||
from app.timetracking.backend.models import (
|
from app.timetracking.backend.models import (
|
||||||
TModuleEconomicExportRequest,
|
TModuleEconomicExportRequest,
|
||||||
TModuleEconomicExportResult
|
TModuleEconomicExportResult
|
||||||
)
|
, execute_query_single)
|
||||||
from app.timetracking.backend.audit import audit
|
from app.timetracking.backend.audit import audit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -162,7 +162,7 @@ class EconomicExportService:
|
|||||||
JOIN tmodule_customers c ON o.customer_id = c.id
|
JOIN tmodule_customers c ON o.customer_id = c.id
|
||||||
WHERE o.id = %s
|
WHERE o.id = %s
|
||||||
"""
|
"""
|
||||||
order = execute_query(order_query, (request.order_id,), fetchone=True)
|
order = execute_query_single(order_query, (request.order_id,))
|
||||||
|
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
@ -187,7 +187,7 @@ class EconomicExportService:
|
|||||||
WHERE order_id = %s
|
WHERE order_id = %s
|
||||||
ORDER BY line_number
|
ORDER BY line_number
|
||||||
"""
|
"""
|
||||||
lines = execute_query(lines_query, (request.order_id,))
|
lines = execute_query_single(lines_query, (request.order_id,))
|
||||||
|
|
||||||
if not lines:
|
if not lines:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -244,7 +244,7 @@ class EconomicExportService:
|
|||||||
LEFT JOIN customers c ON tc.hub_customer_id = c.id
|
LEFT JOIN customers c ON tc.hub_customer_id = c.id
|
||||||
WHERE tc.id = %s
|
WHERE tc.id = %s
|
||||||
"""
|
"""
|
||||||
customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True)
|
customer_data = execute_query(customer_number_query, (order['customer_id'],))
|
||||||
|
|
||||||
if not customer_data or not customer_data.get('economic_customer_number'):
|
if not customer_data or not customer_data.get('economic_customer_number'):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@ -20,7 +20,7 @@ from app.timetracking.backend.models import (
|
|||||||
TModuleOrderLine,
|
TModuleOrderLine,
|
||||||
TModuleOrderCreate,
|
TModuleOrderCreate,
|
||||||
TModuleOrderLineCreate
|
TModuleOrderLineCreate
|
||||||
)
|
, execute_query_single)
|
||||||
from app.timetracking.backend.audit import audit
|
from app.timetracking.backend.audit import audit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -42,7 +42,7 @@ class OrderService:
|
|||||||
try:
|
try:
|
||||||
# Check module customer
|
# Check module customer
|
||||||
query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s"
|
query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s"
|
||||||
result = execute_query(query, (customer_id,), fetchone=True)
|
result = execute_query_single(query, (customer_id,))
|
||||||
|
|
||||||
if result and result.get('hourly_rate'):
|
if result and result.get('hourly_rate'):
|
||||||
rate = result['hourly_rate']
|
rate = result['hourly_rate']
|
||||||
@ -52,7 +52,7 @@ class OrderService:
|
|||||||
# Check Hub customer if linked
|
# Check Hub customer if linked
|
||||||
if hub_customer_id:
|
if hub_customer_id:
|
||||||
query = "SELECT hourly_rate FROM customers WHERE id = %s"
|
query = "SELECT hourly_rate FROM customers WHERE id = %s"
|
||||||
result = execute_query(query, (hub_customer_id,), fetchone=True)
|
result = execute_query_single(query, (hub_customer_id,))
|
||||||
|
|
||||||
if result and result.get('hourly_rate'):
|
if result and result.get('hourly_rate'):
|
||||||
rate = result['hourly_rate']
|
rate = result['hourly_rate']
|
||||||
@ -86,11 +86,9 @@ class OrderService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Hent customer info
|
# Hent customer info
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT * FROM tmodule_customers WHERE id = %s",
|
"SELECT * FROM tmodule_customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
@ -110,7 +108,7 @@ class OrderService:
|
|||||||
AND t.billable = true
|
AND t.billable = true
|
||||||
ORDER BY c.id, t.worked_date
|
ORDER BY c.id, t.worked_date
|
||||||
"""
|
"""
|
||||||
approved_times = execute_query(query, (customer_id,))
|
approved_times = execute_query_single(query, (customer_id,))
|
||||||
|
|
||||||
if not approved_times:
|
if not approved_times:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -316,7 +314,7 @@ class OrderService:
|
|||||||
LEFT JOIN tmodule_customers c ON o.customer_id = c.id
|
LEFT JOIN tmodule_customers c ON o.customer_id = c.id
|
||||||
WHERE o.id = %s
|
WHERE o.id = %s
|
||||||
"""
|
"""
|
||||||
order = execute_query(order_query, (order_id,), fetchone=True)
|
order = execute_query(order_query, (order_id,))
|
||||||
|
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
@ -336,7 +334,7 @@ class OrderService:
|
|||||||
ol.product_number, ol.account_number, ol.created_at
|
ol.product_number, ol.account_number, ol.created_at
|
||||||
ORDER BY ol.line_number
|
ORDER BY ol.line_number
|
||||||
"""
|
"""
|
||||||
lines = execute_query(lines_query, (order_id,))
|
lines = execute_query_single(lines_query, (order_id,))
|
||||||
|
|
||||||
return TModuleOrderWithLines(
|
return TModuleOrderWithLines(
|
||||||
**order,
|
**order,
|
||||||
@ -401,9 +399,7 @@ class OrderService:
|
|||||||
# Check order exists and is not exported
|
# Check order exists and is not exported
|
||||||
order = execute_query(
|
order = execute_query(
|
||||||
"SELECT * FROM tmodule_orders WHERE id = %s",
|
"SELECT * FROM tmodule_orders WHERE id = %s",
|
||||||
(order_id,),
|
(order_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
@ -424,7 +420,7 @@ class OrderService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Reset time entries back to approved
|
# Reset time entries back to approved
|
||||||
lines = execute_query(
|
lines = execute_query_single(
|
||||||
"SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s",
|
"SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s",
|
||||||
(order_id,)
|
(order_id,)
|
||||||
)
|
)
|
||||||
@ -453,9 +449,7 @@ class OrderService:
|
|||||||
# Return updated order
|
# Return updated order
|
||||||
updated = execute_query(
|
updated = execute_query(
|
||||||
"SELECT * FROM tmodule_orders WHERE id = %s",
|
"SELECT * FROM tmodule_orders WHERE id = %s",
|
||||||
(order_id,),
|
(order_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return TModuleOrder(**updated)
|
return TModuleOrder(**updated)
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ from app.timetracking.backend.models import (
|
|||||||
TModuleMetadata,
|
TModuleMetadata,
|
||||||
TModuleUninstallRequest,
|
TModuleUninstallRequest,
|
||||||
TModuleUninstallResult
|
TModuleUninstallResult
|
||||||
)
|
, execute_query_single)
|
||||||
from app.timetracking.backend.vtiger_sync import vtiger_service
|
from app.timetracking.backend.vtiger_sync import vtiger_service
|
||||||
from app.timetracking.backend.wizard import wizard
|
from app.timetracking.backend.wizard import wizard
|
||||||
from app.timetracking.backend.order_service import order_service
|
from app.timetracking.backend.order_service import order_service
|
||||||
@ -80,11 +80,9 @@ async def sync_case_comments(case_id: int):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Hent case fra database
|
# Hent case fra database
|
||||||
case = execute_query(
|
case = execute_query_single(
|
||||||
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
|
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
|
||||||
(case_id,),
|
(case_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not case:
|
if not case:
|
||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
@ -185,7 +183,7 @@ async def approve_time_entry(
|
|||||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
entry = execute_query(query, (time_id,), fetchone=True)
|
entry = execute_query_single(query, (time_id,))
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||||
@ -470,7 +468,7 @@ async def unlock_order(
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = execute_query(update_query, (order_id,), fetchone=True)
|
result = execute_query_single(update_query, (order_id,))
|
||||||
|
|
||||||
# Log unlock
|
# Log unlock
|
||||||
audit.log_event(
|
audit.log_event(
|
||||||
@ -551,10 +549,8 @@ async def test_economic_connection():
|
|||||||
async def get_module_metadata():
|
async def get_module_metadata():
|
||||||
"""Hent modul metadata"""
|
"""Hent modul metadata"""
|
||||||
try:
|
try:
|
||||||
result = execute_query(
|
result = execute_query_single(
|
||||||
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1",
|
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1")
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Module metadata not found")
|
raise HTTPException(status_code=404, detail="Module metadata not found")
|
||||||
@ -575,7 +571,7 @@ async def module_health():
|
|||||||
SELECT COUNT(*) as count FROM information_schema.tables
|
SELECT COUNT(*) as count FROM information_schema.tables
|
||||||
WHERE table_name LIKE 'tmodule_%'
|
WHERE table_name LIKE 'tmodule_%'
|
||||||
"""
|
"""
|
||||||
result = execute_query(tables_query, fetchone=True)
|
result = execute_query_single(tables_query)
|
||||||
table_count = result['count'] if result else 0
|
table_count = result['count'] if result else 0
|
||||||
|
|
||||||
# Get stats - count each table separately
|
# Get stats - count each table separately
|
||||||
@ -588,10 +584,8 @@ async def module_health():
|
|||||||
}
|
}
|
||||||
|
|
||||||
for table_name in ["customers", "cases", "times", "orders"]:
|
for table_name in ["customers", "cases", "times", "orders"]:
|
||||||
count_result = execute_query(
|
count_result = execute_query_single(
|
||||||
f"SELECT COUNT(*) as count FROM tmodule_{table_name}",
|
f"SELECT COUNT(*) as count FROM tmodule_{table_name}")
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
stats[table_name] = count_result['count'] if count_result else 0
|
stats[table_name] = count_result['count'] if count_result else 0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -673,11 +667,9 @@ async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Return updated customer
|
# Return updated customer
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
@ -720,11 +712,9 @@ async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Op
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Return updated customer
|
# Return updated customer
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT * FROM tmodule_customers WHERE id = %s",
|
"SELECT * FROM tmodule_customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
@ -770,7 +760,7 @@ async def list_customers(
|
|||||||
|
|
||||||
query += " ORDER BY customer_name"
|
query += " ORDER BY customer_name"
|
||||||
|
|
||||||
customers = execute_query(query)
|
customers = execute_query_single(query)
|
||||||
else:
|
else:
|
||||||
# Simple customer list
|
# Simple customer list
|
||||||
query = "SELECT * FROM tmodule_customers"
|
query = "SELECT * FROM tmodule_customers"
|
||||||
@ -893,7 +883,7 @@ async def uninstall_module(
|
|||||||
(SELECT COUNT(*) FROM tmodule_order_lines) +
|
(SELECT COUNT(*) FROM tmodule_order_lines) +
|
||||||
(SELECT COUNT(*) FROM tmodule_sync_log) as total
|
(SELECT COUNT(*) FROM tmodule_sync_log) as total
|
||||||
"""
|
"""
|
||||||
count_result = execute_query(count_query, fetchone=True)
|
count_result = execute_query(count_query)
|
||||||
total_rows = count_result['total'] if count_result else 0
|
total_rows = count_result['total'] if count_result else 0
|
||||||
except:
|
except:
|
||||||
total_rows = 0
|
total_rows = 0
|
||||||
@ -902,7 +892,7 @@ async def uninstall_module(
|
|||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection(, execute_query_single)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
dropped_items = {
|
dropped_items = {
|
||||||
|
|||||||
@ -302,11 +302,9 @@ class TimeTrackingVTigerService:
|
|||||||
data_hash = self._calculate_hash(account)
|
data_hash = self._calculate_hash(account)
|
||||||
|
|
||||||
# Check if exists
|
# Check if exists
|
||||||
existing = execute_query(
|
existing = execute_query_single(
|
||||||
"SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s",
|
"SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s",
|
||||||
(vtiger_id,),
|
(vtiger_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Check if data changed
|
# Check if data changed
|
||||||
@ -424,11 +422,9 @@ class TimeTrackingVTigerService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Find customer in our DB
|
# Find customer in our DB
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
|
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
|
||||||
(account_id,),
|
(account_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
logger.warning(f"⚠️ Customer {account_id} not found - sync customers first")
|
logger.warning(f"⚠️ Customer {account_id} not found - sync customers first")
|
||||||
@ -453,11 +449,9 @@ class TimeTrackingVTigerService:
|
|||||||
data_hash = self._calculate_hash(ticket_with_comments)
|
data_hash = self._calculate_hash(ticket_with_comments)
|
||||||
|
|
||||||
# Check if exists
|
# Check if exists
|
||||||
existing = execute_query(
|
existing = execute_query_single(
|
||||||
"SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s",
|
"SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s",
|
||||||
(vtiger_id,),
|
(vtiger_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
if existing['sync_hash'] == data_hash:
|
if existing['sync_hash'] == data_hash:
|
||||||
@ -685,22 +679,18 @@ class TimeTrackingVTigerService:
|
|||||||
|
|
||||||
if related_to:
|
if related_to:
|
||||||
# Try to find case first, then account
|
# Try to find case first, then account
|
||||||
case = execute_query(
|
case = execute_query_single(
|
||||||
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
|
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
|
||||||
(related_to,),
|
(related_to,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if case:
|
if case:
|
||||||
case_id = case['id']
|
case_id = case['id']
|
||||||
customer_id = case['customer_id']
|
customer_id = case['customer_id']
|
||||||
else:
|
else:
|
||||||
# Try to find customer directly
|
# Try to find customer directly
|
||||||
customer = execute_query(
|
customer = execute_query_single(
|
||||||
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
|
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
|
||||||
(related_to,),
|
(related_to,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if customer:
|
if customer:
|
||||||
customer_id = customer['id']
|
customer_id = customer['id']
|
||||||
@ -725,11 +715,9 @@ class TimeTrackingVTigerService:
|
|||||||
data_hash = self._calculate_hash(timelog)
|
data_hash = self._calculate_hash(timelog)
|
||||||
|
|
||||||
# Check if exists
|
# Check if exists
|
||||||
existing = execute_query(
|
existing = execute_query_single(
|
||||||
"SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s",
|
"SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s",
|
||||||
(vtiger_id,),
|
(vtiger_id,))
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
if existing['sync_hash'] == data_hash:
|
if existing['sync_hash'] == data_hash:
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from app.timetracking.backend.models import (
|
|||||||
TModuleWizardProgress,
|
TModuleWizardProgress,
|
||||||
TModuleWizardNextEntry,
|
TModuleWizardNextEntry,
|
||||||
TModuleApprovalStats
|
TModuleApprovalStats
|
||||||
)
|
, execute_query_single)
|
||||||
from app.timetracking.backend.audit import audit
|
from app.timetracking.backend.audit import audit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -36,7 +36,7 @@ class WizardService:
|
|||||||
SELECT * FROM tmodule_approval_stats
|
SELECT * FROM tmodule_approval_stats
|
||||||
WHERE customer_id = %s
|
WHERE customer_id = %s
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (customer_id,), fetchone=True)
|
result = execute_query_single(query, (customer_id,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return None
|
||||||
@ -52,7 +52,7 @@ class WizardService:
|
|||||||
"""Hent approval statistics for alle kunder"""
|
"""Hent approval statistics for alle kunder"""
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
|
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
|
||||||
results = execute_query(query)
|
results = execute_query_single(query)
|
||||||
|
|
||||||
return [TModuleApprovalStats(**row) for row in results]
|
return [TModuleApprovalStats(**row) for row in results]
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ class WizardService:
|
|||||||
WHERE customer_id = %s
|
WHERE customer_id = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (customer_id,), fetchone=True)
|
result = execute_query(query, (customer_id,))
|
||||||
else:
|
else:
|
||||||
# Hent næste generelt
|
# Hent næste generelt
|
||||||
if exclude_time_card:
|
if exclude_time_card:
|
||||||
@ -96,7 +96,7 @@ class WizardService:
|
|||||||
else:
|
else:
|
||||||
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
|
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
|
||||||
|
|
||||||
result = execute_query(query, fetchone=True)
|
result = execute_query_single(query)
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
# Ingen flere entries
|
# Ingen flere entries
|
||||||
@ -161,7 +161,7 @@ class WizardService:
|
|||||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
entry = execute_query(query, (approval.time_id,), fetchone=True)
|
entry = execute_query_single(query, (approval.time_id,))
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||||
@ -215,7 +215,7 @@ class WizardService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Return updated entry
|
# Return updated entry
|
||||||
updated = execute_query(query, (approval.time_id,), fetchone=True)
|
updated = execute_query_single(query, (approval.time_id,))
|
||||||
return TModuleTimeWithContext(**updated)
|
return TModuleTimeWithContext(**updated)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@ -251,7 +251,7 @@ class WizardService:
|
|||||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
entry = execute_query(query, (time_id,), fetchone=True)
|
entry = execute_query_single(query, (time_id,))
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||||
@ -285,7 +285,7 @@ class WizardService:
|
|||||||
logger.info(f"❌ Rejected time entry {time_id}: {reason}")
|
logger.info(f"❌ Rejected time entry {time_id}: {reason}")
|
||||||
|
|
||||||
# Return updated
|
# Return updated
|
||||||
updated = execute_query(query, (time_id,), fetchone=True)
|
updated = execute_query_single(query, (time_id,))
|
||||||
return TModuleTimeWithContext(**updated)
|
return TModuleTimeWithContext(**updated)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@ -321,7 +321,7 @@ class WizardService:
|
|||||||
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
JOIN tmodule_customers cust ON t.customer_id = cust.id
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
entry = execute_query(query, (time_id,), fetchone=True)
|
entry = execute_query_single(query, (time_id,))
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||||
@ -368,7 +368,7 @@ class WizardService:
|
|||||||
logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}")
|
logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}")
|
||||||
|
|
||||||
# Return updated
|
# Return updated
|
||||||
updated = execute_query(query, (time_id,), fetchone=True)
|
updated = execute_query_single(query, (time_id,))
|
||||||
return TModuleTimeWithContext(**updated)
|
return TModuleTimeWithContext(**updated)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@ -491,7 +491,7 @@ class WizardService:
|
|||||||
ORDER BY t.worked_date
|
ORDER BY t.worked_date
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
case = execute_query(query, (customer_id,), fetchone=True)
|
case = execute_query_single(query, (customer_id,))
|
||||||
if case:
|
if case:
|
||||||
current_case_id = case['id']
|
current_case_id = case['id']
|
||||||
current_case_title = case['title']
|
current_case_title = case['title']
|
||||||
@ -585,7 +585,7 @@ class WizardService:
|
|||||||
ORDER BY t.worked_date, t.id
|
ORDER BY t.worked_date, t.id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
results = execute_query(query, (case_id,))
|
results = execute_query_single(query, (case_id,))
|
||||||
return [TModuleTimeWithContext(**row) for row in results]
|
return [TModuleTimeWithContext(**row) for row in results]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -608,7 +608,7 @@ class WizardService:
|
|||||||
FROM tmodule_cases
|
FROM tmodule_cases
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
case = execute_query(case_query, (case_id,), fetchone=True)
|
case = execute_query(case_query, (case_id,))
|
||||||
|
|
||||||
if not case:
|
if not case:
|
||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|||||||
11
bmc_hub_dev.code-workspace
Normal file
11
bmc_hub_dev.code-workspace
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../pakkemodtagelse"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@ -42,17 +42,12 @@ services:
|
|||||||
# Mount for local development - live code reload
|
# Mount for local development - live code reload
|
||||||
- ./app:/app/app:ro
|
- ./app:/app/app:ro
|
||||||
- ./main.py:/app/main.py:ro
|
- ./main.py:/app/main.py:ro
|
||||||
- ./scripts:/app/scripts:ro
|
|
||||||
# Mount OmniSync database for import (read-only)
|
|
||||||
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
# Override database URL to point to postgres service
|
# Override database URL to point to postgres service
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
- ENABLE_RELOAD=false
|
- ENABLE_RELOAD=false
|
||||||
- OLLAMA_MODEL=qwen3:4b # Bruger Chat API format
|
|
||||||
- OLLAMA_MODEL_FALLBACK=qwen2.5:3b # Backup model
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
|||||||
128
docs/SIMPLY_CRM_SETUP.md
Normal file
128
docs/SIMPLY_CRM_SETUP.md
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# Simply-CRM Integration Setup
|
||||||
|
|
||||||
|
## Status
|
||||||
|
⚠️ **Simply-CRM credentials ikke konfigureret** - Salgsordre fra det gamle system vises ikke
|
||||||
|
|
||||||
|
## Hvad er Simply-CRM?
|
||||||
|
|
||||||
|
Simply-CRM er et **separat CRM system** (VTiger fork) der bruges til at hente **historiske salgsordre** med `recurring_frequency`.
|
||||||
|
|
||||||
|
⚠️ **Vigtigt:** Simply-CRM er IKKE det samme som:
|
||||||
|
- vTiger Cloud (https://bmcnetworks.od2.vtiger.com)
|
||||||
|
- Det gamle on-premise vTiger (http://crm.bmcnetworks.dk)
|
||||||
|
|
||||||
|
Simply-CRM har sin egen URL, credentials og API endpoint (`/webservice.php`).
|
||||||
|
|
||||||
|
## Hvorfor vises ingen Simply-CRM data?
|
||||||
|
|
||||||
|
3 grunde:
|
||||||
|
1. ⚠️ **OLD_VTIGER_URL, OLD_VTIGER_USERNAME, OLD_VTIGER_API_KEY er tomme** i `.env` filen
|
||||||
|
2. Koden leder efter `OLD_VTIGER_API_KEY` men kan ikke finde credentials
|
||||||
|
3. Serveren er tilgængelig (301 response), men authentication mangler
|
||||||
|
|
||||||
|
## Sådan finder du credentials
|
||||||
|
|
||||||
|
### Option 1: Hvis I stadig bruger det gamle system
|
||||||
|
|
||||||
|
1. **Log ind på Simply-CRM:**
|
||||||
|
- URL: http://crm.bmcnetworks.dk
|
||||||
|
- Brug din normale bruger
|
||||||
|
|
||||||
|
2. **Find Access Key:**
|
||||||
|
- Gå til **Settings** (tandhjul øverst til højre)
|
||||||
|
- Klik på **My Preferences**
|
||||||
|
- Under **Webservices** vil du se din **Access Key**
|
||||||
|
- Kopier access key'en
|
||||||
|
|
||||||
|
3. **Tilføj til .env:**
|
||||||
|
```bash
|
||||||
|
# Simply-CRM (separat system)
|
||||||
|
SIMPLYCRM_URL=http://your-simplycrm-server.com
|
||||||
|
SIMPLYCRM_USERNAME=din_email@domain.dk
|
||||||
|
SIMPLYCRM_API_KEY=din_access_key_herfra
|
||||||
|
|
||||||
|
# ELLER hvis det er samme som gamle vTiger (fallback):
|
||||||
|
OLD_VTIGER_URL=http://crm.bmcnetworks.dk
|
||||||
|
OLD_VTIGER_USERNAME=din_email@bmcnetworks.dk
|
||||||
|
OLD_VTIGER_API_KEY=din_access_key_herfra
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Genstart API:**
|
||||||
|
```bash
|
||||||
|
docker restart bmc-hub-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Hvis I ikke længere bruger det gamle system
|
||||||
|
|
||||||
|
Hvis alle kunder er migreret til vTiger Cloud og Simply-CRM ikke længere bruges:
|
||||||
|
|
||||||
|
1. **Kommenter linjerne ud i .env:**
|
||||||
|
```bash
|
||||||
|
# OLD_VTIGER_URL=
|
||||||
|
# OLD_VTIGER_USERNAME=
|
||||||
|
# OLD_VTIGER_API_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Simply-CRM vil automatisk blive sprunget over og der vises kun:
|
||||||
|
- vTiger Cloud subscriptions ✅
|
||||||
|
- BMC Office subscriptions ✅
|
||||||
|
|
||||||
|
## Test After Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test med en kunde
|
||||||
|
curl http://localhost:8001/api/v1/customers/327/subscriptions | jq '.sales_orders | length'
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker logs bmc-hub-api --tail=30 | grep -i simply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hvad henter Simply-CRM?
|
||||||
|
|
||||||
|
Koden henter **kun salgsordre med `recurring_frequency`** - altså abonnementer:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM SalesOrder
|
||||||
|
WHERE account_id='<kunde_id>'
|
||||||
|
AND recurring_frequency IS NOT NULL
|
||||||
|
AND sostatus NOT IN ('closed', 'cancelled')
|
||||||
|
```
|
||||||
|
|
||||||
|
For hver ordre:
|
||||||
|
- Henter line items (produkter)
|
||||||
|
- Grupperer efter ordre ID
|
||||||
|
- Viser i "Salgsordre" sektionen på kunde-siden
|
||||||
|
|
||||||
|
## Hvorfor er det vigtigt?
|
||||||
|
|
||||||
|
Uden Simply-CRM credentials kan I ikke se:
|
||||||
|
- Gamle abonnementer oprettet før cloud migrationen
|
||||||
|
- Historiske recurring orders
|
||||||
|
- Kunder der stadig har aktive ordrer i det gamle system
|
||||||
|
|
||||||
|
**Men** I kan stadig se:
|
||||||
|
- ✅ vTiger Cloud subscriptions
|
||||||
|
- ✅ BMC Office subscriptions
|
||||||
|
- ✅ Nye vTiger Cloud sales orders
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ vTiger Cloud - Virker (2 subscriptions for Maskinsikkerhed)
|
||||||
|
✅ BMC Office - Virker (16 subscriptions for Maskinsikkerhed)
|
||||||
|
⚠️ Simply-CRM - Mangler credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Simply-CRM credentials not configured"
|
||||||
|
→ Tilføj OLD_VTIGER_* settings til `.env` og genstart
|
||||||
|
|
||||||
|
### "Not logged in to Simply-CRM"
|
||||||
|
→ Access key er forkert eller expired
|
||||||
|
|
||||||
|
### "No Simply-CRM account found for 'Kunde Navn'"
|
||||||
|
→ Kundens navn matcher ikke præcist mellem systemer (vTiger Cloud vs Simply-CRM)
|
||||||
|
|
||||||
|
### Server timeout
|
||||||
|
→ Check at `http://crm.bmcnetworks.dk` er tilgængelig fra Docker containeren
|
||||||
1
docs/TICKET_SYSTEM_ENHANCEMENTS.md
Normal file
1
docs/TICKET_SYSTEM_ENHANCEMENTS.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
w
|
||||||
107
docs/VTIGER_SETUP.md
Normal file
107
docs/VTIGER_SETUP.md
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# vTiger & Simply-CRM Integration Setup
|
||||||
|
|
||||||
|
## Status
|
||||||
|
✅ **BMC Office Abonnementer** - Virker nu! (fix applied: changed `execute_query_single` to `execute_query`)
|
||||||
|
⚠️ **vTiger Cloud Abonnementer** - Kræver credentials
|
||||||
|
⚠️ **Simply-CRM Salgsordre** - Kræver credentials
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Abonnementer & Salgsordre fanen viste ingen data fra vTiger og Simply-CRM fordi:
|
||||||
|
|
||||||
|
1. **BMC Office query brugte `execute_query_single()`** - returnerede kun 1 række i stedet for alle
|
||||||
|
2. **vTiger Cloud credentials mangler** - VTIGER_URL, VTIGER_USERNAME, VTIGER_ACCESS_KEY
|
||||||
|
3. **Simply-CRM credentials mangler** - OLD_VTIGER_URL, OLD_VTIGER_USERNAME, OLD_VTIGER_ACCESS_KEY
|
||||||
|
|
||||||
|
## Løsning
|
||||||
|
|
||||||
|
### 1. BMC Office Subscriptions (✅ Fixed)
|
||||||
|
Changed from `execute_query_single()` to `execute_query()` in `app/customers/backend/router.py` line 554.
|
||||||
|
|
||||||
|
Nu vises alle BMC Office abonnementer korrekt:
|
||||||
|
- Kunde 327 (Maskinsikkerhed): 16 abonnementer
|
||||||
|
- Kunde 372 (Norva24 Danmark A/S): 12 abonnementer
|
||||||
|
|
||||||
|
### 2. vTiger Cloud Integration (⚠️ Requires Credentials)
|
||||||
|
|
||||||
|
Tilføj følgende til `.env` filen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# vTiger Cloud Integration
|
||||||
|
VTIGER_URL=https://bmcnetworks.od2.vtiger.com
|
||||||
|
VTIGER_USERNAME=din_vtiger_bruger
|
||||||
|
VTIGER_ACCESS_KEY=din_vtiger_access_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sådan finder du credentials:**
|
||||||
|
1. Log ind på vTiger Cloud (https://bmcnetworks.od2.vtiger.com)
|
||||||
|
2. Gå til **Settings** (tandhjul øverst til højre)
|
||||||
|
3. Vælg **Integration** → **Webservices**
|
||||||
|
4. Kopier **Access Key** for din bruger
|
||||||
|
5. Username er din vTiger login email
|
||||||
|
|
||||||
|
### 3. Simply-CRM / Old vTiger Integration (⚠️ Requires Credentials)
|
||||||
|
|
||||||
|
Hvis I stadig bruger den gamle on-premise vTiger installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simply-CRM (Old vTiger On-Premise)
|
||||||
|
OLD_VTIGER_URL=http://crm.bmcnetworks.dk
|
||||||
|
OLD_VTIGER_USERNAME=din_gamle_vtiger_bruger
|
||||||
|
OLD_VTIGER_ACCESS_KEY=din_gamle_access_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Simply-CRM bruges til at hente salgsordre med `recurring_frequency` fra det gamle system.
|
||||||
|
|
||||||
|
## Test Efter Setup
|
||||||
|
|
||||||
|
1. Genstart API containeren:
|
||||||
|
```bash
|
||||||
|
docker restart bmc-hub-api
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Test en kunde med vTiger ID:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/api/v1/customers/39/subscriptions | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check logs for fejl:
|
||||||
|
```bash
|
||||||
|
docker logs bmc-hub-api --tail=50 | grep -i "vtiger\|simply"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forventet Output
|
||||||
|
|
||||||
|
Med credentials konfigureret skulle du se:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"recurring_orders": [...], // vTiger recurring sales orders
|
||||||
|
"sales_orders": [...], // Simply-CRM orders med recurring_frequency
|
||||||
|
"subscriptions": [...], // vTiger Subscriptions module
|
||||||
|
"expired_subscriptions": [...], // Expired/cancelled subscriptions
|
||||||
|
"bmc_office_subscriptions": [...] // Local BMC Office subscriptions (✅ works now)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Display
|
||||||
|
|
||||||
|
Abonnementer & Salgsordre fanen viser nu 3 sektioner:
|
||||||
|
|
||||||
|
1. **vTiger Abonnementer** - Subscriptions module data med lock/unlock funktion
|
||||||
|
2. **BMC Office Abonnementer** - Lokale abonnementer (✅ virker)
|
||||||
|
3. **Samlet overblik** - Stats kortene øverst
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "VTIGER_URL not configured"
|
||||||
|
→ Tilføj credentials til `.env` og genstart containeren
|
||||||
|
|
||||||
|
### "No Simply-CRM account found"
|
||||||
|
→ Kunden findes ikke i det gamle system, eller navnet matcher ikke præcist
|
||||||
|
|
||||||
|
### "Not logged in to Simply-CRM"
|
||||||
|
→ OLD_VTIGER_ACCESS_KEY er forkert eller mangler
|
||||||
|
|
||||||
|
### BMC Office subscriptions viser stadig ikke data
|
||||||
|
→ Tjek at containeren er restartet efter query fix
|
||||||
117
main.py
117
main.py
@ -7,43 +7,25 @@ import logging
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import RedirectResponse, FileResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import init_db
|
from app.core.database import init_db
|
||||||
from app.core.module_loader import module_loader
|
|
||||||
from app.services.email_scheduler import email_scheduler
|
|
||||||
|
|
||||||
# Import CORE Feature Routers (disse forbliver hardcoded)
|
# Import Feature Routers
|
||||||
from app.auth.backend import router as auth_api
|
|
||||||
from app.auth.backend import views as auth_views
|
|
||||||
from app.customers.backend import router as customers_api
|
from app.customers.backend import router as customers_api
|
||||||
from app.customers.backend import views as customers_views
|
from app.customers.backend import views as customers_views
|
||||||
from app.contacts.backend import router as contacts_api
|
|
||||||
from app.contacts.backend import views as contacts_views
|
|
||||||
from app.vendors.backend import router as vendors_api
|
|
||||||
from app.vendors.backend import views as vendors_views
|
|
||||||
from app.settings.backend import router as settings_api
|
|
||||||
from app.settings.backend import views as settings_views
|
|
||||||
from app.hardware.backend import router as hardware_api
|
from app.hardware.backend import router as hardware_api
|
||||||
from app.billing.backend import router as billing_api
|
from app.billing.backend import router as billing_api
|
||||||
from app.billing.frontend import views as billing_views
|
|
||||||
from app.system.backend import router as system_api
|
from app.system.backend import router as system_api
|
||||||
from app.dashboard.backend import views as dashboard_views
|
from app.dashboard.backend import views as dashboard_views
|
||||||
from app.dashboard.backend import router as dashboard_api
|
from app.prepaid.backend import router as prepaid_api
|
||||||
from app.devportal.backend import router as devportal_api
|
from app.prepaid.backend import views as prepaid_views
|
||||||
from app.devportal.backend import views as devportal_views
|
|
||||||
from app.timetracking.backend import router as timetracking_api
|
|
||||||
from app.timetracking.frontend import views as timetracking_views
|
|
||||||
from app.emails.backend import router as emails_api
|
|
||||||
from app.emails.frontend import views as emails_views
|
|
||||||
from app.backups.backend import router as backups_api
|
|
||||||
from app.backups.frontend import views as backups_views
|
|
||||||
from app.backups.backend.scheduler import backup_scheduler
|
|
||||||
from app.ticket.backend import router as ticket_api
|
from app.ticket.backend import router as ticket_api
|
||||||
from app.ticket.frontend import views as ticket_views
|
from app.ticket.frontend import views as ticket_views
|
||||||
|
from app.vendors.backend import router as vendors_api
|
||||||
|
from app.vendors.backend import views as vendors_views
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -66,25 +48,10 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# Start email scheduler (background job)
|
|
||||||
email_scheduler.start()
|
|
||||||
|
|
||||||
# Start backup scheduler (background job)
|
|
||||||
backup_scheduler.start()
|
|
||||||
|
|
||||||
# Load dynamic modules (hvis enabled)
|
|
||||||
if settings.MODULES_ENABLED:
|
|
||||||
logger.info("📦 Loading dynamic modules...")
|
|
||||||
module_loader.register_modules(app)
|
|
||||||
module_status = module_loader.get_module_status()
|
|
||||||
logger.info(f"✅ Loaded {len(module_status)} modules: {list(module_status.keys())}")
|
|
||||||
|
|
||||||
logger.info("✅ System initialized successfully")
|
logger.info("✅ System initialized successfully")
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("👋 Shutting down...")
|
logger.info("👋 Shutting down...")
|
||||||
email_scheduler.stop()
|
|
||||||
backup_scheduler.stop()
|
|
||||||
|
|
||||||
# Create FastAPI app
|
# Create FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@ -105,11 +72,6 @@ app = FastAPI(
|
|||||||
openapi_url="/api/openapi.json"
|
openapi_url="/api/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
"""Redirect root to dashboard"""
|
|
||||||
return RedirectResponse(url="/dashboard")
|
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -120,34 +82,20 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Authentication"])
|
|
||||||
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
||||||
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
|
||||||
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
|
||||||
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
|
||||||
app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
|
app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
|
||||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
||||||
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
||||||
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
|
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
||||||
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
|
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
||||||
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
|
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||||
app.include_router(backups_api.router, prefix="/api/v1", tags=["Backup System"])
|
|
||||||
app.include_router(emails_api.router, prefix="/api/v1", tags=["Email System"])
|
|
||||||
app.include_router(ticket_api.router, prefix="/api/v1", tags=["Ticket System"])
|
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(auth_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
app.include_router(dashboard_views.router, tags=["Frontend"])
|
||||||
app.include_router(customers_views.router, tags=["Frontend"])
|
app.include_router(customers_views.router, tags=["Frontend"])
|
||||||
app.include_router(contacts_views.router, tags=["Frontend"])
|
app.include_router(prepaid_views.router, tags=["Frontend"])
|
||||||
app.include_router(vendors_views.router, tags=["Frontend"])
|
app.include_router(vendors_views.router, tags=["Frontend"])
|
||||||
app.include_router(billing_views.router, tags=["Frontend"])
|
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"])
|
||||||
app.include_router(settings_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(devportal_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(backups_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(timetracking_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(emails_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend - Tickets"])
|
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
@ -161,49 +109,6 @@ async def health_check():
|
|||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/v1/modules")
|
|
||||||
async def list_modules():
|
|
||||||
"""List alle dynamic modules og deres status"""
|
|
||||||
return {
|
|
||||||
"modules_enabled": settings.MODULES_ENABLED,
|
|
||||||
"modules": module_loader.get_module_status()
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.post("/api/v1/modules/{module_name}/enable")
|
|
||||||
async def enable_module_endpoint(module_name: str):
|
|
||||||
"""Enable et modul (kræver restart)"""
|
|
||||||
success = module_loader.enable_module(module_name)
|
|
||||||
return {
|
|
||||||
"success": success,
|
|
||||||
"message": f"Modul {module_name} enabled. Restart app for at loade.",
|
|
||||||
"restart_required": True
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.post("/api/v1/modules/{module_name}/disable")
|
|
||||||
async def disable_module_endpoint(module_name: str):
|
|
||||||
"""Disable et modul (kræver restart)"""
|
|
||||||
success = module_loader.disable_module(module_name)
|
|
||||||
return {
|
|
||||||
"success": success,
|
|
||||||
"message": f"Modul {module_name} disabled. Restart app for at unload.",
|
|
||||||
"restart_required": True
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/docs/{doc_name}")
|
|
||||||
async def serve_documentation(doc_name: str):
|
|
||||||
"""Serve markdown documentation files"""
|
|
||||||
docs_dir = Path(__file__).parent / "docs"
|
|
||||||
doc_path = docs_dir / doc_name
|
|
||||||
|
|
||||||
# Security: Ensure path is within docs directory
|
|
||||||
if not doc_path.resolve().is_relative_to(docs_dir.resolve()):
|
|
||||||
return {"error": "Invalid path"}
|
|
||||||
|
|
||||||
if doc_path.exists() and doc_path.suffix == ".md":
|
|
||||||
return FileResponse(doc_path, media_type="text/markdown")
|
|
||||||
|
|
||||||
return {"error": "Documentation not found"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import os
|
import os
|
||||||
|
|||||||
446
migrations/026_ticket_enhancements.sql
Normal file
446
migrations/026_ticket_enhancements.sql
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 026: Ticket System Enhancements - Kravspecifikation Implementation
|
||||||
|
-- ============================================================================
|
||||||
|
-- Implementerer:
|
||||||
|
-- 1. Ticket relations (merge, split, parent/child hierarchy)
|
||||||
|
-- 2. Calendar events og deadlines
|
||||||
|
-- 3. Templates system
|
||||||
|
-- 4. AI suggestions (metadata only - ingen automatik)
|
||||||
|
-- 5. Enhanced contact identification
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TICKET RELATIONS (flette, splitte, hierarki)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_relations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
related_ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
relation_type VARCHAR(20) NOT NULL CHECK (relation_type IN ('merged_into', 'split_from', 'parent_of', 'child_of', 'related_to')),
|
||||||
|
|
||||||
|
-- Metadata om relationen
|
||||||
|
created_by_user_id INTEGER, -- Reference til users.user_id (read-only)
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason TEXT, -- Hvorfor blev relationen oprettet
|
||||||
|
|
||||||
|
CONSTRAINT unique_relation UNIQUE (ticket_id, related_ticket_id, relation_type),
|
||||||
|
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
|
||||||
|
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
|
||||||
|
CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type);
|
||||||
|
|
||||||
|
-- View for at finde alle relationer for en ticket (begge retninger)
|
||||||
|
CREATE OR REPLACE VIEW tticket_all_relations AS
|
||||||
|
SELECT
|
||||||
|
ticket_id,
|
||||||
|
related_ticket_id,
|
||||||
|
relation_type,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
reason
|
||||||
|
FROM tticket_relations
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
related_ticket_id as ticket_id,
|
||||||
|
ticket_id as related_ticket_id,
|
||||||
|
CASE
|
||||||
|
WHEN relation_type = 'parent_of' THEN 'child_of'
|
||||||
|
WHEN relation_type = 'child_of' THEN 'parent_of'
|
||||||
|
WHEN relation_type = 'merged_into' THEN 'merged_from'
|
||||||
|
WHEN relation_type = 'split_from' THEN 'split_into'
|
||||||
|
ELSE relation_type
|
||||||
|
END as relation_type,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
reason
|
||||||
|
FROM tticket_relations;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CALENDAR EVENTS (aftaler, deadlines, milepæle)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_calendar_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Event data
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
event_type VARCHAR(20) DEFAULT 'appointment' CHECK (event_type IN ('appointment', 'deadline', 'milestone', 'reminder', 'follow_up')),
|
||||||
|
|
||||||
|
-- Tidspunkt
|
||||||
|
event_date DATE NOT NULL,
|
||||||
|
event_time TIME,
|
||||||
|
duration_minutes INTEGER, -- Varighed i minutter
|
||||||
|
all_day BOOLEAN DEFAULT false,
|
||||||
|
|
||||||
|
-- AI forslag
|
||||||
|
suggested_by_ai BOOLEAN DEFAULT false, -- Blev denne foreslået af AI?
|
||||||
|
ai_confidence DECIMAL(3,2), -- AI confidence score 0-1
|
||||||
|
ai_source_text TEXT, -- Tekst som AI fandt datoen i
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled')),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by_user_id INTEGER, -- Reference til users.user_id
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
|
||||||
|
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
|
||||||
|
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
|
||||||
|
CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TEMPLATES (svarskabeloner, guides, standardbreve)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Template metadata
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100), -- guide, standard_letter, technical, billing, etc.
|
||||||
|
|
||||||
|
-- Template indhold
|
||||||
|
subject_template VARCHAR(500), -- Emne med placeholders
|
||||||
|
body_template TEXT NOT NULL, -- Indhold med placeholders
|
||||||
|
|
||||||
|
-- Placeholders dokumentation
|
||||||
|
available_placeholders TEXT[], -- fx ['{{customer_name}}', '{{ticket_number}}']
|
||||||
|
|
||||||
|
-- Attachments (optional)
|
||||||
|
default_attachments JSONB, -- Array af fil-paths/URLs
|
||||||
|
|
||||||
|
-- Settings
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
requires_approval BOOLEAN DEFAULT false, -- Kræver godkendelse før afsendelse
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
usage_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
|
||||||
|
CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_template_usage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES tticket_templates(id) ON DELETE CASCADE,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER, -- Reference til users.user_id
|
||||||
|
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id);
|
||||||
|
CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_ai_suggestions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Suggestion data
|
||||||
|
suggestion_type VARCHAR(50) NOT NULL CHECK (suggestion_type IN (
|
||||||
|
'contact_update', -- Opdater kontakt oplysninger
|
||||||
|
'new_contact', -- Ny kontakt opdaget
|
||||||
|
'category', -- Foreslå kategori
|
||||||
|
'tag', -- Foreslå tag
|
||||||
|
'priority', -- Foreslå prioritet
|
||||||
|
'deadline', -- Foreslå deadline
|
||||||
|
'calendar_event', -- Foreslå kalender event
|
||||||
|
'template', -- Foreslå skabelon
|
||||||
|
'merge', -- Foreslå flet med anden ticket
|
||||||
|
'related_ticket' -- Foreslå relation til anden ticket
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- Suggestion content
|
||||||
|
suggestion_data JSONB NOT NULL, -- Struktureret data om forslaget
|
||||||
|
confidence DECIMAL(3,2), -- AI confidence 0-1
|
||||||
|
reasoning TEXT, -- Hvorfor blev dette foreslået
|
||||||
|
|
||||||
|
-- Source
|
||||||
|
source_text TEXT, -- Tekst som AI analyserede
|
||||||
|
source_comment_id INTEGER REFERENCES tticket_comments(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'auto_expired')),
|
||||||
|
reviewed_by_user_id INTEGER, -- Hvem behandlede forslaget
|
||||||
|
reviewed_at TIMESTAMP,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP -- Forslag udløber efter X dage
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
|
||||||
|
CREATE INDEX idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
|
||||||
|
CREATE INDEX idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
|
||||||
|
CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- EMAIL METADATA (udvidet til contact identification)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_email_metadata (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Email headers
|
||||||
|
message_id VARCHAR(500) UNIQUE, -- Email Message-ID for threading
|
||||||
|
in_reply_to VARCHAR(500), -- In-Reply-To header
|
||||||
|
email_references TEXT, -- References header (renamed to avoid SQL keyword conflict)
|
||||||
|
|
||||||
|
-- Sender info (fra email)
|
||||||
|
from_email VARCHAR(255) NOT NULL,
|
||||||
|
from_name VARCHAR(255),
|
||||||
|
from_signature TEXT, -- Udtræk af signatur
|
||||||
|
|
||||||
|
-- Matched contact (hvis fundet)
|
||||||
|
matched_contact_id INTEGER, -- Reference til contacts.id
|
||||||
|
match_confidence DECIMAL(3,2), -- Hvor sikker er vi på match
|
||||||
|
match_method VARCHAR(50), -- email_exact, email_domain, name_similarity, etc.
|
||||||
|
|
||||||
|
-- Suggested contacts (hvis tvivl)
|
||||||
|
suggested_contacts JSONB, -- Array af {contact_id, confidence, reason}
|
||||||
|
|
||||||
|
-- Extracted data (AI analysis)
|
||||||
|
extracted_phone VARCHAR(50),
|
||||||
|
extracted_address TEXT,
|
||||||
|
extracted_company VARCHAR(255),
|
||||||
|
extracted_title VARCHAR(100),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
|
||||||
|
CREATE INDEX idx_tticket_email_message_id ON tticket_email_metadata(message_id);
|
||||||
|
CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Tilføj manglende kolonner til existing tticket_tickets
|
||||||
|
-- ============================================================================
|
||||||
|
ALTER TABLE tticket_tickets
|
||||||
|
ADD COLUMN IF NOT EXISTS deadline TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS parent_ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_merged BOOLEAN DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS merged_into_ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tticket_tickets_deadline ON tticket_tickets(deadline);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tticket_tickets_parent ON tticket_tickets(parent_ticket_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- AUDIT LOG for ticket changes (sporbarhed)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- What changed
|
||||||
|
action VARCHAR(50) NOT NULL, -- created, updated, merged, split, status_change, etc.
|
||||||
|
field_name VARCHAR(100), -- Hvilket felt blev ændret
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
|
||||||
|
-- Who and when
|
||||||
|
user_id INTEGER, -- Reference til users.user_id
|
||||||
|
performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
reason TEXT,
|
||||||
|
metadata JSONB -- Additional context
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
|
||||||
|
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
|
||||||
|
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS for audit logging
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION tticket_log_ticket_changes()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'UPDATE' THEN
|
||||||
|
-- Log status changes
|
||||||
|
IF OLD.status != NEW.status THEN
|
||||||
|
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
|
||||||
|
VALUES (NEW.id, 'status_change', 'status', OLD.status, NEW.status);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Log priority changes
|
||||||
|
IF OLD.priority != NEW.priority THEN
|
||||||
|
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
|
||||||
|
VALUES (NEW.id, 'priority_change', 'priority', OLD.priority, NEW.priority);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Log assignment changes
|
||||||
|
IF OLD.assigned_to_user_id IS DISTINCT FROM NEW.assigned_to_user_id THEN
|
||||||
|
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
|
||||||
|
VALUES (NEW.id, 'assignment_change', 'assigned_to_user_id',
|
||||||
|
OLD.assigned_to_user_id::TEXT, NEW.assigned_to_user_id::TEXT);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Log deadline changes
|
||||||
|
IF OLD.deadline IS DISTINCT FROM NEW.deadline THEN
|
||||||
|
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
|
||||||
|
VALUES (NEW.id, 'deadline_change', 'deadline',
|
||||||
|
OLD.deadline::TEXT, NEW.deadline::TEXT);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER tticket_audit_changes
|
||||||
|
AFTER UPDATE ON tticket_tickets
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION tticket_log_ticket_changes();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VIEWS for enhanced queries
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- View for tickets with hierarchy info
|
||||||
|
CREATE OR REPLACE VIEW tticket_tickets_with_hierarchy AS
|
||||||
|
SELECT
|
||||||
|
t.*,
|
||||||
|
parent.ticket_number as parent_ticket_number,
|
||||||
|
parent.subject as parent_subject,
|
||||||
|
(SELECT COUNT(*) FROM tticket_tickets WHERE parent_ticket_id = t.id) as child_count,
|
||||||
|
(SELECT COUNT(*) FROM tticket_relations WHERE ticket_id = t.id) as relation_count
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN tticket_tickets parent ON t.parent_ticket_id = parent.id;
|
||||||
|
|
||||||
|
-- View for tickets with pending AI suggestions
|
||||||
|
CREATE OR REPLACE VIEW tticket_tickets_with_suggestions AS
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.ticket_number,
|
||||||
|
t.subject,
|
||||||
|
t.status,
|
||||||
|
COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'pending') as pending_suggestions,
|
||||||
|
COUNT(DISTINCT ce.id) FILTER (WHERE ce.suggested_by_ai = true AND ce.status = 'pending') as pending_calendar_suggestions
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN tticket_ai_suggestions s ON t.id = s.ticket_id
|
||||||
|
LEFT JOIN tticket_calendar_events ce ON t.id = ce.ticket_id
|
||||||
|
GROUP BY t.id, t.ticket_number, t.subject, t.status;
|
||||||
|
|
||||||
|
-- View for overdue tickets
|
||||||
|
CREATE OR REPLACE VIEW tticket_overdue_tickets AS
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.ticket_number,
|
||||||
|
t.subject,
|
||||||
|
t.status,
|
||||||
|
t.priority,
|
||||||
|
t.deadline,
|
||||||
|
t.assigned_to_user_id,
|
||||||
|
(CURRENT_TIMESTAMP - t.deadline) as overdue_duration,
|
||||||
|
c.name as customer_name
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN customers c ON t.customer_id = c.id
|
||||||
|
WHERE t.deadline < CURRENT_TIMESTAMP
|
||||||
|
AND t.status NOT IN ('resolved', 'closed')
|
||||||
|
ORDER BY t.deadline ASC;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Seed data: Default templates
|
||||||
|
-- ============================================================================
|
||||||
|
INSERT INTO tticket_templates (name, description, category, subject_template, body_template, available_placeholders, is_active)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'Tak for henvendelse',
|
||||||
|
'Standard svar ved modtagelse af ticket',
|
||||||
|
'standard_letter',
|
||||||
|
'Re: {{ticket_subject}}',
|
||||||
|
'Hej {{contact_name}},
|
||||||
|
|
||||||
|
Tak for din henvendelse. Vi har modtaget din sag og den er nu registreret som sag nr. {{ticket_number}}.
|
||||||
|
|
||||||
|
Vi vender tilbage hurtigst muligt med svar.
|
||||||
|
|
||||||
|
Med venlig hilsen,
|
||||||
|
BMC Networks',
|
||||||
|
ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Løsning: Genstart router',
|
||||||
|
'Guide til genstart af router',
|
||||||
|
'guide',
|
||||||
|
'Re: {{ticket_subject}} - Løsning: Genstart router',
|
||||||
|
'Hej {{contact_name}},
|
||||||
|
|
||||||
|
Her er en guide til at genstarte din router:
|
||||||
|
|
||||||
|
1. Træk strømkablet ud af routeren
|
||||||
|
2. Vent 30 sekunder
|
||||||
|
3. Sæt strømkablet i igen
|
||||||
|
4. Vent 2-3 minutter mens routeren starter op
|
||||||
|
5. Test forbindelsen
|
||||||
|
|
||||||
|
Hvis problemet fortsætter, er du velkommen til at svare på denne mail.
|
||||||
|
|
||||||
|
Med venlig hilsen,
|
||||||
|
BMC Networks
|
||||||
|
|
||||||
|
Sag: {{ticket_number}}',
|
||||||
|
ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Afslutning af sag',
|
||||||
|
'Besked ved lukning af sag',
|
||||||
|
'standard_letter',
|
||||||
|
'Re: {{ticket_subject}} - Sag lukket',
|
||||||
|
'Hej {{contact_name}},
|
||||||
|
|
||||||
|
Vi betragter nu denne sag som løst og lukker den.
|
||||||
|
|
||||||
|
Hvis du har yderligere spørgsmål, er du velkommen til at kontakte os.
|
||||||
|
|
||||||
|
Med venlig hilsen,
|
||||||
|
BMC Networks
|
||||||
|
|
||||||
|
Sag: {{ticket_number}}',
|
||||||
|
ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Comments
|
||||||
|
-- ============================================================================
|
||||||
|
COMMENT ON TABLE tticket_relations IS 'Ticket relationer: merge, split, parent/child hierarki';
|
||||||
|
COMMENT ON TABLE tticket_calendar_events IS 'Kalender events, deadlines og milepæle på tickets';
|
||||||
|
COMMENT ON TABLE tticket_templates IS 'Svarskabeloner med placeholders';
|
||||||
|
COMMENT ON TABLE tticket_ai_suggestions IS 'AI forslag der kræver manuel godkendelse';
|
||||||
|
COMMENT ON TABLE tticket_email_metadata IS 'Email metadata og contact identification data';
|
||||||
|
COMMENT ON TABLE tticket_audit_log IS 'Audit trail for alle ticket ændringer';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Migration complete
|
||||||
|
-- ============================================================================
|
||||||
|
-- Dette modul tilføjer:
|
||||||
|
-- ✅ Ticket relations (merge, split, hierarchy)
|
||||||
|
-- ✅ Calendar events med AI forslag
|
||||||
|
-- ✅ Templates system
|
||||||
|
-- ✅ AI suggestions (kun forslag)
|
||||||
|
-- ✅ Enhanced email/contact matching
|
||||||
|
-- ✅ Full audit trail
|
||||||
|
-- ============================================================================
|
||||||
@ -6,21 +6,3 @@ pydantic-settings==2.6.1
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
python-multipart==0.0.17
|
python-multipart==0.0.17
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
pyjwt==2.9.0
|
|
||||||
aiohttp==3.10.10
|
|
||||||
|
|
||||||
# Email & Scheduling
|
|
||||||
APScheduler==3.10.4
|
|
||||||
msal==1.31.1
|
|
||||||
|
|
||||||
# Backup & SSH
|
|
||||||
paramiko==3.4.0
|
|
||||||
|
|
||||||
# AI & Document Processing
|
|
||||||
httpx==0.27.2
|
|
||||||
PyPDF2==3.0.1
|
|
||||||
pdfplumber==0.11.4
|
|
||||||
pytesseract==0.3.13
|
|
||||||
Pillow==11.0.0
|
|
||||||
invoice2data==0.4.4
|
|
||||||
pyyaml==6.0.2
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user