2026-01-29 23:07:33 +01:00
|
|
|
import logging
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
import os
|
|
|
|
|
import shutil
|
|
|
|
|
from pathlib import Path
|
2026-01-29 23:07:33 +01:00
|
|
|
from datetime import datetime
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
from typing import List, Optional
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
from fastapi.responses import FileResponse
|
2026-02-15 11:12:58 +01:00
|
|
|
from pydantic import BaseModel, Field
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
from app.core.database import execute_query, execute_query_single
|
2026-02-14 02:26:29 +01:00
|
|
|
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.services.email_service import EmailService
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import extract_msg
|
|
|
|
|
except ImportError:
|
|
|
|
|
extract_msg = None
|
|
|
|
|
import email
|
|
|
|
|
from email.header import decode_header
|
2026-01-29 23:07:33 +01:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
def _get_user_id_from_request(request: Request) -> int:
|
|
|
|
|
user_id = getattr(request.state, "user_id", None)
|
|
|
|
|
if user_id is not None:
|
|
|
|
|
try:
|
|
|
|
|
return int(user_id)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid user_id format")
|
|
|
|
|
|
|
|
|
|
user_id = request.query_params.get("user_id")
|
|
|
|
|
if user_id:
|
|
|
|
|
try:
|
|
|
|
|
return int(user_id)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid user_id format")
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
def _normalize_case_status(status_value: Optional[str]) -> str:
|
|
|
|
|
if not status_value:
|
|
|
|
|
return "åben"
|
|
|
|
|
|
|
|
|
|
normalized = str(status_value).strip().lower()
|
|
|
|
|
if normalized == "afventer":
|
|
|
|
|
return "åben"
|
|
|
|
|
if normalized in {"åben", "lukket"}:
|
|
|
|
|
return normalized
|
|
|
|
|
return "åben"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if isinstance(value, datetime):
|
|
|
|
|
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
|
|
|
if parsed.tzinfo is not None:
|
|
|
|
|
parsed = parsed.replace(tzinfo=None)
|
|
|
|
|
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _coerce_optional_int(value: Optional[object], field_name: str) -> Optional[int]:
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return int(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_user_id(user_id: Optional[int], field_name: str = "ansvarlig_bruger_id") -> None:
|
|
|
|
|
if user_id is None:
|
|
|
|
|
return
|
|
|
|
|
exists = execute_query("SELECT 1 FROM users WHERE user_id = %s", (user_id,))
|
|
|
|
|
if not exists:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_group_id") -> None:
|
|
|
|
|
if group_id is None:
|
|
|
|
|
return
|
|
|
|
|
exists = execute_query("SELECT 1 FROM groups WHERE id = %s", (group_id,))
|
|
|
|
|
if not exists:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
|
|
|
|
|
2026-01-29 23:07:33 +01:00
|
|
|
# ============================================================================
|
2026-02-01 00:38:10 +01:00
|
|
|
# SAGER - CRUD Operations
|
2026-01-29 23:07:33 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.get("/sag")
|
|
|
|
|
async def list_sager(
|
|
|
|
|
status: Optional[str] = Query(None),
|
|
|
|
|
tag: Optional[str] = Query(None),
|
|
|
|
|
customer_id: Optional[int] = Query(None),
|
|
|
|
|
ansvarlig_bruger_id: Optional[int] = Query(None),
|
2026-02-17 08:29:05 +01:00
|
|
|
assigned_group_id: Optional[int] = Query(None),
|
2026-02-09 15:30:07 +01:00
|
|
|
include_deferred: bool = Query(False),
|
2026-02-14 02:26:29 +01:00
|
|
|
q: Optional[str] = Query(None),
|
|
|
|
|
limit: Optional[int] = Query(None, ge=1, le=200),
|
|
|
|
|
offset: int = Query(0, ge=0),
|
2026-02-01 00:38:10 +01:00
|
|
|
):
|
|
|
|
|
"""List all cases with optional filtering."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
2026-02-17 08:29:05 +01:00
|
|
|
query = """
|
|
|
|
|
SELECT s.*,
|
|
|
|
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
|
|
|
|
g.name AS assigned_group_name
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
|
|
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
|
|
|
|
WHERE s.deleted_at IS NULL
|
|
|
|
|
"""
|
2026-02-01 00:38:10 +01:00
|
|
|
params = []
|
2026-02-09 15:30:07 +01:00
|
|
|
|
|
|
|
|
if not include_deferred:
|
|
|
|
|
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
if status:
|
2026-02-17 08:29:05 +01:00
|
|
|
query += " AND s.status = %s"
|
2026-02-01 00:38:10 +01:00
|
|
|
params.append(status)
|
|
|
|
|
if customer_id:
|
2026-02-17 08:29:05 +01:00
|
|
|
query += " AND s.customer_id = %s"
|
2026-02-01 00:38:10 +01:00
|
|
|
params.append(customer_id)
|
|
|
|
|
if ansvarlig_bruger_id:
|
2026-02-17 08:29:05 +01:00
|
|
|
query += " AND s.ansvarlig_bruger_id = %s"
|
2026-02-01 00:38:10 +01:00
|
|
|
params.append(ansvarlig_bruger_id)
|
2026-02-17 08:29:05 +01:00
|
|
|
if assigned_group_id:
|
|
|
|
|
query += " AND s.assigned_group_id = %s"
|
|
|
|
|
params.append(assigned_group_id)
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
if q:
|
2026-02-17 08:29:05 +01:00
|
|
|
query += " AND (LOWER(s.titel) LIKE %s OR CAST(s.id AS TEXT) LIKE %s)"
|
2026-02-14 02:26:29 +01:00
|
|
|
q_like = f"%{q.lower()}%"
|
|
|
|
|
params.extend([q_like, q_like])
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
query += " ORDER BY s.created_at DESC"
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
if limit is not None:
|
|
|
|
|
query += " LIMIT %s OFFSET %s"
|
|
|
|
|
params.extend([limit, offset])
|
2026-01-31 23:16:24 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
cases = execute_query(query, tuple(params))
|
2026-01-31 23:16:24 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
# If tag filter, filter in Python after fetch
|
|
|
|
|
if tag:
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
case_ids = [case["id"] for case in cases]
|
2026-02-01 00:38:10 +01:00
|
|
|
if case_ids:
|
|
|
|
|
tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL"
|
|
|
|
|
tagged_cases = execute_query(tag_query, (tag,))
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
tagged_ids = set(t["sag_id"] for t in tagged_cases)
|
|
|
|
|
cases = [c for c in cases if c["id"] in tagged_ids]
|
2026-02-01 00:38:10 +01:00
|
|
|
|
|
|
|
|
return cases
|
2026-01-29 23:07:33 +01:00
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error listing cases: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list cases")
|
2026-01-31 23:16:24 +01:00
|
|
|
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
@router.get("/sag/sale-items/all")
|
|
|
|
|
async def list_all_sale_items(
|
|
|
|
|
type: Optional[str] = Query(None),
|
|
|
|
|
status: Optional[str] = Query(None),
|
|
|
|
|
q: Optional[str] = Query(None),
|
|
|
|
|
customer_id: Optional[int] = Query(None),
|
|
|
|
|
sag_id: Optional[int] = Query(None),
|
|
|
|
|
date_from: Optional[str] = Query(None),
|
|
|
|
|
date_to: Optional[str] = Query(None),
|
|
|
|
|
):
|
|
|
|
|
"""List all sale items across cases (orders overview)."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT si.*, s.titel AS sag_titel, s.customer_id, c.name AS customer_name
|
|
|
|
|
FROM sag_salgsvarer si
|
|
|
|
|
JOIN sag_sager s ON s.id = si.sag_id
|
|
|
|
|
LEFT JOIN customers c ON c.id = s.customer_id
|
|
|
|
|
WHERE s.deleted_at IS NULL
|
|
|
|
|
"""
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
if type:
|
|
|
|
|
query += " AND LOWER(si.type) = %s"
|
|
|
|
|
params.append(type.lower())
|
|
|
|
|
if status:
|
|
|
|
|
query += " AND LOWER(si.status) = %s"
|
|
|
|
|
params.append(status.lower())
|
|
|
|
|
if q:
|
|
|
|
|
query += " AND (LOWER(si.description) LIKE %s OR LOWER(si.external_ref) LIKE %s OR LOWER(s.titel) LIKE %s OR LOWER(c.name) LIKE %s)"
|
|
|
|
|
like = f"%{q.lower()}%"
|
|
|
|
|
params.extend([like, like, like, like])
|
|
|
|
|
if customer_id:
|
|
|
|
|
query += " AND s.customer_id = %s"
|
|
|
|
|
params.append(customer_id)
|
|
|
|
|
if sag_id:
|
|
|
|
|
query += " AND s.id = %s"
|
|
|
|
|
params.append(sag_id)
|
|
|
|
|
if date_from:
|
|
|
|
|
query += " AND si.line_date >= %s"
|
|
|
|
|
params.append(date_from)
|
|
|
|
|
if date_to:
|
|
|
|
|
query += " AND si.line_date <= %s"
|
|
|
|
|
params.append(date_to)
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY si.line_date DESC NULLS LAST, si.id DESC"
|
|
|
|
|
|
|
|
|
|
return execute_query(query, tuple(params)) or []
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing sale items: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list sale items")
|
|
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.post("/sag")
|
|
|
|
|
async def create_sag(data: dict):
|
|
|
|
|
"""Create a new case."""
|
|
|
|
|
try:
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
if not data.get("titel"):
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=400, detail="titel is required")
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
if not data.get("customer_id"):
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
status = _normalize_case_status(data.get("status"))
|
|
|
|
|
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
|
|
|
|
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
|
|
|
|
ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
|
|
|
|
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
|
|
|
|
|
|
|
|
|
_validate_user_id(ansvarlig_bruger_id)
|
|
|
|
|
_validate_group_id(assigned_group_id)
|
2026-02-03 15:37:16 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_sager
|
2026-02-17 08:29:05 +01:00
|
|
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2026-02-01 00:38:10 +01:00
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
data.get("titel"),
|
|
|
|
|
data.get("beskrivelse", ""),
|
2026-02-03 15:37:16 +01:00
|
|
|
data.get("template_key") or data.get("type", "ticket"),
|
|
|
|
|
status,
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
data.get("customer_id"),
|
2026-02-17 08:29:05 +01:00
|
|
|
ansvarlig_bruger_id,
|
|
|
|
|
assigned_group_id,
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
data.get("created_by_user_id", 1),
|
2026-02-17 08:29:05 +01:00
|
|
|
deadline,
|
|
|
|
|
deferred_until,
|
2026-02-06 10:47:14 +01:00
|
|
|
data.get("deferred_until_case_id"),
|
|
|
|
|
data.get("deferred_until_status"),
|
2026-02-01 00:38:10 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
if result:
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
logger.info("✅ Case created: %s", result[0]["id"])
|
2026-02-01 00:38:10 +01:00
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create case")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error creating case: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create case")
|
2026-01-31 23:16:24 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.get("/sag/{sag_id}")
|
|
|
|
|
async def get_sag(sag_id: int):
|
|
|
|
|
"""Get a specific case."""
|
|
|
|
|
try:
|
2026-02-17 08:29:05 +01:00
|
|
|
query = """
|
|
|
|
|
SELECT s.*,
|
|
|
|
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
|
|
|
|
g.name AS assigned_group_name
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
|
|
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
|
|
|
|
WHERE s.id = %s AND s.deleted_at IS NULL
|
|
|
|
|
"""
|
2026-02-01 00:38:10 +01:00
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
return result[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error getting case: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to get case")
|
2026-01-31 23:16:24 +01:00
|
|
|
|
2026-02-06 10:47:14 +01:00
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/modules")
|
|
|
|
|
async def get_case_module_prefs(sag_id: int):
|
|
|
|
|
"""Get module visibility preferences for a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s"
|
|
|
|
|
prefs = execute_query(query, (sag_id,))
|
|
|
|
|
return prefs or []
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error getting module prefs: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to get module prefs")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/modules")
|
|
|
|
|
async def set_case_module_pref(sag_id: int, data: dict):
|
|
|
|
|
"""Set module visibility preference for a case."""
|
|
|
|
|
try:
|
|
|
|
|
module_key = data.get("module_key")
|
|
|
|
|
is_enabled = data.get("is_enabled")
|
|
|
|
|
|
|
|
|
|
if not module_key or is_enabled is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail="module_key and is_enabled are required")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
ON CONFLICT (sag_id, module_key)
|
|
|
|
|
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
|
|
|
|
|
RETURNING module_key, is_enabled
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, module_key, bool(is_enabled)))
|
|
|
|
|
return result[0] if result else {"module_key": module_key, "is_enabled": bool(is_enabled)}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error setting module pref: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to set module pref")
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# SAG TODO STEPS
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
|
|
|
|
|
async def list_todo_steps(sag_id: int):
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
t.*,
|
|
|
|
|
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
|
|
|
|
|
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
|
|
|
|
|
FROM sag_todo_steps t
|
|
|
|
|
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
|
|
|
|
|
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
|
|
|
|
|
WHERE t.sag_id = %s AND t.deleted_at IS NULL
|
|
|
|
|
ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
return execute_query(query, (sag_id,)) or []
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing todo steps: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list todo steps")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/todo-steps", response_model=TodoStep)
|
|
|
|
|
async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
|
|
|
|
|
try:
|
|
|
|
|
user_id = _get_user_id_from_request(request)
|
|
|
|
|
if not data.title:
|
|
|
|
|
raise HTTPException(status_code=400, detail="title is required")
|
|
|
|
|
|
|
|
|
|
insert_query = """
|
|
|
|
|
INSERT INTO sag_todo_steps
|
|
|
|
|
(sag_id, title, description, due_date, created_by_user_id)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(insert_query, (
|
|
|
|
|
sag_id,
|
|
|
|
|
data.title,
|
|
|
|
|
data.description,
|
|
|
|
|
data.due_date,
|
|
|
|
|
user_id
|
|
|
|
|
))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create todo step")
|
|
|
|
|
|
|
|
|
|
step_id = result[0]["id"]
|
|
|
|
|
return execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
t.*,
|
|
|
|
|
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
|
|
|
|
|
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
|
|
|
|
|
FROM sag_todo_steps t
|
|
|
|
|
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
|
|
|
|
|
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
|
|
|
|
|
WHERE t.id = %s
|
|
|
|
|
""",
|
|
|
|
|
(step_id,)
|
|
|
|
|
)[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error creating todo step: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create todo step")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
|
|
|
|
|
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
|
|
|
|
|
try:
|
|
|
|
|
if data.is_done is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail="is_done is required")
|
|
|
|
|
|
|
|
|
|
user_id = _get_user_id_from_request(request)
|
|
|
|
|
if data.is_done:
|
|
|
|
|
update_query = """
|
|
|
|
|
UPDATE sag_todo_steps
|
|
|
|
|
SET is_done = TRUE,
|
|
|
|
|
completed_by_user_id = %s,
|
|
|
|
|
completed_at = CURRENT_TIMESTAMP
|
|
|
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(update_query, (user_id, step_id))
|
|
|
|
|
else:
|
|
|
|
|
update_query = """
|
|
|
|
|
UPDATE sag_todo_steps
|
|
|
|
|
SET is_done = FALSE,
|
|
|
|
|
completed_by_user_id = NULL,
|
|
|
|
|
completed_at = NULL
|
|
|
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(update_query, (step_id,))
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Todo step not found")
|
|
|
|
|
|
|
|
|
|
return execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
t.*,
|
|
|
|
|
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
|
|
|
|
|
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
|
|
|
|
|
FROM sag_todo_steps t
|
|
|
|
|
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
|
|
|
|
|
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
|
|
|
|
|
WHERE t.id = %s
|
|
|
|
|
""",
|
|
|
|
|
(step_id,)
|
|
|
|
|
)[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error updating todo step: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update todo step")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/sag/todo-steps/{step_id}")
|
|
|
|
|
async def delete_todo_step(step_id: int):
|
|
|
|
|
try:
|
|
|
|
|
result = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE sag_todo_steps
|
|
|
|
|
SET deleted_at = CURRENT_TIMESTAMP
|
|
|
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
""",
|
|
|
|
|
(step_id,)
|
|
|
|
|
)
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Todo step not found")
|
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error deleting todo step: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
|
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.patch("/sag/{sag_id}")
|
|
|
|
|
async def update_sag(sag_id: int, updates: dict):
|
|
|
|
|
"""Update a case."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
2026-02-01 00:38:10 +01:00
|
|
|
# Check if case exists
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
# Backwards compatibility: frontend sends "type", DB stores "template_key"
|
|
|
|
|
if "type" in updates and "template_key" not in updates:
|
|
|
|
|
updates["template_key"] = updates.get("type")
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
if "status" in updates:
|
|
|
|
|
updates["status"] = _normalize_case_status(updates.get("status"))
|
|
|
|
|
if "deadline" in updates:
|
|
|
|
|
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
|
|
|
|
if "deferred_until" in updates:
|
|
|
|
|
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
|
|
|
|
|
if "ansvarlig_bruger_id" in updates:
|
|
|
|
|
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
|
|
|
|
_validate_user_id(updates["ansvarlig_bruger_id"])
|
|
|
|
|
if "assigned_group_id" in updates:
|
|
|
|
|
updates["assigned_group_id"] = _coerce_optional_int(updates.get("assigned_group_id"), "assigned_group_id")
|
|
|
|
|
_validate_group_id(updates["assigned_group_id"])
|
|
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
# Build dynamic update query
|
2026-02-17 08:29:05 +01:00
|
|
|
allowed_fields = [
|
|
|
|
|
"titel",
|
|
|
|
|
"beskrivelse",
|
|
|
|
|
"template_key",
|
|
|
|
|
"status",
|
|
|
|
|
"ansvarlig_bruger_id",
|
|
|
|
|
"assigned_group_id",
|
|
|
|
|
"deadline",
|
|
|
|
|
"deferred_until",
|
|
|
|
|
"deferred_until_case_id",
|
|
|
|
|
"deferred_until_status",
|
|
|
|
|
]
|
2026-02-01 00:38:10 +01:00
|
|
|
set_clauses = []
|
|
|
|
|
params = []
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
for field in allowed_fields:
|
|
|
|
|
if field in updates:
|
|
|
|
|
set_clauses.append(f"{field} = %s")
|
|
|
|
|
params.append(updates[field])
|
|
|
|
|
|
|
|
|
|
if not set_clauses:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No valid fields to update")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
params.append(sag_id)
|
2026-02-15 11:12:58 +01:00
|
|
|
set_sql = ", ".join(set_clauses)
|
|
|
|
|
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
result = execute_query(query, tuple(params))
|
2026-01-29 23:07:33 +01:00
|
|
|
if result:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.info("✅ Case updated: %s", sag_id)
|
2026-01-29 23:07:33 +01:00
|
|
|
return result[0]
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=500, detail="Failed to update case")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error updating case: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update case")
|
|
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
|
|
|
|
|
class PipelineUpdate(BaseModel):
|
|
|
|
|
amount: Optional[float] = None
|
|
|
|
|
probability: Optional[int] = Field(default=None, ge=0, le=100)
|
|
|
|
|
stage_id: Optional[int] = None
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/sag/{sag_id}/pipeline")
|
|
|
|
|
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
|
|
|
|
|
"""Update pipeline fields for a case."""
|
|
|
|
|
try:
|
|
|
|
|
exists = execute_query(
|
|
|
|
|
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id,)
|
|
|
|
|
)
|
|
|
|
|
if not exists:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
provided = pipeline_data.model_dump(exclude_unset=True)
|
|
|
|
|
|
|
|
|
|
if not provided:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No pipeline fields provided")
|
|
|
|
|
|
|
|
|
|
if "stage_id" in provided and provided["stage_id"] is not None:
|
|
|
|
|
stage_exists = execute_query(
|
|
|
|
|
"SELECT id FROM pipeline_stages WHERE id = %s",
|
|
|
|
|
(provided["stage_id"],)
|
|
|
|
|
)
|
|
|
|
|
if not stage_exists:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid pipeline stage")
|
|
|
|
|
|
|
|
|
|
set_clauses = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
if "amount" in provided:
|
|
|
|
|
set_clauses.append("pipeline_amount = %s")
|
|
|
|
|
params.append(provided["amount"])
|
|
|
|
|
|
|
|
|
|
if "probability" in provided:
|
|
|
|
|
set_clauses.append("pipeline_probability = %s")
|
|
|
|
|
params.append(provided["probability"])
|
|
|
|
|
|
|
|
|
|
if "stage_id" in provided:
|
|
|
|
|
set_clauses.append("pipeline_stage_id = %s")
|
|
|
|
|
params.append(provided["stage_id"])
|
|
|
|
|
|
|
|
|
|
if "description" in provided:
|
|
|
|
|
set_clauses.append("pipeline_description = %s")
|
|
|
|
|
params.append(provided["description"])
|
|
|
|
|
|
|
|
|
|
params.append(sag_id)
|
|
|
|
|
query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *"
|
|
|
|
|
result = execute_query(query, tuple(params))
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
|
|
|
|
|
|
|
|
|
logger.info("✅ Pipeline updated for case: %s", sag_id)
|
|
|
|
|
return result[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
|
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.delete("/sag/{sag_id}")
|
|
|
|
|
async def delete_sag(sag_id: int):
|
|
|
|
|
"""Soft-delete a case."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
query = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Case soft-deleted: %s", sag_id)
|
|
|
|
|
return {"status": "deleted", "id": sag_id}
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete case")
|
2026-01-29 23:07:33 +01:00
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error deleting case: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete case")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
|
|
|
|
# ============================================================================
|
2026-02-01 00:38:10 +01:00
|
|
|
# RELATIONER - Case Relations
|
2026-01-29 23:07:33 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.get("/sag/{sag_id}/relationer")
|
|
|
|
|
async def get_relationer(sag_id: int):
|
|
|
|
|
"""Get all relations for a case."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
2026-02-01 00:38:10 +01:00
|
|
|
# Check if case exists
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
2026-01-29 23:07:33 +01:00
|
|
|
query = """
|
2026-02-01 00:38:10 +01:00
|
|
|
SELECT sr.*,
|
|
|
|
|
ss_kilde.titel as kilde_titel,
|
|
|
|
|
ss_mål.titel as mål_titel
|
|
|
|
|
FROM sag_relationer sr
|
|
|
|
|
JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id
|
|
|
|
|
JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id
|
|
|
|
|
WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s)
|
|
|
|
|
AND sr.deleted_at IS NULL
|
|
|
|
|
ORDER BY sr.created_at DESC
|
2026-01-29 23:07:33 +01:00
|
|
|
"""
|
2026-02-01 00:38:10 +01:00
|
|
|
result = execute_query(query, (sag_id, sag_id))
|
|
|
|
|
return result
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2026-01-29 23:07:33 +01:00
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error getting relations: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to get relations")
|
2026-01-31 23:16:24 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.post("/sag/{sag_id}/relationer")
|
|
|
|
|
async def create_relation(sag_id: int, data: dict):
|
|
|
|
|
"""Add a relation to another case."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
if not data.get("målsag_id") or not data.get("relationstype"):
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
|
|
|
|
|
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
målsag_id = data.get("målsag_id")
|
|
|
|
|
relationstype = data.get("relationstype")
|
2026-02-01 00:38:10 +01:00
|
|
|
|
|
|
|
|
# Validate both cases exist
|
|
|
|
|
check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
check2 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (målsag_id,))
|
|
|
|
|
|
|
|
|
|
if not check1 or not check2:
|
|
|
|
|
raise HTTPException(status_code=404, detail="One or both cases not found")
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
query = """
|
2026-02-01 00:38:10 +01:00
|
|
|
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
|
|
|
|
|
VALUES (%s, %s, %s)
|
2026-01-31 23:16:24 +01:00
|
|
|
RETURNING *
|
|
|
|
|
"""
|
2026-02-01 00:38:10 +01:00
|
|
|
result = execute_query(query, (sag_id, målsag_id, relationstype))
|
|
|
|
|
|
2026-01-29 23:07:33 +01:00
|
|
|
if result:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.info("✅ Relation created: %s -> %s (%s)", sag_id, målsag_id, relationstype)
|
2026-01-31 23:16:24 +01:00
|
|
|
return result[0]
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=500, detail="Failed to create relation")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2026-01-29 23:07:33 +01:00
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error creating relation: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create relation")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.delete("/sag/{sag_id}/relationer/{relation_id}")
|
|
|
|
|
async def delete_relation(sag_id: int, relation_id: int):
|
|
|
|
|
"""Soft-delete a relation."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
2026-02-01 00:38:10 +01:00
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_relationer WHERE id = %s AND deleted_at IS NULL AND (kilde_sag_id = %s OR målsag_id = %s)",
|
|
|
|
|
(relation_id, sag_id, sag_id)
|
|
|
|
|
)
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Relation not found")
|
|
|
|
|
|
|
|
|
|
query = "UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (relation_id,))
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
if result:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.info("✅ Relation soft-deleted: %s", relation_id)
|
|
|
|
|
return {"status": "deleted", "id": relation_id}
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete relation")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2026-01-29 23:07:33 +01:00
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error deleting relation: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete relation")
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# TAGS - Case Tags
|
|
|
|
|
# ============================================================================
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.get("/sag/{sag_id}/tags")
|
|
|
|
|
async def get_tags(sag_id: int):
|
|
|
|
|
"""Get all tags for a case."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
return result
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error getting tags: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to get tags")
|
2026-01-31 23:16:24 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.post("/sag/{sag_id}/tags")
|
|
|
|
|
async def add_tag(sag_id: int, data: dict):
|
|
|
|
|
"""Add a tag to a case."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
if not data.get("tag_navn"):
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=400, detail="tag_navn is required")
|
|
|
|
|
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
2026-01-29 23:07:33 +01:00
|
|
|
query = """
|
2026-02-01 00:38:10 +01:00
|
|
|
INSERT INTO sag_tags (sag_id, tag_navn)
|
|
|
|
|
VALUES (%s, %s)
|
2026-01-29 23:07:33 +01:00
|
|
|
RETURNING *
|
|
|
|
|
"""
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
result = execute_query(query, (sag_id, data.get("tag_navn")))
|
2026-02-01 00:38:10 +01:00
|
|
|
|
2026-01-29 23:07:33 +01:00
|
|
|
if result:
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
logger.info("✅ Tag added: %s -> %s", sag_id, data.get("tag_navn"))
|
2026-01-29 23:07:33 +01:00
|
|
|
return result[0]
|
2026-02-01 00:38:10 +01:00
|
|
|
raise HTTPException(status_code=500, detail="Failed to add tag")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2026-01-29 23:07:33 +01:00
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error adding tag: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add tag")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
@router.delete("/sag/{sag_id}/tags/{tag_id}")
|
|
|
|
|
async def delete_tag(sag_id: int, tag_id: int):
|
|
|
|
|
"""Soft-delete a tag."""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
2026-02-01 00:38:10 +01:00
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL",
|
|
|
|
|
(tag_id, sag_id)
|
|
|
|
|
)
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Tag not found")
|
2026-01-29 23:07:33 +01:00
|
|
|
|
2026-02-01 00:38:10 +01:00
|
|
|
query = "UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (tag_id,))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Tag soft-deleted: %s", tag_id)
|
|
|
|
|
return {"status": "deleted", "id": tag_id}
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete tag")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2026-01-29 23:07:33 +01:00
|
|
|
except Exception as e:
|
2026-02-01 00:38:10 +01:00
|
|
|
logger.error("❌ Error deleting tag: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete tag")
|
2026-02-01 11:58:44 +01:00
|
|
|
|
|
|
|
|
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
# CUSTOMERS - Case Customers (Many-to-Many)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/customers")
|
|
|
|
|
async def list_case_customers(sag_id: int):
|
|
|
|
|
"""List customers associated with a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT sk.*, c.name as customer_name, c.email as customer_email
|
|
|
|
|
FROM sag_kunder sk
|
|
|
|
|
JOIN customers c ON sk.customer_id = c.id
|
|
|
|
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing case customers: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list case customers")
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/customers")
|
|
|
|
|
async def add_case_customer(sag_id: int, data: dict):
|
|
|
|
|
"""Add a customer to a case."""
|
|
|
|
|
try:
|
|
|
|
|
if not data.get("customer_id"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
|
|
|
|
|
|
|
|
|
role = data.get("role", "Kunde")
|
|
|
|
|
|
|
|
|
|
# Check if already exists
|
|
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_kunder WHERE sag_id = %s AND customer_id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id, data["customer_id"])
|
|
|
|
|
)
|
|
|
|
|
if check:
|
|
|
|
|
return check[0] # Already linked
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_kunder (sag_id, customer_id, role)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, data["customer_id"], role))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Customer %s added to case %s", data["customer_id"], sag_id)
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add case customer")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error adding case customer: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add case customer")
|
|
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/customers/{customer_id}")
|
|
|
|
|
async def remove_case_customer(sag_id: int, customer_id: int):
|
|
|
|
|
"""Remove customer from case."""
|
|
|
|
|
try:
|
|
|
|
|
query = "UPDATE sag_kunder SET deleted_at = NOW() WHERE sag_id = %s AND customer_id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (sag_id, customer_id))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Customer %s removed from case %s", customer_id, sag_id)
|
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
raise HTTPException(status_code=404, detail="Customer link not found")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error removing case customer: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to remove case customer")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# CONTACTS - Case Contacts
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/contacts")
|
|
|
|
|
async def list_case_contacts(sag_id: int):
|
|
|
|
|
"""List contacts associated with a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
2026-02-06 10:47:14 +01:00
|
|
|
SELECT
|
|
|
|
|
sk.*,
|
|
|
|
|
c.first_name,
|
|
|
|
|
c.last_name,
|
|
|
|
|
c.email,
|
|
|
|
|
c.phone,
|
|
|
|
|
c.mobile,
|
|
|
|
|
c.title,
|
|
|
|
|
company.customer_name
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
FROM sag_kontakter sk
|
|
|
|
|
JOIN contacts c ON sk.contact_id = c.id
|
2026-02-06 10:47:14 +01:00
|
|
|
LEFT JOIN LATERAL (
|
|
|
|
|
SELECT cu.name AS customer_name
|
|
|
|
|
FROM contact_companies cc
|
|
|
|
|
JOIN customers cu ON cu.id = cc.customer_id
|
|
|
|
|
WHERE cc.contact_id = c.id
|
|
|
|
|
ORDER BY cc.is_primary DESC, cu.name
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) company ON TRUE
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing case contacts: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list case contacts")
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/contacts")
|
|
|
|
|
async def add_case_contact(sag_id: int, data: dict):
|
|
|
|
|
"""Add a contact to a case."""
|
|
|
|
|
try:
|
|
|
|
|
if not data.get("contact_id"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="contact_id is required")
|
|
|
|
|
|
|
|
|
|
role = data.get("role", "Kontakt")
|
2026-02-06 10:47:14 +01:00
|
|
|
is_primary = bool(data.get("is_primary", False))
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
|
|
|
|
|
# Check if already exists
|
|
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_kontakter WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id, data["contact_id"])
|
|
|
|
|
)
|
|
|
|
|
if check:
|
|
|
|
|
return check[0] # Already linked
|
|
|
|
|
|
2026-02-06 10:47:14 +01:00
|
|
|
if is_primary:
|
|
|
|
|
execute_update(
|
|
|
|
|
"UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s",
|
|
|
|
|
(sag_id,)
|
|
|
|
|
)
|
|
|
|
|
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
query = """
|
2026-02-06 10:47:14 +01:00
|
|
|
INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
|
|
|
|
|
VALUES (%s, %s, %s, %s)
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
RETURNING *
|
|
|
|
|
"""
|
2026-02-06 10:47:14 +01:00
|
|
|
result = execute_query(query, (sag_id, data["contact_id"], role, is_primary))
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Contact added to case %s: %s", sag_id, data["contact_id"])
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add contact")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error adding case contact: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add contact")
|
|
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/contacts/{contact_id}")
|
|
|
|
|
async def remove_case_contact(sag_id: int, contact_id: int):
|
|
|
|
|
"""Remove contact from case."""
|
|
|
|
|
try:
|
|
|
|
|
query = "UPDATE sag_kontakter SET deleted_at = NOW() WHERE sag_id = %s AND contact_id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (sag_id, contact_id))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Contact %s removed from case %s", contact_id, sag_id)
|
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact link not found")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error removing case contact: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to remove case contact")
|
|
|
|
|
|
|
|
|
|
|
2026-02-06 10:47:14 +01:00
|
|
|
@router.patch("/sag/{sag_id}/contacts/{contact_id}")
|
|
|
|
|
async def update_case_contact(sag_id: int, contact_id: int, data: dict):
|
|
|
|
|
"""Update role or primary status for a case contact."""
|
|
|
|
|
try:
|
|
|
|
|
existing = execute_query(
|
|
|
|
|
"SELECT id FROM sag_kontakter WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id, contact_id)
|
|
|
|
|
)
|
|
|
|
|
if not existing:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact link not found")
|
|
|
|
|
|
|
|
|
|
role = data.get("role")
|
|
|
|
|
is_primary = data.get("is_primary")
|
|
|
|
|
|
|
|
|
|
updates = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
if role is not None:
|
|
|
|
|
updates.append("role = %s")
|
|
|
|
|
params.append(role)
|
|
|
|
|
|
|
|
|
|
if is_primary is not None:
|
|
|
|
|
if bool(is_primary):
|
|
|
|
|
execute_update(
|
|
|
|
|
"UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s",
|
|
|
|
|
(sag_id,)
|
|
|
|
|
)
|
|
|
|
|
updates.append("is_primary = %s")
|
|
|
|
|
params.append(bool(is_primary))
|
|
|
|
|
|
|
|
|
|
if not updates:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
|
|
|
|
|
|
params.extend([sag_id, contact_id])
|
|
|
|
|
query = f"""
|
|
|
|
|
UPDATE sag_kontakter
|
|
|
|
|
SET {', '.join(updates)}
|
|
|
|
|
WHERE sag_id = %s AND contact_id = %s
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
result = execute_query(query, tuple(params))
|
|
|
|
|
if result:
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update contact")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error updating case contact: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update contact")
|
|
|
|
|
|
|
|
|
|
|
2026-02-01 11:58:44 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
# HARDWARE - Placeholder endpoints for frontend compatibility
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/hardware")
|
|
|
|
|
async def list_case_hardware(sag_id: int):
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
"""List hardware associated with a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT sh.id as relation_id, h.*
|
|
|
|
|
FROM sag_hardware sh
|
|
|
|
|
JOIN hardware_assets h ON sh.hardware_id = h.id
|
|
|
|
|
WHERE sh.sag_id = %s AND sh.deleted_at IS NULL
|
|
|
|
|
ORDER BY sh.created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing case hardware: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list case hardware")
|
2026-02-01 11:58:44 +01:00
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/hardware")
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
async def add_case_hardware(sag_id: int, data: dict):
|
|
|
|
|
"""Add hardware to case."""
|
|
|
|
|
try:
|
|
|
|
|
if not data.get("hardware_id"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="hardware_id is required")
|
|
|
|
|
|
|
|
|
|
hardware_id = data.get("hardware_id")
|
|
|
|
|
|
|
|
|
|
# Check if already linked
|
|
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_hardware WHERE sag_id = %s AND hardware_id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id, hardware_id)
|
|
|
|
|
)
|
|
|
|
|
if check:
|
|
|
|
|
return check[0] # Already linked
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_hardware (sag_id, hardware_id, note)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
ON CONFLICT (sag_id, hardware_id)
|
|
|
|
|
DO UPDATE SET deleted_at = NULL, note = EXCLUDED.note
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, hardware_id, data.get("note")))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Hardware %s added to case %s", hardware_id, sag_id)
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add hardware")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error adding case hardware: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add hardware")
|
2026-02-01 11:58:44 +01:00
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/hardware/{hardware_id}")
|
|
|
|
|
async def remove_case_hardware(sag_id: int, hardware_id: int):
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
"""Remove hardware from case."""
|
|
|
|
|
try:
|
|
|
|
|
query = "UPDATE sag_hardware SET deleted_at = NOW() WHERE sag_id = %s AND hardware_id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (sag_id, hardware_id))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Hardware %s removed from case %s", hardware_id, sag_id)
|
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
raise HTTPException(status_code=404, detail="Hardware link not found")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error removing case hardware: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to remove hardware")
|
2026-02-01 11:58:44 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# LOCATIONS - Placeholder endpoints for frontend compatibility
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/locations")
|
|
|
|
|
async def list_case_locations(sag_id: int):
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
"""List locations associated with a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT sl.id as relation_id, l.*
|
|
|
|
|
FROM sag_lokationer sl
|
|
|
|
|
JOIN locations_locations l ON sl.location_id = l.id
|
|
|
|
|
WHERE sl.sag_id = %s AND sl.deleted_at IS NULL
|
|
|
|
|
ORDER BY sl.created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing case locations: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list case locations")
|
2026-02-01 11:58:44 +01:00
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/locations")
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
async def add_case_location(sag_id: int, data: dict):
|
|
|
|
|
"""Add location to case."""
|
|
|
|
|
try:
|
|
|
|
|
if not data.get("location_id"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="location_id is required")
|
|
|
|
|
|
|
|
|
|
location_id = data.get("location_id")
|
|
|
|
|
|
|
|
|
|
# Check if already linked
|
|
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_lokationer WHERE sag_id = %s AND location_id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id, location_id)
|
|
|
|
|
)
|
|
|
|
|
if check:
|
|
|
|
|
return check[0] # Already linked
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_lokationer (sag_id, location_id, note)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
ON CONFLICT (sag_id, location_id)
|
|
|
|
|
DO UPDATE SET deleted_at = NULL, note = EXCLUDED.note
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, location_id, data.get("note")))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Location %s added to case %s", location_id, sag_id)
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add location")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error adding case location: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add location")
|
2026-02-01 11:58:44 +01:00
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/locations/{location_id}")
|
|
|
|
|
async def remove_case_location(sag_id: int, location_id: int):
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
"""Remove location from case."""
|
|
|
|
|
try:
|
2026-02-17 08:29:05 +01:00
|
|
|
query = """
|
|
|
|
|
UPDATE sag_lokationer
|
|
|
|
|
SET deleted_at = NOW()
|
|
|
|
|
WHERE sag_id = %s
|
|
|
|
|
AND deleted_at IS NULL
|
|
|
|
|
AND (location_id = %s OR id = %s)
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, location_id, location_id))
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Location %s removed from case %s", location_id, sag_id)
|
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
raise HTTPException(status_code=404, detail="Location link not found")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error removing case location: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to remove location")
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# VAREKØB & SALG - Aggregation (Case Tree)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/varekob-salg")
|
|
|
|
|
async def get_varekob_salg(sag_id: int, include_subcases: bool = True):
|
|
|
|
|
"""Aggregate time entries (and future sales items) for a case tree."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
if include_subcases:
|
|
|
|
|
case_tree_query = """
|
|
|
|
|
WITH RECURSIVE normalized_relations AS (
|
|
|
|
|
SELECT
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
|
|
|
|
|
ELSE kilde_sag_id
|
|
|
|
|
END AS parent_id,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
|
|
|
|
|
ELSE målsag_id
|
|
|
|
|
END AS child_id
|
|
|
|
|
FROM sag_relationer
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
),
|
|
|
|
|
case_tree AS (
|
|
|
|
|
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
UNION
|
|
|
|
|
SELECT nr.child_id
|
|
|
|
|
FROM normalized_relations nr
|
|
|
|
|
JOIN case_tree ct ON nr.parent_id = ct.id
|
|
|
|
|
)
|
|
|
|
|
SELECT s.id, s.titel
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
JOIN case_tree ct ON s.id = ct.id
|
|
|
|
|
WHERE s.deleted_at IS NULL
|
|
|
|
|
ORDER BY s.id
|
|
|
|
|
"""
|
|
|
|
|
case_tree = execute_query(case_tree_query, (sag_id,))
|
|
|
|
|
|
|
|
|
|
time_query = """
|
|
|
|
|
WITH RECURSIVE normalized_relations AS (
|
|
|
|
|
SELECT
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
|
|
|
|
|
ELSE kilde_sag_id
|
|
|
|
|
END AS parent_id,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
|
|
|
|
|
ELSE målsag_id
|
|
|
|
|
END AS child_id
|
|
|
|
|
FROM sag_relationer
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
),
|
|
|
|
|
case_tree AS (
|
|
|
|
|
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
UNION
|
|
|
|
|
SELECT nr.child_id
|
|
|
|
|
FROM normalized_relations nr
|
|
|
|
|
JOIN case_tree ct ON nr.parent_id = ct.id
|
|
|
|
|
)
|
|
|
|
|
SELECT t.*, s.titel AS source_sag_titel
|
|
|
|
|
FROM tmodule_times t
|
|
|
|
|
JOIN case_tree ct ON t.sag_id = ct.id
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
|
|
|
|
ORDER BY t.worked_date DESC NULLS LAST, t.id DESC
|
|
|
|
|
"""
|
|
|
|
|
time_entries = execute_query(time_query, (sag_id,))
|
|
|
|
|
|
|
|
|
|
sale_items_query = """
|
|
|
|
|
WITH RECURSIVE normalized_relations AS (
|
|
|
|
|
SELECT
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
|
|
|
|
|
ELSE kilde_sag_id
|
|
|
|
|
END AS parent_id,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
|
|
|
|
|
ELSE målsag_id
|
|
|
|
|
END AS child_id
|
|
|
|
|
FROM sag_relationer
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
),
|
|
|
|
|
case_tree AS (
|
|
|
|
|
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
UNION
|
|
|
|
|
SELECT nr.child_id
|
|
|
|
|
FROM normalized_relations nr
|
|
|
|
|
JOIN case_tree ct ON nr.parent_id = ct.id
|
|
|
|
|
)
|
|
|
|
|
SELECT si.*, s.titel AS source_sag_titel
|
|
|
|
|
FROM sag_salgsvarer si
|
|
|
|
|
JOIN case_tree ct ON si.sag_id = ct.id
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = si.sag_id
|
|
|
|
|
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
|
|
|
|
|
"""
|
|
|
|
|
sale_items = execute_query(sale_items_query, (sag_id,))
|
|
|
|
|
else:
|
|
|
|
|
case_tree = execute_query(
|
|
|
|
|
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id,)
|
|
|
|
|
)
|
|
|
|
|
time_query = """
|
|
|
|
|
SELECT t.*, s.titel AS source_sag_titel
|
|
|
|
|
FROM tmodule_times t
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
|
|
|
|
WHERE t.sag_id = %s
|
|
|
|
|
ORDER BY t.worked_date DESC NULLS LAST, t.id DESC
|
|
|
|
|
"""
|
|
|
|
|
time_entries = execute_query(time_query, (sag_id,))
|
|
|
|
|
|
|
|
|
|
sale_items_query = """
|
|
|
|
|
SELECT si.*, s.titel AS source_sag_titel
|
|
|
|
|
FROM sag_salgsvarer si
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = si.sag_id
|
|
|
|
|
WHERE si.sag_id = %s
|
|
|
|
|
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
|
|
|
|
|
"""
|
|
|
|
|
sale_items = execute_query(sale_items_query, (sag_id,))
|
|
|
|
|
|
|
|
|
|
total_entries = len(time_entries or [])
|
|
|
|
|
total_hours = 0
|
|
|
|
|
billable_hours = 0
|
|
|
|
|
|
|
|
|
|
sale_total = 0
|
|
|
|
|
purchase_total = 0
|
|
|
|
|
|
|
|
|
|
for entry in time_entries or []:
|
|
|
|
|
hours = entry.get("approved_hours") or entry.get("original_hours") or 0
|
|
|
|
|
total_hours += float(hours)
|
|
|
|
|
if entry.get("billable"):
|
|
|
|
|
billable_hours += float(hours)
|
|
|
|
|
|
|
|
|
|
for item in sale_items or []:
|
|
|
|
|
amount = item.get("amount") or 0
|
|
|
|
|
if (item.get("type") or "").lower() == "purchase":
|
|
|
|
|
purchase_total += float(amount)
|
|
|
|
|
else:
|
|
|
|
|
sale_total += float(amount)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"sag_id": sag_id,
|
|
|
|
|
"include_subcases": include_subcases,
|
|
|
|
|
"case_tree": case_tree or [],
|
|
|
|
|
"time_entries": time_entries or [],
|
|
|
|
|
"sale_items": sale_items or [],
|
|
|
|
|
"totals": {
|
|
|
|
|
"total_entries": total_entries,
|
|
|
|
|
"total_hours": round(total_hours, 2),
|
|
|
|
|
"billable_hours": round(billable_hours, 2),
|
|
|
|
|
"sale_total": round(sale_total, 2),
|
|
|
|
|
"purchase_total": round(purchase_total, 2),
|
|
|
|
|
"net_total": round(sale_total - purchase_total, 2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error aggregating case data: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to aggregate case data")
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/calendar-events")
|
|
|
|
|
async def get_case_calendar_events(sag_id: int, include_children: bool = True):
|
|
|
|
|
"""Return calendar events for a case and optionally its child cases."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
if include_children:
|
|
|
|
|
case_tree_query = """
|
|
|
|
|
WITH RECURSIVE normalized_relations AS (
|
|
|
|
|
SELECT
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
|
|
|
|
|
ELSE kilde_sag_id
|
|
|
|
|
END AS parent_id,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
|
|
|
|
|
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
|
|
|
|
|
ELSE målsag_id
|
|
|
|
|
END AS child_id
|
|
|
|
|
FROM sag_relationer
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
),
|
|
|
|
|
case_tree AS (
|
|
|
|
|
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
UNION
|
|
|
|
|
SELECT nr.child_id
|
|
|
|
|
FROM normalized_relations nr
|
|
|
|
|
JOIN case_tree ct ON nr.parent_id = ct.id
|
|
|
|
|
)
|
|
|
|
|
SELECT s.id, s.titel
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
JOIN case_tree ct ON s.id = ct.id
|
|
|
|
|
WHERE s.deleted_at IS NULL
|
|
|
|
|
ORDER BY s.id
|
|
|
|
|
"""
|
|
|
|
|
case_rows = execute_query(case_tree_query, (sag_id,)) or []
|
|
|
|
|
else:
|
|
|
|
|
case_rows = execute_query(
|
|
|
|
|
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
|
|
|
|
(sag_id,)
|
|
|
|
|
) or []
|
|
|
|
|
|
|
|
|
|
case_ids = [row["id"] for row in case_rows]
|
|
|
|
|
case_titles = {row["id"]: row.get("titel") for row in case_rows}
|
|
|
|
|
|
|
|
|
|
if not case_ids:
|
|
|
|
|
return {"current": [], "children": []}
|
|
|
|
|
|
|
|
|
|
placeholders = ",".join(["%s"] * len(case_ids))
|
|
|
|
|
|
|
|
|
|
reminder_query = f"""
|
|
|
|
|
SELECT r.id, r.sag_id, r.title, r.message, r.event_type, r.priority,
|
|
|
|
|
r.next_check_at, r.scheduled_at
|
|
|
|
|
FROM sag_reminders r
|
|
|
|
|
WHERE r.deleted_at IS NULL
|
|
|
|
|
AND r.is_active = true
|
|
|
|
|
AND r.sag_id IN ({placeholders})
|
|
|
|
|
"""
|
|
|
|
|
reminders = execute_query(reminder_query, tuple(case_ids)) or []
|
|
|
|
|
|
|
|
|
|
case_query = f"""
|
|
|
|
|
SELECT id, titel, deadline, deferred_until
|
|
|
|
|
FROM sag_sager
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
AND id IN ({placeholders})
|
|
|
|
|
"""
|
|
|
|
|
case_dates = execute_query(case_query, tuple(case_ids)) or []
|
|
|
|
|
|
|
|
|
|
events_by_case: dict[int, list] = {cid: [] for cid in case_ids}
|
|
|
|
|
|
|
|
|
|
for row in reminders:
|
|
|
|
|
start_value = row.get("next_check_at") or row.get("scheduled_at")
|
|
|
|
|
if not start_value:
|
|
|
|
|
continue
|
|
|
|
|
events_by_case[row["sag_id"]].append({
|
|
|
|
|
"id": f"reminder:{row['id']}",
|
|
|
|
|
"title": row.get("title"),
|
|
|
|
|
"message": row.get("message"),
|
|
|
|
|
"event_kind": row.get("event_type") or "reminder",
|
|
|
|
|
"start": start_value.isoformat(),
|
|
|
|
|
"url": f"/sag/{row['sag_id']}"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for row in case_dates:
|
|
|
|
|
if row.get("deadline"):
|
|
|
|
|
events_by_case[row["id"]].append({
|
|
|
|
|
"id": f"deadline:{row['id']}",
|
|
|
|
|
"title": f"Deadline: {row.get('titel')}",
|
|
|
|
|
"event_kind": "deadline",
|
|
|
|
|
"start": row["deadline"].isoformat(),
|
|
|
|
|
"url": f"/sag/{row['id']}"
|
|
|
|
|
})
|
|
|
|
|
if row.get("deferred_until"):
|
|
|
|
|
events_by_case[row["id"]].append({
|
|
|
|
|
"id": f"deferred:{row['id']}",
|
|
|
|
|
"title": f"Deferred: {row.get('titel')}",
|
|
|
|
|
"event_kind": "deferred",
|
|
|
|
|
"start": row["deferred_until"].isoformat(),
|
|
|
|
|
"url": f"/sag/{row['id']}"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
current_events = events_by_case.get(sag_id, [])
|
|
|
|
|
children = []
|
|
|
|
|
for cid in case_ids:
|
|
|
|
|
if cid == sag_id:
|
|
|
|
|
continue
|
|
|
|
|
children.append({
|
|
|
|
|
"case_id": cid,
|
|
|
|
|
"case_title": case_titles.get(cid) or f"Sag #{cid}",
|
|
|
|
|
"events": events_by_case.get(cid, [])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {"current": current_events, "children": children}
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error loading case calendar events: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to load calendar events")
|
|
|
|
|
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
# VAREKØB & SALG - CRUD (Case-linked sale items)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/sale-items")
|
|
|
|
|
async def list_sale_items(sag_id: int):
|
|
|
|
|
"""List sale items for a case."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
SELECT si.*, s.titel AS source_sag_titel
|
|
|
|
|
FROM sag_salgsvarer si
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = si.sag_id
|
|
|
|
|
WHERE si.sag_id = %s
|
|
|
|
|
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
|
|
|
|
|
"""
|
|
|
|
|
return execute_query(query, (sag_id,)) or []
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing sale items: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list sale items")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/sale-items")
|
|
|
|
|
async def create_sale_item(sag_id: int, data: dict):
|
|
|
|
|
"""Create a sale item for a case."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
description = data.get("description")
|
|
|
|
|
amount = data.get("amount")
|
|
|
|
|
if not description:
|
|
|
|
|
raise HTTPException(status_code=400, detail="description is required")
|
|
|
|
|
if amount is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail="amount is required")
|
|
|
|
|
|
|
|
|
|
item_type = (data.get("type") or "sale").lower()
|
|
|
|
|
if item_type not in ("sale", "purchase"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="type must be sale or purchase")
|
|
|
|
|
|
|
|
|
|
status = (data.get("status") or "draft").lower()
|
|
|
|
|
if status not in ("draft", "confirmed", "cancelled"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_salgsvarer
|
2026-02-08 12:42:19 +01:00
|
|
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
VALUES
|
2026-02-08 12:42:19 +01:00
|
|
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
sag_id,
|
|
|
|
|
item_type,
|
|
|
|
|
description,
|
|
|
|
|
data.get("quantity"),
|
|
|
|
|
data.get("unit"),
|
|
|
|
|
data.get("unit_price"),
|
|
|
|
|
amount,
|
|
|
|
|
data.get("currency", "DKK"),
|
|
|
|
|
status,
|
|
|
|
|
data.get("line_date"),
|
|
|
|
|
data.get("external_ref"),
|
2026-02-08 12:42:19 +01:00
|
|
|
data.get("product_id"),
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
)
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Sale item created for case %s", sag_id)
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create sale item")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error creating sale item: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create sale item")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/sale-items/{item_id}")
|
|
|
|
|
async def get_sale_item(sag_id: int, item_id: int):
|
|
|
|
|
"""Get a single sale item for a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT si.*, s.titel AS source_sag_titel
|
|
|
|
|
FROM sag_salgsvarer si
|
|
|
|
|
LEFT JOIN sag_sager s ON s.id = si.sag_id
|
|
|
|
|
WHERE si.sag_id = %s AND si.id = %s
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, item_id))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Sale item not found")
|
|
|
|
|
return result[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error getting sale item: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to get sale item")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
|
|
|
|
|
async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
|
|
|
|
"""Update a sale item for a case."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query(
|
|
|
|
|
"SELECT id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
|
|
|
|
|
(item_id, sag_id)
|
|
|
|
|
)
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Sale item not found")
|
|
|
|
|
|
|
|
|
|
allowed_fields = [
|
|
|
|
|
"type",
|
|
|
|
|
"description",
|
|
|
|
|
"quantity",
|
|
|
|
|
"unit",
|
|
|
|
|
"unit_price",
|
|
|
|
|
"amount",
|
|
|
|
|
"currency",
|
|
|
|
|
"status",
|
|
|
|
|
"line_date",
|
|
|
|
|
"external_ref",
|
2026-02-08 12:42:19 +01:00
|
|
|
"product_id",
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
set_clauses = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
for field in allowed_fields:
|
|
|
|
|
if field in updates:
|
|
|
|
|
if field == "type":
|
|
|
|
|
value = (updates[field] or "").lower()
|
|
|
|
|
if value not in ("sale", "purchase"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="type must be sale or purchase")
|
|
|
|
|
set_clauses.append("type = %s")
|
|
|
|
|
params.append(value)
|
|
|
|
|
elif field == "status":
|
|
|
|
|
value = (updates[field] or "").lower()
|
|
|
|
|
if value not in ("draft", "confirmed", "cancelled"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
|
|
|
|
set_clauses.append("status = %s")
|
|
|
|
|
params.append(value)
|
|
|
|
|
else:
|
|
|
|
|
set_clauses.append(f"{field} = %s")
|
|
|
|
|
params.append(updates[field])
|
|
|
|
|
|
|
|
|
|
if not set_clauses:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No valid fields to update")
|
|
|
|
|
|
|
|
|
|
params.extend([item_id, sag_id])
|
|
|
|
|
query = f"UPDATE sag_salgsvarer SET {', '.join(set_clauses)} WHERE id = %s AND sag_id = %s RETURNING *"
|
|
|
|
|
result = execute_query(query, tuple(params))
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Sale item updated: %s", item_id)
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update sale item")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error updating sale item: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to update sale item")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/sale-items/{item_id}")
|
|
|
|
|
async def delete_sale_item(sag_id: int, item_id: int):
|
|
|
|
|
"""Delete a sale item for a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = "DELETE FROM sag_salgsvarer WHERE id = %s AND sag_id = %s RETURNING id"
|
|
|
|
|
result = execute_query(query, (item_id, sag_id))
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Sale item deleted: %s", item_id)
|
|
|
|
|
return {"status": "deleted", "id": item_id}
|
|
|
|
|
raise HTTPException(status_code=404, detail="Sale item not found")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error deleting sale item: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to delete sale item")
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# KOMMENTARER - Case Comments
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/kommentarer")
|
|
|
|
|
async def get_kommentarer(sag_id: int):
|
|
|
|
|
"""Get all comments for a case."""
|
|
|
|
|
try:
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
|
|
|
|
|
result = execute_query(query, (sag_id,))
|
|
|
|
|
return result
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error getting comments: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to get comments")
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/kommentarer")
|
|
|
|
|
async def add_kommentar(sag_id: int, data: dict):
|
|
|
|
|
"""Add a comment to a case."""
|
|
|
|
|
try:
|
|
|
|
|
if not data.get("indhold"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="indhold is required")
|
|
|
|
|
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
# Default author to current user or provided in body (if system)
|
|
|
|
|
# simplistic auth for now
|
|
|
|
|
forfatter = data.get("forfatter", "Bruger")
|
|
|
|
|
er_system_besked = data.get("er_system_besked", False)
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
|
|
|
|
VALUES (%s, %s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("✅ Comment added to case %s by %s", sag_id, forfatter)
|
|
|
|
|
return result[0]
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add comment")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error adding comment: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to add comment")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# FILES - Case Files
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve()
|
|
|
|
|
SAG_FILE_SUBDIR = "sag_files"
|
|
|
|
|
(UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
ALLOWED_EXTENSIONS = {ext.lower() for ext in settings.ALLOWED_EXTENSIONS}
|
|
|
|
|
MAX_ATTACHMENT_SIZE = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
def _generate_stored_name(filename: str, subdir: str) -> str:
|
|
|
|
|
cleaned = Path(filename).name
|
|
|
|
|
unique = f"{uuid4().hex}_{cleaned}"
|
|
|
|
|
return f"{subdir}/{unique}"
|
|
|
|
|
|
|
|
|
|
def _resolve_attachment_path(stored_name: str) -> Path:
|
|
|
|
|
return UPLOAD_BASE_PATH / stored_name
|
|
|
|
|
|
|
|
|
|
def _store_upload_file(upload_file: UploadFile, subdir: str):
|
|
|
|
|
if not upload_file.filename:
|
|
|
|
|
raise HTTPException(400, detail="Filename missing")
|
|
|
|
|
|
|
|
|
|
ext = Path(upload_file.filename).suffix.lower().lstrip(".")
|
|
|
|
|
# Basic check - allow more types for generic files?
|
|
|
|
|
# if ext not in ALLOWED_EXTENSIONS: ...
|
|
|
|
|
|
|
|
|
|
upload_file.file.seek(0, os.SEEK_END)
|
|
|
|
|
size = upload_file.file.tell()
|
|
|
|
|
upload_file.file.seek(0)
|
|
|
|
|
|
|
|
|
|
if size > MAX_ATTACHMENT_SIZE:
|
|
|
|
|
raise HTTPException(400, detail=f"File too large (> {settings.EMAIL_MAX_UPLOAD_SIZE_MB}MB)")
|
|
|
|
|
|
|
|
|
|
stored_name = _generate_stored_name(upload_file.filename, subdir)
|
|
|
|
|
destination = _resolve_attachment_path(stored_name)
|
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with destination.open("wb") as buffer:
|
|
|
|
|
shutil.copyfileobj(upload_file.file, buffer)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Upload failed: {e}")
|
|
|
|
|
raise HTTPException(500, detail="Server upload failed")
|
|
|
|
|
|
|
|
|
|
return stored_name, size
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/files")
|
|
|
|
|
async def list_sag_files(sag_id: int):
|
|
|
|
|
"""List files attached to a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM sag_files
|
|
|
|
|
WHERE sag_id = %s
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
files = execute_query(query, (sag_id,))
|
|
|
|
|
# Add download URL
|
|
|
|
|
if files:
|
|
|
|
|
for f in files:
|
|
|
|
|
f["download_url"] = f"/api/v1/sag/{sag_id}/files/{f['id']}"
|
|
|
|
|
return files or []
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("❌ Error listing files: %s", e)
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to list files")
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/files")
|
|
|
|
|
async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
|
|
|
|
"""Upload files to a case."""
|
|
|
|
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
|
|
|
|
if not check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case not found")
|
|
|
|
|
|
|
|
|
|
saved_files = []
|
|
|
|
|
|
|
|
|
|
for file in files:
|
|
|
|
|
try:
|
|
|
|
|
stored_name, size = _store_upload_file(file, SAG_FILE_SUBDIR)
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING id, filename, created_at
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (sag_id, file.filename, file.content_type, size, stored_name))
|
|
|
|
|
if result:
|
|
|
|
|
saved = result[0]
|
|
|
|
|
saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}"
|
|
|
|
|
saved_files.append(saved)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
continue # Skip invalid
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error saving file {file.filename}: {e}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
return saved_files
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/files/{file_id}")
|
2026-02-06 10:47:14 +01:00
|
|
|
async def download_sag_file(sag_id: int, file_id: int, download: bool = False):
|
|
|
|
|
"""Download or preview a specific file.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
download: If True, force download. If False (default), display inline in browser.
|
|
|
|
|
"""
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s"
|
|
|
|
|
result = execute_query(query, (file_id, sag_id))
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
|
|
|
|
|
|
file_data = result[0]
|
|
|
|
|
path = _resolve_attachment_path(file_data["stored_name"])
|
|
|
|
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise HTTPException(status_code=404, detail="File lost on server")
|
2026-02-06 10:47:14 +01:00
|
|
|
|
|
|
|
|
# Determine content disposition
|
|
|
|
|
headers = {}
|
|
|
|
|
if download:
|
|
|
|
|
headers["Content-Disposition"] = f'attachment; filename="{file_data["filename"]}"'
|
|
|
|
|
else:
|
|
|
|
|
headers["Content-Disposition"] = f'inline; filename="{file_data["filename"]}"'
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
|
|
|
|
|
return FileResponse(
|
|
|
|
|
path=path,
|
|
|
|
|
filename=file_data["filename"],
|
2026-02-06 10:47:14 +01:00
|
|
|
media_type=file_data.get("content_type", "application/octet-stream"),
|
|
|
|
|
headers=headers
|
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/files/{file_id}")
|
|
|
|
|
async def delete_sag_file(sag_id: int, file_id: int):
|
|
|
|
|
"""Delete a file."""
|
|
|
|
|
query = "DELETE FROM sag_files WHERE id = %s AND sag_id = %s RETURNING stored_name"
|
|
|
|
|
result = execute_query(query, (file_id, sag_id))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
# Clean up disk
|
|
|
|
|
path = _resolve_attachment_path(result[0]["stored_name"])
|
|
|
|
|
if path.exists():
|
|
|
|
|
try:
|
|
|
|
|
os.remove(path)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# EMAILS - Case Emails (Linked)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/email-links")
|
|
|
|
|
async def add_sag_email_link(sag_id: int, payload: dict):
|
|
|
|
|
"""Link an existing email to a case."""
|
|
|
|
|
email_id = payload.get("email_id")
|
|
|
|
|
if not email_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="email_id required")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_emails (sag_id, email_id)
|
|
|
|
|
VALUES (%s, %s)
|
|
|
|
|
ON CONFLICT DO NOTHING
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
execute_query(query, (sag_id, email_id))
|
|
|
|
|
return {"status": "linked"}
|
|
|
|
|
|
|
|
|
|
@router.get("/sag/{sag_id}/email-links")
|
|
|
|
|
async def get_sag_emails(sag_id: int):
|
|
|
|
|
"""Get emails linked to a case."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT e.*
|
|
|
|
|
FROM email_messages e
|
|
|
|
|
JOIN sag_emails se ON e.id = se.email_id
|
|
|
|
|
WHERE se.sag_id = %s
|
|
|
|
|
ORDER BY e.received_date DESC
|
|
|
|
|
"""
|
|
|
|
|
return execute_query(query, (sag_id,)) or []
|
|
|
|
|
|
|
|
|
|
@router.delete("/sag/{sag_id}/email-links/{email_id}")
|
|
|
|
|
async def remove_sag_email_link(sag_id: int, email_id: int):
|
|
|
|
|
"""Unlink an email."""
|
|
|
|
|
query = "DELETE FROM sag_emails WHERE sag_id = %s AND email_id = %s"
|
|
|
|
|
execute_query(query, (sag_id, email_id))
|
|
|
|
|
return {"status": "unlinked"}
|
|
|
|
|
|
|
|
|
|
def _decode_header_str(header_val):
|
|
|
|
|
if not header_val: return ""
|
|
|
|
|
try:
|
|
|
|
|
decoded_list = decode_header(header_val)
|
|
|
|
|
result = ""
|
|
|
|
|
for content, encoding in decoded_list:
|
|
|
|
|
if isinstance(content, bytes):
|
|
|
|
|
if encoding:
|
|
|
|
|
try:
|
|
|
|
|
result += content.decode(encoding)
|
|
|
|
|
except:
|
|
|
|
|
result += content.decode('utf-8', errors='ignore')
|
|
|
|
|
else:
|
|
|
|
|
result += content.decode('utf-8', errors='ignore')
|
|
|
|
|
else:
|
|
|
|
|
result += str(content)
|
|
|
|
|
return result
|
|
|
|
|
except:
|
|
|
|
|
return str(header_val)
|
|
|
|
|
|
|
|
|
|
@router.post("/sag/{sag_id}/upload-email")
|
|
|
|
|
async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
|
|
|
|
"""Upload .eml/.msg, parse it, save to email_messages, and link to case."""
|
|
|
|
|
content = await file.read()
|
|
|
|
|
filename = file.filename.lower()
|
|
|
|
|
email_data = {}
|
|
|
|
|
temp_id = uuid4().hex
|
|
|
|
|
|
|
|
|
|
# 1. Parse File
|
|
|
|
|
if filename.endswith('.msg'):
|
|
|
|
|
if not extract_msg: raise HTTPException(500, "extract-msg missing")
|
|
|
|
|
import io
|
|
|
|
|
msg = extract_msg.Message(io.BytesIO(content))
|
|
|
|
|
email_data = {
|
|
|
|
|
'message_id': msg.messageId or f"msg-{temp_id}",
|
|
|
|
|
'subject': msg.subject or "No Subject",
|
|
|
|
|
'sender_email': msg.sender or "",
|
|
|
|
|
'sender_name': msg.sender or "",
|
|
|
|
|
'recipient_email': msg.to or "",
|
|
|
|
|
'cc': msg.cc or "",
|
|
|
|
|
'body_text': msg.body,
|
|
|
|
|
'body_html': msg.htmlBody,
|
|
|
|
|
'received_date': msg.date or datetime.now(),
|
|
|
|
|
'folder': 'Imported',
|
|
|
|
|
'attachments': [],
|
|
|
|
|
'has_attachments': False,
|
|
|
|
|
'attachment_count': 0
|
|
|
|
|
}
|
|
|
|
|
elif filename.endswith('.eml'):
|
|
|
|
|
msg = email.message_from_bytes(content)
|
|
|
|
|
body_text = ""
|
|
|
|
|
if msg.is_multipart():
|
|
|
|
|
for part in msg.walk():
|
|
|
|
|
if part.get_content_type() == "text/plain":
|
|
|
|
|
body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
|
|
|
|
|
|
|
|
email_data = {
|
|
|
|
|
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
|
|
|
|
|
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
|
|
|
|
|
'sender_email': _decode_header_str(msg.get('From', '')),
|
|
|
|
|
'sender_name': _decode_header_str(msg.get('From', '')),
|
|
|
|
|
'recipient_email': _decode_header_str(msg.get('To', '')),
|
|
|
|
|
'cc': _decode_header_str(msg.get('Cc', '')),
|
|
|
|
|
'received_date': datetime.now(),
|
|
|
|
|
'body_text': body_text,
|
|
|
|
|
'body_html': "",
|
|
|
|
|
'folder': 'Imported',
|
|
|
|
|
'has_attachments': False,
|
|
|
|
|
'attachment_count': 0,
|
|
|
|
|
'attachments': []
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
raise HTTPException(400, "Only .msg or .eml files allowed")
|
|
|
|
|
|
|
|
|
|
# 2. Save Email via Service (deduplicate)
|
|
|
|
|
svc = EmailService()
|
|
|
|
|
existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],))
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
email_id = existing['id']
|
|
|
|
|
else:
|
|
|
|
|
# We try to strict save, keeping it simple
|
|
|
|
|
email_id = await svc.save_email(email_data)
|
|
|
|
|
|
|
|
|
|
# 3. Link
|
|
|
|
|
await add_sag_email_link(sag_id, {"email_id": email_id})
|
|
|
|
|
return {"status": "imported", "email_id": email_id}
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# SOLUTIONS
|
|
|
|
|
# ============================================================================
|
|
|
|
|
from . import solutions
|
|
|
|
|
router.include_router(solutions.router)
|