Compare commits

..

6 Commits

Author SHA1 Message Date
Christian
3452472ba9 Add migrations for recent cases, time tracking pause/resume, and user notes
- Created `sag_recent_cases` table to persist recently opened cases per user for quick access in the bottom bar.
- Added pause/resume support in `tmodule_times` by introducing `paused_at` and `pause_total_seconds` columns.
- Established `user_notes` table for personal user notes with indexing for active and updated notes, along with a trigger to update the `updated_at` timestamp on modifications.

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 11:28:12 +02:00
Christian
ca6640c33c feat: Enhance case detail view with tab count badges and importance bubbles 2026-04-23 23:42:31 +02:00
Christian
fcc7192015 feat: Add rental statistics and pricing tabs to hardware detail view 2026-04-21 18:59:30 +02:00
Christian
4a52bdb5d6 feat: Implement quick-rent functionality for hardware assets
- Added QuickRentCreateInput model to handle quick-rent requests.
- Introduced quick_rent_preview endpoint to check existing subscriptions.
- Created quick_rent_hardware endpoint to manage rental subscriptions, asset bindings, and startup order drafts.
- Updated SQL queries to ensure proper data retrieval and handling.
- Added default rental price columns to hardware_assets table via migration.
- Enhanced UI in sag templates for better user experience and accessibility.
- Refactored existing code for improved readability and maintainability.
2026-04-21 01:34:40 +02:00
Christian
8e8616c835 feat: Enhance vendor and customer linking functionality
- Added endpoints to link and unlink customers to vendors, including validation for relationship types.
- Implemented a UI for managing linked customers in the vendor detail view.
- Introduced a search feature for customers when linking to vendors.
- Updated database schema to support customer-vendor relationships with necessary constraints and indices.
- Added migration scripts for new tables and fields related to supplier invoices and customer-vendor links.
- Modified bottom bar visibility in the frontend for improved user experience.
2026-04-15 09:34:26 +02:00
Christian
13dc1736b4 feat: Implement supplier invoice case traceability and purchase line classification 2026-04-12 09:26:35 +02:00
41 changed files with 8492 additions and 506 deletions

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,42 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _ensure_customer_supplier_tag(customer_id: int) -> None:
"""Ensure linked customers are tagged as suppliers."""
try:
tag = execute_query_single(
"SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1"
)
if tag and tag.get("id") is not None:
tag_id = int(tag["id"])
else:
created = execute_query_single(
"""
INSERT INTO tags (name, type, description, color, is_active)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, type)
DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
RETURNING id
""",
("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True),
)
tag_id = int(created["id"]) if created and created.get("id") is not None else None
if not tag_id:
return
execute_query(
"""
INSERT INTO entity_tags (entity_type, entity_id, tag_id)
VALUES (%s, %s, %s)
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
""",
("customer", customer_id, tag_id),
)
except Exception as tag_error:
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
# Pydantic Models # Pydantic Models
class CustomerBase(BaseModel): class CustomerBase(BaseModel):
name: str name: str
@ -517,6 +553,78 @@ async def get_customer_utility_company(customer_id: int):
"supplier": supplier "supplier": supplier
} }
@router.get("/customers/{customer_id}/vendors")
async def list_customer_vendors(customer_id: int):
"""List vendors linked to a customer."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
rows = execute_query(
"""
SELECT
l.id,
l.customer_id,
l.vendor_id,
l.relationship_type,
l.created_at,
l.updated_at,
v.name AS vendor_name,
v.email AS vendor_email,
v.cvr_number AS vendor_cvr
FROM customer_vendor_links l
JOIN vendors v ON v.id = l.vendor_id
WHERE l.customer_id = %s
ORDER BY v.name ASC, l.id ASC
""",
(customer_id,),
) or []
return rows
@router.post("/customers/{customer_id}/vendors/{vendor_id}")
async def link_customer_to_vendor(customer_id: int, vendor_id: int, relationship_type: str = Query("supplier")):
"""Create or update a customer-vendor link."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
rel = str(relationship_type or "supplier").strip().lower()
if rel not in {"supplier", "reseller", "partner"}:
raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
row = execute_query_single(
"""
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
VALUES (%s, %s, %s)
ON CONFLICT (customer_id, vendor_id)
DO UPDATE SET
relationship_type = EXCLUDED.relationship_type,
updated_at = CURRENT_TIMESTAMP
RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
""",
(customer_id, vendor_id, rel),
)
_ensure_customer_supplier_tag(int(customer_id))
return row
@router.delete("/customers/{customer_id}/vendors/{vendor_id}")
async def unlink_customer_from_vendor(customer_id: int, vendor_id: int):
"""Remove customer-vendor link."""
deleted = execute_update(
"DELETE FROM customer_vendor_links WHERE customer_id = %s AND vendor_id = %s",
(customer_id, vendor_id),
)
if not deleted:
raise HTTPException(status_code=404, detail="Link not found")
return {"success": True, "customer_id": customer_id, "vendor_id": vendor_id}
@router.post("/customers") @router.post("/customers")
async def create_customer(customer: CustomerCreate): async def create_customer(customer: CustomerCreate):
"""Create a new customer""" """Create a new customer"""
@ -1096,7 +1204,69 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
try: try:
# Create contact normalized_email = (contact.email or "").strip().lower() or None
existing_contact = None
# Prefer exact email match scoped to this customer, then global email match.
if normalized_email:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND LOWER(COALESCE(c.email, '')) = %s
ORDER BY c.id ASC
LIMIT 1
""",
(customer_id, normalized_email),
)
if not existing_contact:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
WHERE LOWER(COALESCE(c.email, '')) = %s
ORDER BY c.id ASC
LIMIT 1
""",
(normalized_email,),
)
# Fallback dedupe by full name within same customer when email is missing.
if not existing_contact and not normalized_email:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s)
AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s)
ORDER BY c.id ASC
LIMIT 1
""",
(customer_id, contact.first_name, contact.last_name),
)
if existing_contact:
contact_id = int(existing_contact["id"])
execute_update(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (contact_id, customer_id)
DO UPDATE SET
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
role = COALESCE(contact_companies.role, EXCLUDED.role)
""",
(contact_id, customer_id, bool(contact.is_primary), contact.role),
)
logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id)
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
# Create contact when no reusable match exists.
contact_id = execute_insert( contact_id = execute_insert(
"""INSERT INTO contacts """INSERT INTO contacts
(first_name, last_name, email, phone, mobile, title, department) (first_name, last_name, email, phone, mobile, title, department)
@ -1105,7 +1275,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
( (
contact.first_name, contact.first_name,
contact.last_name, contact.last_name,
contact.email, normalized_email,
contact.phone, contact.phone,
contact.mobile, contact.mobile,
contact.title, contact.title,
@ -1114,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
) )
# Link contact to customer # Link contact to customer
execute_insert( execute_update(
"""INSERT INTO contact_companies """INSERT INTO contact_companies
(contact_id, customer_id, is_primary, role) (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)""", VALUES (%s, %s, %s, %s)
(contact_id, customer_id, contact.is_primary, contact.role) ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, customer_id, bool(contact.is_primary), contact.role)
) )
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}") logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")

View File

@ -498,6 +498,32 @@
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div> <div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
</div> </div>
</div> </div>
<div class="col-12">
<div class="info-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0">Leverandørrelationer</h5>
<span class="badge bg-primary" id="customerVendorsCount">0</span>
</div>
<div class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
class="form-control"
id="customerVendorSearch"
placeholder="Søg leverandør (navn, CVR, domæne)"
oninput="searchVendorsForCustomer(this.value)"
>
</div>
<div class="col-md-4 text-md-end">
<small class="text-muted">Knyt kunde til leverandør</small>
</div>
</div>
<div id="customerVendorSearchResults" class="list-group mb-3" style="display:none;"></div>
<div id="customerVendorLinksContainer" class="list-group mb-2"></div>
<div id="customerVendorLinksEmpty" class="text-muted small">Ingen linked leverandører endnu.</div>
</div>
</div>
</div> </div>
</div> </div>
@ -1423,6 +1449,7 @@ async function loadCustomer() {
await loadUtilityCompany(); await loadUtilityCompany();
await loadCustomerTags(); await loadCustomerTags();
await loadCustomerVendorLinks();
// Check data consistency // Check data consistency
await checkDataConsistency(); await checkDataConsistency();
@ -1433,6 +1460,141 @@ async function loadCustomer() {
} }
} }
async function loadCustomerVendorLinks() {
const container = document.getElementById('customerVendorLinksContainer');
const empty = document.getElementById('customerVendorLinksEmpty');
const countEl = document.getElementById('customerVendorsCount');
if (!container || !empty || !countEl) return;
try {
const response = await fetch(`/api/v1/customers/${customerId}/vendors`);
if (!response.ok) throw new Error('Kunne ikke hente leverandørlinks');
const links = await response.json();
const rows = Array.isArray(links) ? links : [];
countEl.textContent = String(rows.length);
if (!rows.length) {
container.innerHTML = '';
empty.classList.remove('d-none');
return;
}
empty.classList.add('d-none');
container.innerHTML = rows.map((row) => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}</div>
<div class="small text-muted">
${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''}
${row.vendor_email ? escapeHtml(row.vendor_email) : '-'}
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
<a class="btn btn-sm btn-outline-primary" href="/vendors/${row.vendor_id}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="unlinkVendorFromCustomer(${row.vendor_id})">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load customer vendor links:', error);
container.innerHTML = '';
empty.classList.remove('d-none');
}
}
let vendorSearchDebounce = null;
async function searchVendorsForCustomer(query) {
const resultsEl = document.getElementById('customerVendorSearchResults');
if (!resultsEl) return;
const q = String(query || '').trim();
if (!q) {
resultsEl.style.display = 'none';
resultsEl.innerHTML = '';
return;
}
if (vendorSearchDebounce) window.clearTimeout(vendorSearchDebounce);
vendorSearchDebounce = window.setTimeout(async () => {
try {
const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(q)}&limit=10`);
if (!response.ok) throw new Error('Søgning fejlede');
const vendors = await response.json();
const rows = Array.isArray(vendors) ? vendors : [];
if (!rows.length) {
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen leverandører fundet</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = rows.map((v) => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(v.name || '')}</div>
<div class="small text-muted">${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}</div>
</div>
<button class="btn btn-sm btn-primary" onclick="linkVendorToCustomerFromUI(${v.id})">
<i class="bi bi-link-45deg me-1"></i>Link
</button>
</div>
`).join('');
resultsEl.style.display = 'block';
} catch (error) {
console.error('Vendor search failed:', error);
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
resultsEl.style.display = 'block';
}
}, 220);
}
async function linkVendorToCustomerFromUI(vendorId) {
try {
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}?relationship_type=supplier`, {
method: 'POST'
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke linke leverandør');
}
const input = document.getElementById('customerVendorSearch');
const results = document.getElementById('customerVendorSearchResults');
if (input) input.value = '';
if (results) {
results.innerHTML = '';
results.style.display = 'none';
}
await loadCustomerVendorLinks();
await loadCustomerTags();
} catch (error) {
alert(error.message || 'Kunne ikke linke leverandør');
}
}
async function unlinkVendorFromCustomer(vendorId) {
if (!confirm('Fjern link mellem kunde og leverandør?')) return;
try {
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}`, {
method: 'DELETE'
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke fjerne link');
}
await loadCustomerVendorLinks();
} catch (error) {
alert(error.message || 'Kunne ikke fjerne link');
}
}
function displayCustomer(customer) { function displayCustomer(customer) {
// Update page title // Update page title
document.title = `${customer.name} - BMC Hub`; document.title = `${customer.name} - BMC Hub`;

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
import unicodedata
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.services.email_processor_service import EmailProcessorService from app.services.email_processor_service import EmailProcessorService
@ -20,6 +21,218 @@ router = APIRouter()
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"} ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
IGNORED_SENDER_DOMAINS = {
"bmcnetworks.dk",
"bmchub.local",
"outlook.com",
"hotmail.com",
"gmail.com",
"icloud.com",
"yahoo.com",
"live.com",
}
def _normalize_domain(value: Optional[str]) -> str:
domain = str(value or "").strip().lower()
if not domain:
return ""
if domain.startswith("www."):
return domain[4:]
return domain
def _extract_sender_domain(sender_email: Optional[str]) -> str:
sender = str(sender_email or "").strip().lower()
if "@" not in sender:
return ""
return _normalize_domain(sender.split("@", 1)[1])
def _is_ignored_sender_domain(domain: str) -> bool:
if not domain:
return True
return domain in IGNORED_SENDER_DOMAINS or "bmc" in domain
def _upsert_domain_mapping(domain: str, customer_id: int, source: str = "manual") -> None:
if not domain or not customer_id:
return
try:
execute_update(
"""
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
VALUES (%s, %s, %s)
ON CONFLICT (domain)
DO UPDATE SET
customer_id = EXCLUDED.customer_id,
source = EXCLUDED.source,
updated_at = CURRENT_TIMESTAMP
""",
(domain, customer_id, source),
)
except Exception as e:
# Keep linking flow operational even if mapping table is not migrated yet.
logger.warning("⚠️ Could not upsert domain mapping for %s: %s", domain, e)
def _resolve_procurement_customer_id() -> Optional[int]:
"""Resolve a fallback customer for supplier/procurement case creation."""
bmc_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE is_active = true
AND LOWER(name) LIKE %s
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id
LIMIT 1
""",
("%bmc%", "%bmc networks%")
)
if bmc_row:
return int(bmc_row["id"])
fallback = execute_query_single(
"SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1"
)
if fallback:
return int(fallback["id"])
return None
def _normalize_case_type(value: Optional[str]) -> str:
raw = str(value or "").strip().lower()
if not raw:
return "support"
normalized = unicodedata.normalize("NFKD", raw)
ascii_value = normalized.encode("ascii", "ignore").decode("ascii").strip().lower()
return ascii_value or raw
def _is_supplier_case_type(case_type: Optional[str]) -> bool:
value = _normalize_case_type(case_type)
if value in {
"indkob",
"indkoeb",
"supplier",
"leverandor",
"leverandoer",
"vendor",
"procurement",
"purchase",
}:
return True
return "indk" in value or "leverand" in value or "supplier" in value
def _extract_domain_from_email(email: Optional[str]) -> str:
sender = str(email or "").strip().lower()
if "@" not in sender:
return ""
return _normalize_domain(sender.split("@", 1)[1])
def _find_customer_for_vendor(vendor: Dict) -> Optional[int]:
cvr = str(vendor.get("cvr_number") or "").strip()
if cvr:
row = execute_query_single(
"SELECT id FROM customers WHERE cvr_number = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
(cvr,),
)
if row:
return int(row["id"])
email = str(vendor.get("email") or "").strip().lower()
if email:
row = execute_query_single(
"SELECT id FROM customers WHERE LOWER(TRIM(email)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
(email,),
)
if row:
return int(row["id"])
domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email")))
if domain:
row = execute_query_single(
"""
SELECT id
FROM customers
WHERE COALESCE(is_active, true) = true
AND (
LOWER(TRIM(COALESCE(email_domain, ''))) = %s
OR LOWER(TRIM(COALESCE(email_domain, ''))) = %s
)
ORDER BY id
LIMIT 1
""",
(domain, f"www.{domain}"),
)
if row:
return int(row["id"])
name = str(vendor.get("name") or "").strip().lower()
if name:
row = execute_query_single(
"SELECT id FROM customers WHERE LOWER(TRIM(name)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
(name,),
)
if row:
return int(row["id"])
return None
def _ensure_customer_from_vendor(vendor_id: Optional[int]) -> Optional[int]:
if not vendor_id:
return None
vendor = execute_query_single(
"""
SELECT id, name, email, phone, address, cvr_number, domain, city, postal_code, country, website
FROM vendors
WHERE id = %s AND is_active = true
""",
(vendor_id,),
)
if not vendor:
return None
existing_customer_id = _find_customer_for_vendor(vendor)
if existing_customer_id:
return existing_customer_id
name = str(vendor.get("name") or "").strip()
if not name:
return None
domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email"))) or None
try:
created_id = execute_insert(
"""
INSERT INTO customers (name, email, phone, address, cvr_number, email_domain, city, postal_code, country, website, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
RETURNING id
""",
(
name,
vendor.get("email"),
vendor.get("phone"),
vendor.get("address"),
vendor.get("cvr_number"),
domain,
vendor.get("city"),
vendor.get("postal_code"),
vendor.get("country") or "DK",
vendor.get("website"),
),
)
return int(created_id)
except Exception:
# Handle potential race/unique conflict by resolving again.
return _find_customer_for_vendor(vendor)
# Pydantic Models # Pydantic Models
class EmailListItem(BaseModel): class EmailListItem(BaseModel):
@ -225,6 +438,12 @@ class RewriteEmailTextResponse(BaseModel):
context: Optional[str] = None context: Optional[str] = None
class DomainMappingUpsertRequest(BaseModel):
domain: str
customer_id: int
source: Optional[str] = "manual"
@router.get("/emails/sag-options") @router.get("/emails/sag-options")
async def get_sag_assignment_options(): async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI.""" """Return users and groups for SAG assignment controls in email UI."""
@ -294,6 +513,222 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/{email_id}/domain-customer-suggestion")
async def get_domain_customer_suggestion(email_id: int):
"""Suggest customer based on sender domain for mails without known contact/customer."""
try:
email_row = execute_query_single(
"SELECT id, sender_email, customer_id FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not email_row:
raise HTTPException(status_code=404, detail="Email not found")
if email_row.get("customer_id"):
return {
"email_id": email_id,
"domain": None,
"has_customer": True,
"ignored": False,
"suggestion": None,
}
sender_email = str(email_row.get("sender_email") or "").strip().lower()
if sender_email:
contact_match = execute_query_single(
"""
SELECT
c.id,
c.name,
c.email_domain,
c.cvr_number,
ct.id AS contact_id,
ct.first_name,
ct.last_name
FROM contacts ct
JOIN contact_companies cc ON cc.contact_id = ct.id
JOIN customers c ON c.id = cc.customer_id
WHERE c.is_active = true
AND LOWER(TRIM(COALESCE(ct.email, ''))) = %s
ORDER BY cc.is_primary DESC, c.id ASC
LIMIT 1
""",
(sender_email,),
)
if contact_match:
contact_name = " ".join(
part for part in [contact_match.get("first_name"), contact_match.get("last_name")] if part
).strip()
return {
"email_id": email_id,
"domain": _extract_sender_domain(sender_email),
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": contact_match["id"],
"customer_name": contact_match["name"],
"email_domain": contact_match.get("email_domain"),
"cvr_number": contact_match.get("cvr_number"),
"confidence": "high",
"score": 110,
"source": f"contact_email:{contact_name or sender_email}",
},
}
sender_domain = _extract_sender_domain(email_row.get("sender_email"))
if not sender_domain:
return {
"email_id": email_id,
"domain": None,
"has_customer": False,
"ignored": True,
"reason": "no_domain",
"suggestion": None,
}
if _is_ignored_sender_domain(sender_domain):
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": True,
"reason": "ignored_domain",
"suggestion": None,
}
mapped = execute_query_single(
"""
SELECT c.id, c.name, c.email_domain, c.cvr_number, m.source
FROM email_domain_customer_mappings m
JOIN customers c ON c.id = m.customer_id
WHERE m.domain = %s
AND c.is_active = true
LIMIT 1
""",
(sender_domain,),
)
if mapped:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": mapped["id"],
"customer_name": mapped["name"],
"email_domain": mapped.get("email_domain"),
"cvr_number": mapped.get("cvr_number"),
"confidence": "high",
"score": 100,
"source": f"mapping:{mapped.get('source') or 'manual'}",
},
}
exact = execute_query_single(
"""
SELECT id, name, email_domain, cvr_number
FROM customers
WHERE is_active = true
AND (
LOWER(TRIM(email_domain)) = %s
OR LOWER(TRIM(email_domain)) = %s
)
ORDER BY id ASC
LIMIT 1
""",
(sender_domain, f"www.{sender_domain}"),
)
if exact:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": exact["id"],
"customer_name": exact["name"],
"email_domain": exact.get("email_domain"),
"cvr_number": exact.get("cvr_number"),
"confidence": "high",
"score": 95,
"source": "exact_domain",
},
}
partial = execute_query_single(
"""
SELECT id, name, email_domain, cvr_number
FROM customers
WHERE is_active = true
AND COALESCE(email_domain, '') ILIKE %s
ORDER BY name ASC
LIMIT 1
""",
(f"%{sender_domain}%",),
)
if partial:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": partial["id"],
"customer_name": partial["name"],
"email_domain": partial.get("email_domain"),
"cvr_number": partial.get("cvr_number"),
"confidence": "medium",
"score": 70,
"source": "partial_domain",
},
}
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": None,
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error getting domain customer suggestion: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/domain-customer-mapping")
async def upsert_domain_customer_mapping(payload: DomainMappingUpsertRequest):
"""Persist trusted mapping from sender domain to customer."""
try:
domain = _normalize_domain(payload.domain)
if not domain:
raise HTTPException(status_code=400, detail="domain is required")
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s AND is_active = true",
(payload.customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
_upsert_domain_mapping(domain, int(payload.customer_id), payload.source or "manual")
return {
"success": True,
"domain": domain,
"customer_id": int(payload.customer_id),
"source": payload.source or "manual",
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error upserting domain mapping: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse) @router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
async def rewrite_email_text(request: RewriteEmailTextRequest): async def rewrite_email_text(request: RewriteEmailTextRequest):
"""Rewrite email/case text via Ollama using the text_rewrite prompt.""" """Rewrite email/case text via Ollama using the text_rewrite prompt."""
@ -608,6 +1043,16 @@ async def link_email(email_id: int, payload: Dict):
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s" query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
execute_update(query, tuple(params)) execute_update(query, tuple(params))
customer_id = payload.get('customer_id')
if customer_id:
email_row = execute_query_single(
"SELECT sender_email FROM email_messages WHERE id = %s",
(email_id,),
)
sender_domain = _extract_sender_domain((email_row or {}).get("sender_email"))
if sender_domain and not _is_ignored_sender_domain(sender_domain):
_upsert_domain_mapping(sender_domain, int(customer_id), "auto_link")
logger.info(f"✅ Linked email {email_id}: {payload}") logger.info(f"✅ Linked email {email_id}: {payload}")
return {"success": True, "message": "Email linket"} return {"success": True, "message": "Email linket"}
@ -630,13 +1075,59 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
raise HTTPException(status_code=404, detail="Email not found") raise HTTPException(status_code=404, detail="Email not found")
email_data = email_row[0] email_data = email_row[0]
# Idempotent safeguard: repeated clicks should return existing linked case.
existing_sag_id = email_data.get('linked_case_id')
if existing_sag_id:
existing_sag = execute_query_single(
"""
SELECT id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(existing_sag_id,),
)
if existing_sag:
execute_update(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT (sag_id, email_id) DO NOTHING
""",
(existing_sag_id, email_id),
)
return {
"success": True,
"email_id": email_id,
"sag": existing_sag,
"idempotent": True,
"message": "E-mail er allerede knyttet til eksisterende SAG"
}
requested_case_type = _normalize_case_type(payload.case_type)
customer_id = payload.customer_id or email_data.get('customer_id') customer_id = payload.customer_id or email_data.get('customer_id')
if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _resolve_procurement_customer_id()
if not customer_id: if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)") raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
if not email_data.get('customer_id') and customer_id:
execute_update(
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(customer_id, email_id),
)
sender_domain = _extract_sender_domain(email_data.get("sender_email"))
if sender_domain and not _is_ignored_sender_domain(sender_domain):
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip() titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
template_key = (payload.case_type or 'support').strip().lower()[:50] template_key = requested_case_type[:50]
priority = (payload.priority or 'normal').strip().lower() priority = (payload.priority or 'normal').strip().lower()
if priority not in {'low', 'normal', 'high', 'urgent'}: if priority not in {'low', 'normal', 'high', 'urgent'}:
@ -692,6 +1183,25 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
(sag_id, email_id) (sag_id, email_id)
) )
attachments_linked = 0
try:
# Reuse workflow helper so attachments become real sag_files entries.
attachments_linked = int(email_workflow_service._copy_email_attachments_to_case(email_id, sag_id, None) or 0)
if attachments_linked > 0:
logger.info(
"📎 Linked %s attachment(s) from email %s to SAG-%s during create-sag",
attachments_linked,
email_id,
sag_id,
)
except Exception as attach_exc:
logger.warning(
"⚠️ Could not auto-link attachments from email %s to SAG-%s: %s",
email_id,
sag_id,
attach_exc,
)
if payload.contact_id: if payload.contact_id:
execute_update( execute_update(
""" """
@ -710,6 +1220,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
"success": True, "success": True,
"email_id": email_id, "email_id": email_id,
"sag": sag, "sag": sag,
"attachments_linked": attachments_linked,
"message": "SAG oprettet fra e-mail" "message": "SAG oprettet fra e-mail"
} }

View File

@ -283,6 +283,62 @@
overflow-x: hidden; overflow-x: hidden;
} }
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 0.5rem;
width: 100%;
margin-bottom: 0.65rem;
}
.email-secondary-actions {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.email-secondary-actions .left,
.email-secondary-actions .right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.domain-suggestion-card {
border: 1px dashed rgba(15, 76, 117, 0.35);
border-radius: 10px;
padding: 0.7rem 0.8rem;
background: rgba(15, 76, 117, 0.04);
margin-top: 0.65rem;
}
.domain-suggestion-card .title {
font-size: 0.83rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.35rem;
}
.domain-suggestion-meta {
font-size: 0.78rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.triage-priority-badge {
border: 1px solid rgba(15, 76, 117, 0.22);
background: rgba(15, 76, 117, 0.06);
color: var(--text-primary);
font-size: 0.75rem;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-weight: 600;
}
.attachment-chip { .attachment-chip {
max-width: 240px; max-width: 240px;
overflow: hidden; overflow: hidden;
@ -301,6 +357,17 @@
border-color: #0a3a5c; border-color: #0a3a5c;
} }
.email-actions .btn-danger-soft {
background: rgba(220, 53, 69, 0.08);
color: #a72a37;
border-color: rgba(220, 53, 69, 0.28);
}
.email-actions .btn-danger-soft:hover {
background: rgba(220, 53, 69, 0.14);
color: #8e1f2d;
}
.email-body { .email-body {
flex: 1; flex: 1;
padding: 1.5rem; padding: 1.5rem;
@ -1743,6 +1810,7 @@ function renderEmailList(emailList) {
<div class="email-preview">${escapeHtml(preview)}</div> <div class="email-preview">${escapeHtml(preview)}</div>
<div class="email-meta"> <div class="email-meta">
${!email.is_read ? '<span class="unread-indicator"></span>' : ''} ${!email.is_read ? '<span class="unread-indicator"></span>' : ''}
${getPriorityBadge(email)}
<span class="classification-badge classification-${classification}"> <span class="classification-badge classification-${classification}">
${formatClassification(classification)} ${formatClassification(classification)}
</span> </span>
@ -1793,6 +1861,7 @@ async function loadEmailDetail(emailId) {
renderEmailDetail(email); renderEmailDetail(email);
renderEmailAnalysis(email); renderEmailAnalysis(email);
await maybeSuggestCustomerByDomain(email);
if (!email.is_read) { if (!email.is_read) {
await markAsRead(emailId); await markAsRead(emailId);
@ -1805,6 +1874,176 @@ async function loadEmailDetail(emailId) {
} }
} }
function withActionLoading(button, loadingText = 'Arbejder...') {
const btn = button || null;
if (!btn) {
return () => {};
}
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${loadingText}`;
return () => {
btn.disabled = false;
btn.innerHTML = original;
};
}
function normalizeDomain(value) {
const domain = String(value || '').trim().toLowerCase();
if (!domain) return '';
return domain.startsWith('www.') ? domain.slice(4) : domain;
}
function splitSenderName(name, email) {
const raw = String(name || '').trim();
if (!raw) {
const fallback = String(email || '').split('@')[0] || 'Kontakt';
return { firstName: fallback.slice(0, 40), lastName: 'Fra email' };
}
const cleaned = raw.replace(/[<>]/g, '').trim();
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 1) {
return { firstName: parts[0].slice(0, 40), lastName: '-' };
}
return {
firstName: parts[0].slice(0, 40),
lastName: parts.slice(1).join(' ').slice(0, 60)
};
}
function setCaseCustomerSelection(customerId, customerName) {
const hidden = document.getElementById('caseCustomerId');
const input = document.getElementById('caseCustomerSearch');
if (hidden) hidden.value = customerId ? String(customerId) : '';
if (input && customerName) input.value = customerName;
}
function renderDomainCustomerHint(html, visible = true) {
const hint = document.getElementById('domainCustomerHint');
if (!hint) return;
hint.innerHTML = html || '';
hint.style.display = visible ? 'block' : 'none';
}
async function linkEmailToCustomerQuick(customerId, customerName) {
if (!currentEmailId || !customerId) return;
try {
const resp = await fetch(`/api/v1/emails/${currentEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId })
});
if (!resp.ok) throw new Error('Kunne ikke linke kunden');
try {
const senderEmail = String(document.querySelector('.sender-email')?.textContent || '').trim().toLowerCase();
const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
if (domain) {
await fetch('/api/v1/emails/domain-customer-mapping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain,
customer_id: customerId,
source: 'manual'
})
});
}
} catch (mappingError) {
console.warn('Could not save domain mapping', mappingError);
}
setCaseCustomerSelection(customerId, customerName);
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-check-circle text-success me-1"></i>Firma linket</div>
<div class="domain-suggestion-meta">Email er nu linket til ${escapeHtml(customerName || 'kunden')}.</div>`,
true
);
showSuccess(`Linket til ${customerName}`);
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunde-link fejlede');
}
}
async function maybeSuggestCustomerByDomain(email) {
if (!email || email.customer_id) {
renderDomainCustomerHint('', false);
return;
}
const senderEmail = String(email.sender_email || '').trim().toLowerCase();
const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
if (!domain) {
renderDomainCustomerHint('', false);
return;
}
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-search me-1"></i>Søger firma på domæne…</div>
<div class="domain-suggestion-meta">Domæne: ${escapeHtml(domain)}</div>`,
true
);
try {
const resp = await fetch(`/api/v1/emails/${email.id}/domain-customer-suggestion`);
if (!resp.ok) throw new Error('Domæneopslag fejlede');
const payload = await resp.json();
const best = payload?.suggestion
? {
id: payload.suggestion.customer_id,
name: payload.suggestion.customer_name,
email_domain: payload.suggestion.email_domain,
cvr_number: payload.suggestion.cvr_number,
source: payload.suggestion.source,
score: payload.suggestion.score,
confidence: payload.suggestion.confidence
}
: null;
if (!best) {
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-info-circle me-1"></i>Ingen firmamatch</div>
<div class="domain-suggestion-meta">Ingen kunde fundet for ${escapeHtml(domain)}. Brug “Opret firma/kontakt”.</div>`,
true
);
return;
}
const confidence = best.confidence === 'high' ? 'Høj' : 'Mellem';
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-building me-1"></i>Muligt firma fundet (${confidence} confidence)</div>
<div class="domain-suggestion-meta">
<strong>${escapeHtml(best.name)}</strong>
${best.email_domain ? `• ${escapeHtml(best.email_domain)}` : ''}
${best.cvr_number ? `• CVR ${escapeHtml(best.cvr_number)}` : ''}
${best.source ? `• Kilde: ${escapeHtml(best.source)}` : ''}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" onclick="linkEmailToCustomerQuick(${best.id}, '${escapeHtml(best.name).replace(/'/g, "\\'")}')">
<i class="bi bi-link-45deg me-1"></i>Link firma
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')">
<i class="bi bi-plus-circle me-1"></i>Opret ny
</button>
</div>`,
true
);
setCaseCustomerSelection(best.id, best.name);
} catch (error) {
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-exclamation-circle me-1"></i>Domæneopslag fejlede</div>
<div class="domain-suggestion-meta">${escapeHtml(error.message || 'Ukendt fejl')}</div>`,
true
);
}
}
function getClassificationActions(email) { function getClassificationActions(email) {
const actions = []; const actions = [];
@ -1918,31 +2157,51 @@ function renderEmailDetail(email) {
` : ''} ` : ''}
</div> </div>
<div class="email-actions d-flex justify-content-between align-items-center"> <div class="email-actions">
<div class="d-flex gap-2"> <div class="quick-actions-grid">
<button class="btn btn-sm btn-light border" onclick="archiveEmail()" title="Arkivér (e)"> <button class="btn btn-sm btn-primary" onclick="createSupplierInvoice(${email.id}, this)" title="Leverandørfaktura">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
</button>
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'support')" title="Kundesag">
<i class="bi bi-folder-plus me-1"></i>Kundesag
</button>
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'bogholderi', 'Fakturasporgsmal')" title="Fakturaspørgsmål">
<i class="bi bi-question-circle me-1"></i>Fakturaspørgsmål
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="markAsSpam(this)" title="Marker som spam">
<i class="bi bi-slash-circle me-1"></i>Spam
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')" title="Opret firma/kontakt">
<i class="bi bi-building-add me-1"></i>Opret firma/kontakt
</button>
</div>
<div class="email-secondary-actions">
<div class="left">
<button class="btn btn-sm btn-light border" onclick="archiveEmail(this)" title="Arkivér (e)">
<i class="bi bi-archive"></i> <i class="bi bi-archive"></i>
</button> </button>
<button class="btn btn-sm btn-light border" onclick="markAsSpam()" title="Marker som spam"> <button class="btn btn-sm btn-light border" onclick="reprocessEmail(this)" title="Genbehandl (r)">
<i class="bi bi-exclamation-triangle"></i>
</button>
<button class="btn btn-sm btn-light border" onclick="reprocessEmail()" title="Genbehandl (r)">
<i class="bi bi-arrow-clockwise"></i> <i class="bi bi-arrow-clockwise"></i>
</button> </button>
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows"> <button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail(this)" title="Kør workflows">
<i class="bi bi-diagram-3 me-1"></i>Workflows <i class="bi bi-diagram-3 me-1"></i>Workflows
</button> </button>
${email.linked_case_id ? ` ${email.linked_case_id ? `
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}"> <a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
<i class="bi bi-box-arrow-up-right me-1"></i>Sag <i class="bi bi-box-arrow-up-right me-1"></i>SAG-${email.linked_case_id}
</a> </a>
` : ''} ` : '<span class="triage-priority-badge">Ingen sag linket</span>'}
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet"> </div>
<div class="right">
<button class="btn btn-sm btn-danger-soft" onclick="deleteEmail(this)" title="Slet">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
</div>
${email.attachments && email.attachments.length > 0 ? ` ${email.attachments && email.attachments.length > 0 ? `
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2 flex-wrap mt-2 pt-2 border-top">
<span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span> <span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span>
${email.attachments.map(att => { ${email.attachments.map(att => {
const canPreview = canPreviewFile(att.content_type); const canPreview = canPreviewFile(att.content_type);
@ -2033,7 +2292,7 @@ function renderEmailAnalysis(email) {
<div class="analysis-card"> <div class="analysis-card">
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6> <h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
<div class="quick-action-row mb-3"> <div class="quick-action-row mb-3">
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()"> <button class="btn btn-sm btn-primary" onclick="confirmSuggestion(this)">
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag <i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
</button> </button>
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()"> <button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
@ -2068,6 +2327,7 @@ function renderEmailAnalysis(email) {
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div> <div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
</div> </div>
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}"> <input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
<div id="domainCustomerHint" class="domain-suggestion-card" style="display:none;"></div>
</div> </div>
<div class="suggestion-field"> <div class="suggestion-field">
@ -2113,7 +2373,7 @@ function renderEmailAnalysis(email) {
</div> </div>
<div class="quick-action-row quick-action-row-case mt-3"> <div class="quick-action-row quick-action-row-case mt-3">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()"> <button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm(this)">
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag <i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
</button> </button>
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()"> <button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
@ -2314,8 +2574,8 @@ async function searchSagerForCurrentEmail(query) {
} }
} }
function confirmSuggestion() { function confirmSuggestion(button = null) {
createCaseFromCurrentForm(); createCaseFromCurrentForm(button);
} }
function getCaseFormPayload() { function getCaseFormPayload() {
@ -2336,8 +2596,11 @@ function getCaseFormPayload() {
}; };
} }
async function createCaseFromCurrentForm() { let caseCreateInFlight = false;
async function createCaseFromCurrentForm(button = null) {
if (!currentEmailId) return; if (!currentEmailId) return;
if (caseCreateInFlight) return;
const payload = getCaseFormPayload(); const payload = getCaseFormPayload();
if (!payload.customer_id) { if (!payload.customer_id) {
@ -2345,6 +2608,9 @@ async function createCaseFromCurrentForm() {
return; return;
} }
caseCreateInFlight = true;
const done = withActionLoading(button, 'Opretter...');
try { try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, { const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
method: 'POST', method: 'POST',
@ -2358,11 +2624,18 @@ async function createCaseFromCurrentForm() {
} }
const result = await response.json(); const result = await response.json();
if (result.idempotent) {
showInfo(`E-mail var allerede knyttet til SAG-${result.sag.id}`);
} else {
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`); showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
}
loadEmails(); loadEmails();
await loadEmailDetail(currentEmailId); await loadEmailDetail(currentEmailId);
} catch (error) { } catch (error) {
showError(error.message || 'Kunne ikke oprette sag'); showError(error.message || 'Kunne ikke oprette sag');
} finally {
caseCreateInFlight = false;
done();
} }
} }
@ -2582,8 +2855,9 @@ function formatEventType(eventType) {
return labels[eventType] || eventType; return labels[eventType] || eventType;
} }
async function archiveEmail() { async function archiveEmail(button = null) {
if (!currentEmailId) return; if (!currentEmailId) return;
const done = withActionLoading(button, 'Arkiverer...');
try { try {
const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, { const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, {
@ -2599,14 +2873,18 @@ async function archiveEmail() {
loadEmails(); loadEmails();
} catch (error) { } catch (error) {
showError('Kunne ikke arkivere email'); showError('Kunne ikke arkivere email');
} finally {
done();
} }
} }
async function markAsSpam() { async function markAsSpam(button = null) {
if (!currentEmailId) return; if (!currentEmailId) return;
if (!confirm('Marker denne email som spam?')) return; if (!confirm('Marker denne email som spam?')) return;
const done = withActionLoading(button, 'Spam...');
try { try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, { const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
method: 'PUT', method: 'PUT',
@ -2623,17 +2901,16 @@ async function markAsSpam() {
loadEmails(); loadEmails();
} catch (error) { } catch (error) {
showError('Kunne ikke markere som spam'); showError('Kunne ikke markere som spam');
} finally {
done();
} }
} }
async function reprocessEmail() { async function reprocessEmail(button = null) {
if (!currentEmailId) return; if (!currentEmailId) return;
const done = withActionLoading(button, 'Genbehandler...');
try { try {
const btn = event.target.closest('button');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
const response = await fetch(`/api/v1/emails/${currentEmailId}/reprocess`, { const response = await fetch(`/api/v1/emails/${currentEmailId}/reprocess`, {
method: 'POST' method: 'POST'
}); });
@ -2647,18 +2924,17 @@ async function reprocessEmail() {
} catch (error) { } catch (error) {
showError('Kunne ikke genbehandle email'); showError('Kunne ikke genbehandle email');
} finally { } finally {
if (btn) { done();
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
}
} }
} }
async function deleteEmail() { async function deleteEmail(button = null) {
if (!currentEmailId) return; if (!currentEmailId) return;
if (!confirm('Slet denne email permanent?')) return; if (!confirm('Slet denne email permanent?')) return;
const done = withActionLoading(button, 'Sletter...');
try { try {
const response = await fetch(`/api/v1/emails/${currentEmailId}`, { const response = await fetch(`/api/v1/emails/${currentEmailId}`, {
method: 'DELETE' method: 'DELETE'
@ -2672,11 +2948,86 @@ async function deleteEmail() {
loadEmails(); loadEmails();
} catch (error) { } catch (error) {
showError('Kunne ikke slette email'); showError('Kunne ikke slette email');
} finally {
done();
} }
} }
async function createCaseQuick(emailId, primaryType = 'support', secondaryLabel = '') {
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
setPrimaryType(primaryType || 'support');
const secondary = document.getElementById('caseSecondaryLabel');
if (secondary && secondaryLabel) {
secondary.value = secondaryLabel;
}
const currentEmail = emails.find((item) => Number(item.id) === Number(emailId));
if (currentEmail) {
await maybeSuggestCustomerByDomain(currentEmail);
}
const hasCustomer = !!document.getElementById('caseCustomerId')?.value;
if (!hasCustomer) {
showInfo('Ingen kunde valgt endnu. Vælg forslag fra domæne eller opret firma/kontakt først.');
quickCreateCustomer(emailId, currentEmail?.sender_name || '', currentEmail?.sender_email || '');
return;
}
await createCaseFromCurrentForm();
}
// Classification Action Handlers // Classification Action Handlers
async function createSupplierInvoice(emailId) { const supplierInvoiceInFlightByEmail = new Set();
async function ensureSupplierCaseForEmail(email) {
if (!email || !email.id) return null;
if (email.linked_case_id) return Number(email.linked_case_id);
const subject = String(email.subject || '').trim();
const sender = String(email.sender_email || '').trim();
const payload = {
case_type: 'indkoeb',
relation_type: 'mail',
priority: 'normal',
titel: subject ? `Leverandørmail: ${subject}` : `Leverandørmail fra ${sender || 'ukendt afsender'}`,
beskrivelse: [
'Auto-oprettet fra leverandør email-behandling',
`Afsender: ${sender || '-'}`,
`Subject: ${subject || '-'}`
].join('\n')
};
if (email.customer_id) {
payload.customer_id = Number(email.customer_id);
}
const response = await fetch(`/api/v1/emails/${email.id}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({}));
const detail = errorPayload?.detail || 'Kunne ikke oprette relateret sag';
throw new Error(detail);
}
const result = await response.json();
return result?.sag?.id ? Number(result.sag.id) : null;
}
async function createSupplierInvoice(emailId, button = null) {
if (supplierInvoiceInFlightByEmail.has(Number(emailId))) {
showInfo('Leverandørfaktura behandles allerede for denne email...');
return;
}
supplierInvoiceInFlightByEmail.add(Number(emailId));
const done = withActionLoading(button, 'Behandler...');
try { try {
console.log('📧 Behandler email...'); console.log('📧 Behandler email...');
@ -2753,24 +3104,42 @@ async function createSupplierInvoice(emailId) {
// Show result // Show result
if (successCount > 0) { if (successCount > 0) {
showSuccess(`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}`); let linkedSagId = null;
// Mark email as processed and move to Processed folder try {
linkedSagId = await ensureSupplierCaseForEmail(email);
if (linkedSagId) {
email.linked_case_id = linkedSagId;
}
} catch (caseError) {
console.error('Error creating supplier-related case:', caseError);
showError(`Faktura uploadet, men sag-oprettelse fejlede: ${caseError.message}`);
}
if (!linkedSagId) {
// Fallback: keep prior behavior if case link was not created.
try { try {
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, { const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
method: 'POST' method: 'POST'
}); });
if (markResponse.ok) { if (!markResponse.ok) {
console.log('✅ Email marked as processed and moved to Processed folder');
// Reload email list to reflect changes
loadEmails();
} else {
console.warn('⚠️ Could not mark email as processed'); console.warn('⚠️ Could not mark email as processed');
} }
} catch (e) { } catch (e) {
console.error('Error marking email as processed:', e); console.error('Error marking email as processed:', e);
} }
}
showSuccess(
`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}` +
(linkedSagId ? ` · SAG-${linkedSagId} oprettet/linket` : '')
);
loadEmails();
if (currentEmailId === emailId) {
await loadEmailDetail(emailId);
}
// Ask if user wants to go to supplier invoices page // Ask if user wants to go to supplier invoices page
if (confirm(`${successCount} faktura${successCount > 1 ? 'er' : ''} er uploadet og behandlet.\n\nVil du gå til Leverandørfakturaer for at gennemse?`)) { if (confirm(`${successCount} faktura${successCount > 1 ? 'er' : ''} er uploadet og behandlet.\n\nVil du gå til Leverandørfakturaer for at gennemse?`)) {
@ -2783,6 +3152,9 @@ async function createSupplierInvoice(emailId) {
} catch (error) { } catch (error) {
console.error('Error creating supplier invoice:', error); console.error('Error creating supplier invoice:', error);
showError('Kunne ikke behandle email: ' + error.message); showError('Kunne ikke behandle email: ' + error.message);
} finally {
supplierInvoiceInFlightByEmail.delete(Number(emailId));
done();
} }
} }
@ -2818,11 +3190,16 @@ async function linkToCustomer(emailId) {
// ─── Quick Create Customer ──────────────────────────────────────────────── // ─── Quick Create Customer ────────────────────────────────────────────────
function quickCreateCustomer(emailId, senderName, senderEmail) { function quickCreateCustomer(emailId, senderName, senderEmail) {
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : ''; const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
const nameParts = splitSenderName(senderName, senderEmail);
document.getElementById('qcEmailId').value = emailId; document.getElementById('qcEmailId').value = emailId;
document.getElementById('qcCustomerName').value = senderName || ''; document.getElementById('qcCustomerName').value = senderName || '';
document.getElementById('qcCustomerEmail').value = senderEmail || ''; document.getElementById('qcCustomerEmail').value = senderEmail || '';
document.getElementById('qcCustomerDomain').value = senderDomain; document.getElementById('qcCustomerDomain').value = senderDomain;
document.getElementById('qcCustomerPhone').value = ''; document.getElementById('qcCustomerPhone').value = '';
document.getElementById('qcCreateContact').checked = true;
document.getElementById('qcContactFirstName').value = nameParts.firstName || '';
document.getElementById('qcContactLastName').value = nameParts.lastName || '';
document.getElementById('qcContactEmail').value = senderEmail || '';
document.getElementById('qcCustomerStatus').textContent = ''; document.getElementById('qcCustomerStatus').textContent = '';
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal')); const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
modal.show(); modal.show();
@ -2834,13 +3211,33 @@ async function submitQuickCustomer() {
const email = document.getElementById('qcCustomerEmail').value.trim(); const email = document.getElementById('qcCustomerEmail').value.trim();
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase(); const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
const phone = document.getElementById('qcCustomerPhone').value.trim(); const phone = document.getElementById('qcCustomerPhone').value.trim();
const createContact = !!document.getElementById('qcCreateContact').checked;
const contactFirstName = document.getElementById('qcContactFirstName').value.trim();
const contactLastName = document.getElementById('qcContactLastName').value.trim();
const contactEmail = document.getElementById('qcContactEmail').value.trim();
const statusEl = document.getElementById('qcCustomerStatus'); const statusEl = document.getElementById('qcCustomerStatus');
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; } if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…'; statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
try { try {
// Create customer let customer = null;
// Idempotent fast path: try to reuse existing customer by domain/email before create.
if (domain || email) {
const probe = domain || email;
const searchResp = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(probe)}&limit=20`);
if (searchResp.ok) {
const matches = await searchResp.json();
const exactByDomain = (matches || []).find((row) => normalizeDomain(row.email_domain) === domain);
const exactByEmail = !exactByDomain
? (matches || []).find((row) => String(row.email || '').trim().toLowerCase() === email.toLowerCase())
: null;
customer = exactByDomain || exactByEmail || null;
}
}
if (!customer) {
const custResp = await fetch('/api/v1/customers', { const custResp = await fetch('/api/v1/customers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -2852,7 +3249,26 @@ async function submitQuickCustomer() {
}) })
}); });
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede'); if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
const customer = await custResp.json(); customer = await custResp.json();
}
if (createContact && contactFirstName && contactLastName) {
const contactResp = await fetch(`/api/v1/customers/${customer.id}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: contactFirstName,
last_name: contactLastName,
email: contactEmail || null,
is_primary: true,
role: 'primary'
})
});
if (!contactResp.ok) {
console.warn('Contact creation failed for quick flow', await contactResp.text());
}
}
// Link email // Link email
await fetch(`/api/v1/emails/${emailId}/link`, { await fetch(`/api/v1/emails/${emailId}/link`, {
@ -2862,7 +3278,7 @@ async function submitQuickCustomer() {
}); });
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
showSuccess(`Kunde "${name}" oprettet og linket`); showSuccess(`Kunde "${customer.name || name}" linket`);
loadEmailDetail(parseInt(emailId)); loadEmailDetail(parseInt(emailId));
} catch (e) { } catch (e) {
statusEl.className = 'text-danger small'; statusEl.className = 'text-danger small';
@ -3254,6 +3670,26 @@ function getCaseBadge(email) {
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`; return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
} }
function getPriorityBadge(email) {
const sender = String(email.sender_email || '').toLowerCase();
const domain = normalizeDomain(sender.includes('@') ? sender.split('@')[1] : '');
const hasCustomer = !!email.customer_id;
if (!hasCustomer && domain && !['gmail.com', 'hotmail.com', 'outlook.com', 'icloud.com'].includes(domain)) {
return '<span class="badge bg-danger-subtle text-danger-emphasis ms-1">Ingen kontakt</span>';
}
if ((email.classification || '').toLowerCase() === 'invoice') {
return '<span class="badge bg-info-subtle text-info-emphasis ms-1">Mulig leverandørfaktura</span>';
}
if (!domain) {
return '<span class="badge bg-warning-subtle text-warning-emphasis ms-1">Ukendt domæne</span>';
}
return '';
}
function getFileIcon(contentType) { function getFileIcon(contentType) {
if (contentType?.includes('pdf')) return 'pdf'; if (contentType?.includes('pdf')) return 'pdf';
if (contentType?.includes('image')) return 'image'; if (contentType?.includes('image')) return 'image';
@ -4056,7 +4492,7 @@ async function deleteWorkflow(id) {
} }
} }
async function executeWorkflowsForEmail() { async function executeWorkflowsForEmail(button = null) {
if (!currentEmailId) { if (!currentEmailId) {
alert('Ingen email valgt'); alert('Ingen email valgt');
return; return;
@ -4064,6 +4500,8 @@ async function executeWorkflowsForEmail() {
if (!confirm('Vil du køre workflows for denne email?')) return; if (!confirm('Vil du køre workflows for denne email?')) return;
const done = withActionLoading(button, 'Kører...');
try { try {
showNotification('Kører workflows...', 'info'); showNotification('Kører workflows...', 'info');
@ -4100,6 +4538,8 @@ async function executeWorkflowsForEmail() {
} catch (error) { } catch (error) {
console.error('Error executing workflows:', error); console.error('Error executing workflows:', error);
showNotification('❌ Kunne ikke køre workflows', 'danger'); showNotification('❌ Kunne ikke køre workflows', 'danger');
} finally {
done();
} }
} }
@ -4885,6 +5325,26 @@ async function uploadEmailFiles(files) {
<label class="form-label fw-semibold">Telefon</label> <label class="form-label fw-semibold">Telefon</label>
<input type="text" class="form-control" id="qcCustomerPhone"> <input type="text" class="form-control" id="qcCustomerPhone">
</div> </div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="qcCreateContact" checked>
<label class="form-check-label" for="qcCreateContact">
Opret også primær kontakt
</label>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label fw-semibold">Kontakt fornavn</label>
<input type="text" class="form-control" id="qcContactFirstName" placeholder="Fornavn">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Kontakt efternavn</label>
<input type="text" class="form-control" id="qcContactLastName" placeholder="Efternavn">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Kontakt email</label>
<input type="email" class="form-control" id="qcContactEmail" placeholder="kontakt@firma.dk">
</div>
<div id="qcCustomerStatus"></div> <div id="qcCustomerStatus"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -324,6 +324,16 @@ async def sync_eset_hardware() -> None:
update_fields.append("brand = %s") update_fields.append("brand = %s")
update_params.append(brand) update_params.append(brand)
# Auto-created ESET devices are customer devices by default unless explicitly reassigned.
if customer_id:
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_fields.append("current_owner_customer_id = %s")
update_params.append(customer_id)
elif existing[0].get("notes") == "Auto-created from ESET" and existing[0].get("current_owner_type") != "customer":
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_params.append(hardware_id) update_params.append(hardware_id)
update_query = f""" update_query = f"""
UPDATE hardware_assets UPDATE hardware_assets
@ -332,7 +342,8 @@ async def sync_eset_hardware() -> None:
""" """
execute_query(update_query, tuple(update_params)) execute_query(update_query, tuple(update_params))
else: else:
owner_type = "customer" if customer_id else "bmc" # ESET sync auto-creates customer endpoints; ownership can be refined later if needed.
owner_type = "customer"
insert_query = """ insert_query = """
INSERT INTO hardware_assets ( INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, asset_type, brand, model, serial_number,

View File

@ -1,15 +1,18 @@
from typing import Optional from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single, execute_update
from .service import build_bottom_bar_state from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
_USER_NOTES_SCHEMA_READY = False
class BossAssignPayload(BaseModel): class BossAssignPayload(BaseModel):
@ -21,6 +24,456 @@ class BossAssignNextPayload(BaseModel):
assignee_user_id: int assignee_user_id: int
class UserNoteCreatePayload(BaseModel):
title: Optional[str] = None
content: str
is_pinned: bool = False
class UserNoteUpdatePayload(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
is_pinned: Optional[bool] = None
is_archived: Optional[bool] = None
class NoteToCaseCommentPayload(BaseModel):
sag_id: int
excerpt: Optional[str] = None
class NoteToContactPayload(BaseModel):
contact_id: int
field: str
value: Optional[str] = None
excerpt: Optional[str] = None
mode: str = "append"
class NoteToCustomerPayload(BaseModel):
customer_id: int
field: str
value: Optional[str] = None
excerpt: Optional[str] = None
mode: str = "append"
def _ensure_user_notes_schema() -> None:
global _USER_NOTES_SCHEMA_READY
if _USER_NOTES_SCHEMA_READY:
return
exists = execute_query_single("SELECT to_regclass('public.user_notes') AS table_name") or {}
if exists.get("table_name"):
_USER_NOTES_SCHEMA_READY = True
return
execute_query(
"""
CREATE TABLE IF NOT EXISTS user_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL DEFAULT '',
content TEXT NOT NULL,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
)
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_user_notes_user_active
ON user_notes (user_id, is_archived, is_pinned, updated_at DESC)
WHERE deleted_at IS NULL
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated
ON user_notes (user_id, updated_at DESC)
WHERE deleted_at IS NULL
"""
)
execute_query(
"""
CREATE OR REPLACE FUNCTION update_user_notes_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
execute_query("DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes")
execute_query(
"""
CREATE TRIGGER trg_user_notes_updated_at
BEFORE UPDATE ON user_notes
FOR EACH ROW
EXECUTE FUNCTION update_user_notes_updated_at()
"""
)
_USER_NOTES_SCHEMA_READY = True
logger.warning("⚠️ user_notes table was missing and has been created automatically")
def _resolve_current_user_display_name(current_user: dict) -> str:
current_user_id = current_user.get("id")
if current_user_id is None:
return "System"
row = execute_query_single(
"""
SELECT full_name, username
FROM users
WHERE user_id = %s
""",
(int(current_user_id),),
) or {}
return str(row.get("full_name") or row.get("username") or f"Bruger #{current_user_id}")
def _get_owned_note_or_404(note_id: int, user_id: int) -> dict:
_ensure_user_notes_schema()
row = execute_query_single(
"""
SELECT id, user_id, title, content, is_pinned, is_archived, created_at, updated_at
FROM user_notes
WHERE id = %s
AND user_id = %s
AND deleted_at IS NULL
""",
(int(note_id), int(user_id)),
)
if not row:
raise HTTPException(status_code=404, detail="Note ikke fundet")
return row
def _normalize_note_text(value: Optional[str]) -> str:
return str(value or "").strip()
def _build_merge_value(current_value: Optional[str], incoming_value: str, mode: str) -> str:
incoming = _normalize_note_text(incoming_value)
if not incoming:
return str(current_value or "")
current = str(current_value or "").strip()
normalized_mode = str(mode or "append").strip().lower()
if normalized_mode == "replace":
return incoming
if not current:
return incoming
if incoming in current:
return current
return f"{current}\n{incoming}"
@router.get("/notes")
@router.get("/notes/")
async def list_user_notes(
include_archived: bool = Query(default=False),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
rows = execute_query(
"""
SELECT id, title, content, is_pinned, is_archived, created_at, updated_at
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND (%s = TRUE OR is_archived = FALSE)
ORDER BY is_pinned DESC, updated_at DESC, id DESC
LIMIT %s OFFSET %s
""",
(int(current_user_id), bool(include_archived), int(limit), int(offset)),
) or []
total_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND (%s = TRUE OR is_archived = FALSE)
""",
(int(current_user_id), bool(include_archived)),
) or {}
return {
"items": rows,
"count": int(total_row.get("count") or 0),
}
@router.post("/notes")
@router.post("/notes/")
async def create_user_note(payload: UserNoteCreatePayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
content = _normalize_note_text(payload.content)
if not content:
raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt")
title = _normalize_note_text(payload.title)
row = execute_query_single(
"""
INSERT INTO user_notes (user_id, title, content, is_pinned)
VALUES (%s, %s, %s, %s)
RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at
""",
(int(current_user_id), title, content, bool(payload.is_pinned)),
)
return row or {}
@router.patch("/notes/{note_id}")
@router.patch("/notes/{note_id}/")
async def update_user_note(note_id: int, payload: UserNoteUpdatePayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
_get_owned_note_or_404(note_id, int(current_user_id))
sets = []
params = []
if payload.title is not None:
sets.append("title = %s")
params.append(_normalize_note_text(payload.title))
if payload.content is not None:
content = _normalize_note_text(payload.content)
if not content:
raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt")
sets.append("content = %s")
params.append(content)
if payload.is_pinned is not None:
sets.append("is_pinned = %s")
params.append(bool(payload.is_pinned))
if payload.is_archived is not None:
sets.append("is_archived = %s")
params.append(bool(payload.is_archived))
if not sets:
return _get_owned_note_or_404(note_id, int(current_user_id))
params.extend([int(note_id), int(current_user_id)])
row = execute_query_single(
f"""
UPDATE user_notes
SET {', '.join(sets)}
WHERE id = %s
AND user_id = %s
AND deleted_at IS NULL
RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at
""",
tuple(params),
)
if not row:
raise HTTPException(status_code=404, detail="Note ikke fundet")
return row
@router.delete("/notes/{note_id}")
@router.delete("/notes/{note_id}/")
async def delete_user_note(note_id: int, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
deleted = execute_update(
"""
UPDATE user_notes
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = %s
AND user_id = %s
AND deleted_at IS NULL
""",
(int(note_id), int(current_user_id)),
)
if not deleted:
raise HTTPException(status_code=404, detail="Note ikke fundet")
return {"status": "deleted", "note_id": int(note_id)}
@router.post("/notes/{note_id}/actions/sag-comment")
@router.post("/notes/{note_id}/actions/sag-comment/")
async def note_to_case_comment(note_id: int, payload: NoteToCaseCommentPayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
note = _get_owned_note_or_404(note_id, int(current_user_id))
case_row = execute_query_single(
"""
SELECT id
FROM sag_sager
WHERE id = %s
AND deleted_at IS NULL
""",
(int(payload.sag_id),),
)
if not case_row:
raise HTTPException(status_code=404, detail="Sag ikke fundet")
text = _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
if not text:
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
author = _resolve_current_user_display_name(current_user)
created = execute_query_single(
"""
INSERT INTO sag_kommentarer (sag_id, indhold, forfatter)
VALUES (%s, %s, %s)
RETURNING id, sag_id, indhold, forfatter, created_at
""",
(int(payload.sag_id), text, author),
) or {}
return {
"status": "inserted",
"target": "sag_comment",
"note_id": int(note_id),
"sag_id": int(payload.sag_id),
"comment": created,
}
@router.post("/notes/{note_id}/actions/contact-update")
@router.post("/notes/{note_id}/actions/contact-update/")
async def note_to_contact_update(note_id: int, payload: NoteToContactPayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
note = _get_owned_note_or_404(note_id, int(current_user_id))
allowed_fields = {"phone", "mobile", "email", "title", "department"}
field = str(payload.field or "").strip().lower()
if field not in allowed_fields:
raise HTTPException(status_code=400, detail="Ugyldigt kontaktfelt")
contact = execute_query_single(
f"SELECT id, {field} FROM contacts WHERE id = %s",
(int(payload.contact_id),),
)
if not contact:
raise HTTPException(status_code=404, detail="Kontakt ikke fundet")
incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
if not incoming:
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
merged = _build_merge_value(contact.get(field), incoming, payload.mode)
updated = execute_query_single(
f"""
UPDATE contacts
SET {field} = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, {field}
""",
(merged, int(payload.contact_id)),
) or {}
return {
"status": "updated",
"target": "contact",
"note_id": int(note_id),
"contact_id": int(payload.contact_id),
"field": field,
"value": updated.get(field),
}
@router.post("/notes/{note_id}/actions/customer-update")
@router.post("/notes/{note_id}/actions/customer-update/")
async def note_to_customer_update(note_id: int, payload: NoteToCustomerPayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
note = _get_owned_note_or_404(note_id, int(current_user_id))
field = str(payload.field or "").strip().lower()
allowed_fields = {"phone", "mobile_phone", "email", "address", "invoice_email", "note"}
if field not in allowed_fields:
raise HTTPException(status_code=400, detail="Ugyldigt firmafelt")
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s",
(int(payload.customer_id),),
)
if not customer:
raise HTTPException(status_code=404, detail="Firma ikke fundet")
incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
if not incoming:
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
if field == "note":
author = _resolve_current_user_display_name(current_user)
created = execute_query_single(
"""
INSERT INTO customer_notes (customer_id, note_type, note, created_by)
VALUES (%s, %s, %s, %s)
RETURNING id, customer_id, note_type, note, created_by, created_at
""",
(int(payload.customer_id), "general", incoming, author),
) or {}
return {
"status": "inserted",
"target": "customer_note",
"note_id": int(note_id),
"customer_id": int(payload.customer_id),
"record": created,
}
current = execute_query_single(
f"SELECT {field} FROM customers WHERE id = %s",
(int(payload.customer_id),),
) or {}
merged = _build_merge_value(current.get(field), incoming, payload.mode)
updated = execute_query_single(
f"""
UPDATE customers
SET {field} = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, {field}
""",
(merged, int(payload.customer_id)),
) or {}
return {
"status": "updated",
"target": "customer",
"note_id": int(note_id),
"customer_id": int(payload.customer_id),
"field": field,
"value": updated.get(field),
}
def _resolve_user_id_from_request(request: Request) -> Optional[int]: def _resolve_user_id_from_request(request: Request) -> Optional[int]:
state_user_id = getattr(request.state, "user_id", None) state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not None: if state_user_id is not None:
@ -56,6 +509,27 @@ async def get_bottom_bar_state(request: Request, current_user: dict = Depends(ge
context_path = request.query_params.get("context") or "" context_path = request.query_params.get("context") or ""
return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access) return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access)
@router.get("/timers/own")
async def get_own_timers(
paused_limit: int = Query(default=10, ge=1, le=25),
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return get_own_timer_snapshot(int(current_user_id), paused_limit=paused_limit)
@router.get("/boss/unassigned-cases")
async def list_unassigned_open_cases(
limit: int = Query(default=25, ge=1, le=100),
current_user: dict = Depends(get_current_user),
):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
return get_unassigned_open_cases(limit=limit)
from app.services.task_routing import TaskRouter from app.services.task_routing import TaskRouter
from app.services.m365_calendar import M365CalendarService from app.services.m365_calendar import M365CalendarService
@ -98,8 +572,8 @@ def _get_next_unassigned_case() -> Optional[dict]:
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY ORDER BY
CASE CASE
WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
WHEN LOWER(COALESCE(priority, 'normal')) IN ('high', 'høj') THEN 1 WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1
ELSE 2 ELSE 2
END, END,
COALESCE(updated_at, created_at) ASC, COALESCE(updated_at, created_at) ASC,
@ -159,7 +633,7 @@ async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)):
u.user_id, u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name, COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases, COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
FROM users u FROM users u
JOIN user_groups ug ON ug.user_id = u.user_id JOIN user_groups ug ON ug.user_id = u.user_id
JOIN groups g ON g.id = ug.group_id JOIN groups g ON g.id = ug.group_id

View File

@ -38,6 +38,32 @@ def _priority_rank(priority: str) -> int:
return 0 return 0
def _table_exists(table_name: str) -> bool:
row = execute_query_single(
"""
SELECT EXISTS(
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS exists
""",
(table_name,),
)
return bool((row or {}).get("exists"))
def _table_columns(table_name: str) -> List[str]:
rows = execute_query(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
""",
(table_name,),
) or []
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
def _get_user_group_names(user_id: Optional[int]) -> List[str]: def _get_user_group_names(user_id: Optional[int]) -> List[str]:
if user_id is None: if user_id is None:
return [] return []
@ -148,7 +174,7 @@ def get_dashboard_status() -> Dict[str, int]:
FROM sag_sager FROM sag_sager
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
""" """
) )
) )
@ -227,6 +253,222 @@ def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
} }
def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Dict[str, Any]:
active = get_active_timer(user_id)
if user_id is None:
return {
"active": active,
"paused": [],
"counts": {"active": 0, "paused": 0, "total": 0},
}
paused_limit_safe = max(1, min(int(paused_limit or 10), 25))
paused_rows = execute_query(
"""
SELECT
t.id,
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
t.slut_tid,
GREATEST(
EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int,
0
) AS elapsed_seconds,
COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = FALSE
AND t.paused_at IS NOT NULL
AND t.slut_tid IS NULL
ORDER BY COALESCE(t.paused_at, t.updated_at, t.created_at) DESC, t.id DESC
LIMIT %s
""",
(user_id, paused_limit_safe),
) or []
paused_count_row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM tmodule_times t
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = FALSE
AND t.paused_at IS NOT NULL
AND t.slut_tid IS NULL
""",
(user_id,),
)
paused_count = _safe_count(paused_count_row)
active_count = 1 if active.get("active") else 0
return {
"active": active,
"paused": [
{
"time_entry_id": row.get("id"),
"sag_id": row.get("sag_id"),
"sag_navn": row.get("sag_navn") or f"Sag #{row.get('sag_id')}",
"start_tid": row.get("start_tid"),
"slut_tid": row.get("slut_tid"),
"faktisk_tid_min": 0,
"elapsed_hhmmss": _format_elapsed(
max(0, int(row.get("elapsed_seconds") or 0) - int(row.get("pause_total_seconds") or 0))
),
}
for row in paused_rows
],
"counts": {
"active": active_count,
"paused": paused_count,
"total": active_count + paused_count,
},
}
def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 25), 100))
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.created_at,
s.updated_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
(limit_safe,),
) or []
count_row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
"""
)
return {
"items": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in rows
],
"count": _safe_count(count_row),
"filter_meta": {
"route": "/api/v1/bottom-bar/boss/unassigned-cases",
"query": {"limit": limit_safe, "only_open": True, "only_unassigned": True},
"sql_guarantee": [
"s.deleted_at IS NULL",
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
"s.ansvarlig_bruger_id IS NULL",
],
},
}
def _get_recent_cases(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 10), 20))
source = "direct_query"
rows: List[Dict[str, Any]] = []
if _table_exists("sag_recent_cases"):
columns = set(_table_columns("sag_recent_cases"))
has_required = {"sag_id", "user_id"}.issubset(columns)
if has_required:
order_column = "viewed_at" if "viewed_at" in columns else "opened_at" if "opened_at" in columns else "updated_at" if "updated_at" in columns else "created_at"
if order_column:
source = "sag_recent_cases"
rows = execute_query(
f"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
rc.{order_column} AS recent_at
FROM sag_recent_cases rc
JOIN sag_sager s ON s.id = rc.sag_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND rc.user_id = %s
ORDER BY rc.{order_column} DESC, s.id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
if not rows and user_id is not None:
source = "direct_query_user_timers"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
MAX(COALESCE(t.start_tid, t.updated_at, t.created_at)) AS recent_at
FROM tmodule_times t
JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
GROUP BY s.id, s.titel, s.priority, s.status
ORDER BY recent_at DESC, s.id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
if not rows:
source = "direct_query_global"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
COALESCE(s.updated_at, s.created_at) AS recent_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
(limit_safe,),
) or []
return {
"source": source,
"items": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"status": row.get("status"),
"recent_at": row.get("recent_at"),
}
for row in rows
],
"count": len(rows),
}
def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]: def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
if user_id is None: if user_id is None:
return {"items": [], "count": 0} return {"items": [], "count": 0}
@ -262,7 +504,7 @@ def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP) AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
AND (l.status IS NULL OR l.status != 'dismissed') AND (l.status IS NULL OR l.status != 'dismissed')
ORDER BY ORDER BY
CASE LOWER(COALESCE(r.priority, 'normal')) CASE LOWER(COALESCE(r.priority::text, 'normal'))
WHEN 'urgent' THEN 1 WHEN 'urgent' THEN 1
WHEN 'high' THEN 2 WHEN 'high' THEN 2
WHEN 'normal' THEN 3 WHEN 'normal' THEN 3
@ -367,6 +609,59 @@ def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
return payload return payload
def get_user_notes_summary(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
if user_id is None:
return {"count": 0, "list": []}
limit_safe = max(1, min(int(limit or 10), 50))
rows = execute_query(
"""
SELECT
id,
title,
content,
is_pinned,
is_archived,
created_at,
updated_at
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND is_archived = FALSE
ORDER BY is_pinned DESC, updated_at DESC, id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
total_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND is_archived = FALSE
""",
(user_id,),
)
return {
"count": _safe_count(total_row),
"list": [
{
"id": row.get("id"),
"title": row.get("title") or "",
"content": row.get("content") or "",
"is_pinned": bool(row.get("is_pinned")),
"is_archived": bool(row.get("is_archived")),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in rows
],
}
def build_bottom_bar_state( def build_bottom_bar_state(
user_id: Optional[int], user_id: Optional[int],
context_path: str = "", context_path: str = "",
@ -378,7 +673,11 @@ def build_bottom_bar_state(
status = get_dashboard_status() status = get_dashboard_status()
timer = get_active_timer(user_id) timer = get_active_timer(user_id)
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
notifications = get_notifications(user_id, limit=10) notifications = get_notifications(user_id, limit=10)
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
urgent_cases = execute_query( urgent_cases = execute_query(
""" """
@ -386,7 +685,7 @@ def build_bottom_bar_state(
FROM sag_sager FROM sag_sager
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
ORDER BY updated_at DESC NULLS LAST, id DESC ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5 LIMIT 5
""" """
@ -447,7 +746,7 @@ def build_bottom_bar_state(
u.user_id, u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name, COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases, COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
FROM users u FROM users u
LEFT JOIN sag_sager s LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id ON s.ansvarlig_bruger_id = u.user_id
@ -473,7 +772,7 @@ def build_bottom_bar_state(
json_build_object( json_build_object(
'id', t.id, 'id', t.id,
'title', t.titel, 'title', t.titel,
'priority', COALESCE(t.priority, 'normal'), 'priority', COALESCE(t.priority::text, 'normal'),
'deadline', t.deadline 'deadline', t.deadline
) )
ORDER BY ORDER BY
@ -531,29 +830,21 @@ def build_bottom_bar_state(
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours' AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
ORDER BY COALESCE(s.updated_at, s.created_at) ASC ORDER BY COALESCE(s.updated_at, s.created_at) ASC
LIMIT 8 LIMIT 8
""" """
) or [] ) or []
unassigned_cases = execute_query( unassigned_cases = [
""" {
SELECT "id": row.get("id"),
s.id, "titel": row.get("title"),
s.titel, "priority": row.get("priority"),
s.priority, }
s.created_at, for row in (unassigned_open_cases.get("items") or [])
s.updated_at ]
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 8
"""
) or []
sections = { sections = {
"mail": { "mail": {
@ -570,11 +861,30 @@ def build_bottom_bar_state(
}, },
"unassigned": { "unassigned": {
"count": status.get("sager_unassigned", 0), "count": status.get("sager_unassigned", 0),
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
}, },
"timer": { "timer": {
"active_count": 1 if timer.get("active") else 0, "active_count": 1 if timer.get("active") else 0,
"list": timer_list, "list": timer_list,
"active": timer, "active": timer,
"own": own_timers,
"switch_case_hooks": {
"fetch_own_active_paused_timers": {
"route": "/api/v1/bottom-bar/timers/own",
"method": "GET",
"query": {"paused_limit": 10},
},
"switch_case_start_timer": {
"route": "/api/v1/timetracking/time/start",
"method": "POST",
"payload": {
"sag_id": "required:int",
"medarbejder_id": "optional:int",
"beskrivelse": "optional:string",
},
},
},
}, },
"kuma": { "kuma": {
"down": 0, "down": 0,
@ -592,6 +902,8 @@ def build_bottom_bar_state(
"count": len(tasks), "count": len(tasks),
"list": tasks, "list": tasks,
}, },
"recent_cases": recent_cases,
"notes": notes_summary,
"boss": { "boss": {
"can_view": can_view_boss, "can_view": can_view_boss,
"stats": { "stats": {
@ -652,5 +964,7 @@ def build_bottom_bar_state(
"sections": sections, "sections": sections,
"status": status, "status": status,
"active_timer": timer, "active_timer": timer,
"own_timers": own_timers,
"recent_cases": recent_cases,
"notifications": notifications, "notifications": notifications,
} }

View File

@ -374,9 +374,11 @@ async def create_hardware(data: dict):
internal_asset_id, notes, current_owner_type, current_owner_customer_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life, status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link, anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group eset_uuid, hardware_specs, eset_group,
rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
@ -402,7 +404,11 @@ async def create_hardware(data: dict):
data.get("anydesk_link"), data.get("anydesk_link"),
data.get("eset_uuid"), data.get("eset_uuid"),
specs, specs,
data.get("eset_group") data.get("eset_group"),
data.get("rental_default_start_price"),
data.get("rental_default_freight_price"),
data.get("rental_default_preparation_price"),
data.get("rental_default_operations_monthly_price"),
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result: if not result:
@ -503,7 +509,9 @@ async def update_hardware(hardware_id: int, data: dict):
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id", "internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life", "status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link", "follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group" "eset_uuid", "hardware_specs", "eset_group",
"rental_default_start_price", "rental_default_freight_price",
"rental_default_preparation_price", "rental_default_operations_monthly_price"
] ]
for field in allowed_fields: for field in allowed_fields:

View File

@ -568,6 +568,103 @@ async def hardware_detail(request: Request, hardware_id: int):
all_locations_flat = execute_query(all_locations_query) all_locations_flat = execute_query(all_locations_query)
location_tree = build_location_tree(all_locations_flat) location_tree = build_location_tree(all_locations_flat)
# Rental statistics for this hardware asset
rental_overview_query = """
SELECT
COUNT(*) AS total_bindings,
COALESCE(SUM(CASE WHEN b.status = 'active' AND b.deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS active_bindings,
MIN(b.start_date) AS first_rented_at,
MAX(COALESCE(b.end_date, b.start_date)) AS latest_rental_activity,
COALESCE(SUM(
CASE
WHEN b.status = 'active'
AND b.deleted_at IS NULL
AND s.status IN ('active', 'paused')
THEN COALESCE(i.line_total, 0)
ELSE 0
END
), 0) AS active_mrr,
COALESCE(SUM(
CASE
WHEN b.status = 'active'
AND b.deleted_at IS NULL
AND s.status IN ('draft', 'active', 'paused')
THEN COALESCE(i.line_total, 0)
ELSE 0
END
), 0) AS pipeline_mrr
FROM subscription_asset_bindings b
LEFT JOIN sag_subscriptions s ON s.id = b.subscription_id
LEFT JOIN sag_subscription_items i
ON i.subscription_id = s.id
AND i.asset_id = b.asset_id
WHERE b.asset_id = %s
"""
rental_overview = execute_query(rental_overview_query, (hardware_id,))
rental_overview = (rental_overview or [{}])[0]
rental_revenue_query = """
SELECT
COALESCE(SUM(
CASE
WHEN o.sync_status IN ('exported', 'posted', 'paid')
THEN ((line->>'quantity')::numeric * (line->>'unit_price')::numeric)
ELSE 0
END
), 0) AS exported_or_posted_revenue,
COALESCE(SUM(
CASE
WHEN o.sync_status = 'pending'
THEN ((line->>'quantity')::numeric * (line->>'unit_price')::numeric)
ELSE 0
END
), 0) AS pending_revenue,
COALESCE(SUM((line->>'quantity')::numeric * (line->>'unit_price')::numeric), 0) AS total_revenue,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT CASE WHEN o.sync_status IN ('exported', 'posted', 'paid') THEN o.id END) AS exported_or_posted_order_count,
COUNT(DISTINCT CASE WHEN o.sync_status = 'pending' THEN o.id END) AS pending_order_count
FROM ordre_drafts o
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(o.lines_json, '[]'::jsonb)) line
WHERE line ? 'source_id'
AND (line->>'source_id') ~ '^[0-9]+$'
AND (line->>'source_id')::integer = %s
"""
rental_revenue = execute_query(rental_revenue_query, (hardware_id,))
rental_revenue = (rental_revenue or [{}])[0]
recent_rentals_query = """
SELECT
o.id,
o.title,
o.sync_status,
o.created_at,
COALESCE(SUM((line->>'quantity')::numeric * (line->>'unit_price')::numeric), 0) AS amount
FROM ordre_drafts o
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(o.lines_json, '[]'::jsonb)) line
WHERE line ? 'source_id'
AND (line->>'source_id') ~ '^[0-9]+$'
AND (line->>'source_id')::integer = %s
GROUP BY o.id, o.title, o.sync_status, o.created_at
ORDER BY o.created_at DESC
LIMIT 8
"""
recent_rentals = execute_query(recent_rentals_query, (hardware_id,))
rental_stats = {
"total_bindings": int(rental_overview.get("total_bindings") or 0),
"active_bindings": int(rental_overview.get("active_bindings") or 0),
"first_rented_at": rental_overview.get("first_rented_at"),
"latest_rental_activity": rental_overview.get("latest_rental_activity"),
"active_mrr": float(rental_overview.get("active_mrr") or 0),
"pipeline_mrr": float(rental_overview.get("pipeline_mrr") or 0),
"exported_or_posted_revenue": float(rental_revenue.get("exported_or_posted_revenue") or 0),
"pending_revenue": float(rental_revenue.get("pending_revenue") or 0),
"total_revenue": float(rental_revenue.get("total_revenue") or 0),
"order_count": int(rental_revenue.get("order_count") or 0),
"exported_or_posted_order_count": int(rental_revenue.get("exported_or_posted_order_count") or 0),
"pending_order_count": int(rental_revenue.get("pending_order_count") or 0),
}
return templates.TemplateResponse("modules/hardware/templates/detail.html", { return templates.TemplateResponse("modules/hardware/templates/detail.html", {
"request": request, "request": request,
"hardware": hardware, "hardware": hardware,
@ -581,7 +678,9 @@ async def hardware_detail(request: Request, hardware_id: int):
"owner_customers": owner_customers or [], "owner_customers": owner_customers or [],
"owner_contacts": owner_contacts or [], "owner_contacts": owner_contacts or [],
"location_tree": location_tree or [], "location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware) "eset_specs": extract_eset_specs_summary(hardware),
"rental_stats": rental_stats,
"recent_rentals": recent_rentals or [],
}) })

View File

@ -285,6 +285,30 @@ js{% extends "shared/frontend/base.html" %}
</div> </div>
</div> </div>
<!-- Rental Defaults -->
<div class="form-section">
<h3 class="form-section-title">💶 Standardpriser for Udlejning</h3>
<div class="form-grid">
<div class="form-group">
<label for="rental_default_start_price">Standard startpris</label>
<input type="number" id="rental_default_start_price" name="rental_default_start_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_freight_price">Standard fragt</label>
<input type="number" id="rental_default_freight_price" name="rental_default_freight_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_preparation_price">Standard klargoring</label>
<input type="number" id="rental_default_preparation_price" name="rental_default_preparation_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_operations_monthly_price">Standard drift pr. maned</label>
<input type="number" id="rental_default_operations_monthly_price" name="rental_default_operations_monthly_price" min="0" step="0.01" value="0">
</div>
</div>
<div class="form-text mt-2">Bruges til at autoudfylde Udlej-modal pa asseten.</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="form-section"> <div class="form-section">
<h3 class="form-section-title">📝 Noter</h3> <h3 class="form-section-title">📝 Noter</h3>
@ -356,6 +380,13 @@ js{% extends "shared/frontend/base.html" %}
// Convert customer_id to integer // Convert customer_id to integer
if (key === 'current_owner_customer_id') { if (key === 'current_owner_customer_id') {
data[key] = parseInt(value); data[key] = parseInt(value);
} else if (
key === 'rental_default_start_price' ||
key === 'rental_default_freight_price' ||
key === 'rental_default_preparation_price' ||
key === 'rental_default_operations_monthly_price'
) {
data[key] = Number(value);
} else { } else {
data[key] = value; data[key] = value;
} }

View File

@ -105,6 +105,56 @@
margin-right: 0; margin-right: 0;
padding-right: 0; padding-right: 0;
} }
.price-tile {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
padding: 1rem;
height: 100%;
}
.price-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.price-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-tile {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
padding: 1rem;
height: 100%;
}
.stat-label {
font-size: 0.82rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.15;
}
.stat-helper {
margin-top: 0.4rem;
font-size: 0.82rem;
color: var(--text-secondary);
}
</style> </style>
{% endblock %} {% endblock %}
@ -222,6 +272,16 @@
<i class="bi bi-clock-history me-2"></i>Historik <i class="bi bi-clock-history me-2"></i>Historik
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="prices-tab" data-bs-toggle="tab" data-bs-target="#prices" type="button" role="tab">
<i class="bi bi-cash-coin me-2"></i>Priser
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button" role="tab">
<i class="bi bi-bar-chart-line me-2"></i>Statistik
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab"> <button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }}) <i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
@ -407,6 +467,12 @@
<span class="small fw-bold">Opret Sag</span> <span class="small fw-bold">Opret Sag</span>
</a> </a>
</div> </div>
<div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#quickRentModal">
<i class="bi bi-box-seam text-success"></i>
<span class="small fw-bold">Udlej</span>
</div>
</div>
<div class="col-6"> <div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal"> <div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt text-primary"></i> <i class="bi bi-geo-alt text-primary"></i>
@ -678,6 +744,175 @@
</div> </div>
</div> </div>
<!-- Tab: Prices -->
<div class="tab-pane fade" id="prices" role="tabpanel">
{% set start_price = hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 %}
{% set freight_price = hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 %}
{% set preparation_price = hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 %}
{% set operations_price = hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 %}
{% set startup_total = start_price + freight_price + preparation_price %}
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-primary mb-0"><i class="bi bi-cash-coin me-2"></i>Standardpriser for udlejning</h6>
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary">Rediger priser</a>
</div>
<div class="card-body">
<div class="row g-3 mb-2">
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Startpris</div>
<div class="price-value">{{ "{:,.2f}".format(start_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Fragt</div>
<div class="price-value">{{ "{:,.2f}".format(freight_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Klargoring</div>
<div class="price-value">{{ "{:,.2f}".format(preparation_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Drift pr. maned</div>
<div class="price-value">{{ "{:,.2f}".format(operations_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
</div>
<div class="alert alert-light border mb-0 mt-2">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<span class="text-muted">Standard opstartspris (start + fragt + klargoring)</span>
<span class="fw-bold fs-5">{{ "{:,.2f}".format(startup_total).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Statistics -->
<div class="tab-pane fade" id="statistics" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-bar-chart-line me-2"></i>Udlejningsstatistik for asset</h6>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Total omsaetning</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.total_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Fra alle ordrelinjer pa dette asset</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Eksporteret / bogfort</div>
<div class="stat-value text-success">{{ "{:,.2f}".format(rental_stats.exported_or_posted_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Ordrestatus: exported, posted eller paid</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Afventer fakturering</div>
<div class="stat-value text-warning">{{ "{:,.2f}".format(rental_stats.pending_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Ordrestatus: pending</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Aktiv MRR</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.active_mrr).replace(",", "X").replace(".", ",").replace("X", ".") }} kr./md.</div>
<div class="stat-helper">Aktive/pausede abonnementslinjer</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Udlejninger i alt</div>
<div class="stat-value">{{ rental_stats.total_bindings }}</div>
<div class="stat-helper">Antal bindings pa asset</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Aktive udlejninger</div>
<div class="stat-value">{{ rental_stats.active_bindings }}</div>
<div class="stat-helper">Status active</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Ordrekladder i alt</div>
<div class="stat-value">{{ rental_stats.order_count }}</div>
<div class="stat-helper">Med linjer for dette asset</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Pipeline MRR</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.pipeline_mrr).replace(",", "X").replace(".", ",").replace("X", ".") }} kr./md.</div>
<div class="stat-helper">Inkl. draft + active + paused</div>
</div>
</div>
</div>
<div class="card border-0" style="background: rgba(0,0,0,0.02);">
<div class="card-body">
<div class="d-flex flex-wrap gap-4 small text-muted mb-2">
<span><strong>Forste udlejning:</strong> {{ rental_stats.first_rented_at or '-' }}</span>
<span><strong>Seneste aktivitet:</strong> {{ rental_stats.latest_rental_activity or '-' }}</span>
<span><strong>Eksporterede/bogforte ordrer:</strong> {{ rental_stats.exported_or_posted_order_count }}</span>
<span><strong>Afventende ordrer:</strong> {{ rental_stats.pending_order_count }}</span>
</div>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Titel</th>
<th>Status</th>
<th class="text-end">Beloeb</th>
</tr>
</thead>
<tbody>
{% if recent_rentals %}
{% for item in recent_rentals %}
<tr>
<td class="text-muted">{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '-' }}</td>
<td>
<a href="/ordre/{{ item.id }}" class="text-decoration-none">{{ item.title or ('Ordre #' ~ item.id) }}</a>
</td>
<td>
<span class="badge {% if item.sync_status in ['posted', 'paid', 'exported'] %}bg-success{% elif item.sync_status == 'pending' %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
{{ item.sync_status or '-' }}
</span>
</td>
<td class="text-end fw-semibold">{{ "{:,.2f}".format(item.amount or 0).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">Ingen udlejningsordrer fundet for dette asset endnu.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Files --> <!-- Tab: Files -->
<div class="tab-pane fade" id="files" role="tabpanel"> <div class="tab-pane fade" id="files" role="tabpanel">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
@ -728,6 +963,99 @@
</div> </div>
</div> </div>
<!-- Quick Rent Modal -->
<div class="modal fade" id="quickRentModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Udlej Hardware #{{ hardware.id }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
</div>
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3">
Vaelg kunde og sag for at se hvad der bliver oprettet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<select class="form-select" id="quickRentCustomerId" required>
<option value="">-- Vaelg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}">{{ customer.navn }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Sag ID</label>
<input type="number" class="form-control" id="quickRentSagId" placeholder="fx 123" min="1" required>
</div>
<div class="col-md-4">
<label class="form-label">Start dato</label>
<input type="date" class="form-control" id="quickRentStartDate" required>
</div>
<div class="col-md-4">
<label class="form-label">Startpris</label>
<input
type="number"
class="form-control"
id="quickRentStartPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Fragt</label>
<input
type="number"
class="form-control"
id="quickRentFreightPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Klargoring</label>
<input
type="number"
class="form-control"
id="quickRentPreparationPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Drift pr. maned</label>
<input
type="number"
class="form-control"
id="quickRentOperationsMonthlyPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 }}"
required
>
</div>
<div class="col-md-4">
<label class="form-label">Drift i ordre (maneder)</label>
<input type="number" class="form-control" id="quickRentInitialMonths" min="1" max="12" value="2">
</div>
</div>
<div class="form-text mt-2">Standarden er 2 mdr. drift i opstartsordren (1+1).</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" id="quickRentSubmitBtn" onclick="submitQuickRent()">Opret udlejning</button>
</div>
</div>
</div>
</div>
<!-- Modal for Owner --> <!-- Modal for Owner -->
<div class="modal fade" id="ownerModal" tabindex="-1"> <div class="modal fade" id="ownerModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@ -923,6 +1251,119 @@
} }
} }
async function submitQuickRent() {
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
const startDate = document.getElementById('quickRentStartDate').value;
const startPrice = Number(document.getElementById('quickRentStartPrice').value || 0);
const freightPrice = Number(document.getElementById('quickRentFreightPrice').value || 0);
const preparationPrice = Number(document.getElementById('quickRentPreparationPrice').value || 0);
const operationsMonthlyPrice = Number(document.getElementById('quickRentOperationsMonthlyPrice').value || 0);
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er paakraevet.');
return;
}
if (operationsMonthlyPrice <= 0) {
alert('Drift pr. maned skal vaere over 0.');
return;
}
const submitBtn = document.getElementById('quickRentSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Opretter...';
try {
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: customerId,
sag_id: sagId,
start_date: startDate,
start_price: startPrice,
freight_price: freightPrice,
preparation_price: preparationPrice,
operations_monthly_price: operationsMonthlyPrice,
initial_operations_months: initialOperationsMonths,
notice_period_days: 30
})
});
const data = await response.json();
if (!response.ok) {
throw new Error((data && data.detail) || 'Quick rent fejlede');
}
const modalEl = document.getElementById('quickRentModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
const draftId = data.ordre_draft_id;
if (draftId) {
window.location.href = `/ordre/${draftId}`;
return;
}
alert('Udlejning oprettet: abonnement + binding oprettet.');
window.location.reload();
} catch (error) {
alert(`Udlejning fejlede: ${error.message}`);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Opret udlejning';
}
}
async function refreshQuickRentPlan() {
const infoEl = document.getElementById('quickRentPlanInfo');
const submitBtn = document.getElementById('quickRentSubmitBtn');
const customerId = Number(document.getElementById('quickRentCustomerId')?.value || 0);
const sagId = Number(document.getElementById('quickRentSagId')?.value || 0);
if (!infoEl || !submitBtn) {
return;
}
if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Vaelg kunde og sag for at se hvad der bliver oprettet.';
submitBtn.disabled = false;
return;
}
try {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Tjekker abonnement paa sagen...';
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json();
if (!response.ok) {
throw new Error((data && data.detail) || 'Preview fejlede');
}
submitBtn.disabled = !data.can_submit;
if (data.action === 'reuse') {
infoEl.className = 'alert alert-success py-2 small mb-3';
} else if (data.action === 'create') {
infoEl.className = 'alert alert-primary py-2 small mb-3';
} else {
infoEl.className = 'alert alert-danger py-2 small mb-3';
}
infoEl.textContent = data.message || 'Klar.';
} catch (error) {
submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3';
infoEl.textContent = `Preview fejl: ${error.message}. Du kan stadig prove at oprette.`;
}
}
// Tree Toggle Function // Tree Toggle Function
function toggleLocationChildren(event, nodeId) { function toggleLocationChildren(event, nodeId) {
event.preventDefault(); event.preventDefault();
@ -1040,6 +1481,24 @@
// Initialize Tags // Initialize Tags
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const quickRentStartDate = document.getElementById('quickRentStartDate');
const quickRentCustomerId = document.getElementById('quickRentCustomerId');
const quickRentSagId = document.getElementById('quickRentSagId');
const quickRentModal = document.getElementById('quickRentModal');
if (quickRentStartDate && !quickRentStartDate.value) {
quickRentStartDate.value = new Date().toISOString().slice(0, 10);
}
if (quickRentCustomerId) {
quickRentCustomerId.addEventListener('change', refreshQuickRentPlan);
}
if (quickRentSagId) {
quickRentSagId.addEventListener('input', refreshQuickRentPlan);
}
if (quickRentModal) {
quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan);
}
refreshQuickRentPlan();
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch'); const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect'); const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSearch = document.getElementById('ownerContactSearch'); const ownerContactSearch = document.getElementById('ownerContactSearch');

View File

@ -285,6 +285,58 @@
</div> </div>
</div> </div>
<!-- Rental Defaults -->
<div class="form-section">
<h3 class="form-section-title">💶 Standardpriser for Udlejning</h3>
<div class="form-grid">
<div class="form-group">
<label for="rental_default_start_price">Standard startpris</label>
<input
type="number"
id="rental_default_start_price"
name="rental_default_start_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_freight_price">Standard fragt</label>
<input
type="number"
id="rental_default_freight_price"
name="rental_default_freight_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_preparation_price">Standard klargoring</label>
<input
type="number"
id="rental_default_preparation_price"
name="rental_default_preparation_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_operations_monthly_price">Standard drift pr. maned</label>
<input
type="number"
id="rental_default_operations_monthly_price"
name="rental_default_operations_monthly_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 }}"
>
</div>
</div>
<div class="form-text mt-2">Bruges til at autoudfylde Udlej-modal pa asseten.</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="form-section"> <div class="form-section">
<h3 class="form-section-title">📝 Noter</h3> <h3 class="form-section-title">📝 Noter</h3>
@ -329,6 +381,13 @@
// Convert customer_id to integer // Convert customer_id to integer
if (key === 'current_owner_customer_id') { if (key === 'current_owner_customer_id') {
data[key] = parseInt(value); data[key] = parseInt(value);
} else if (
key === 'rental_default_start_price' ||
key === 'rental_default_freight_price' ||
key === 'rental_default_preparation_price' ||
key === 'rental_default_operations_monthly_price'
) {
data[key] = Number(value);
} else { } else {
data[key] = value; data[key] = value;
} }

View File

@ -260,7 +260,7 @@ async def list_ordre_drafts(
"""List all ordre drafts (no user filtering).""" """List all ordre drafts (no user filtering)."""
try: try:
query = """ query = """
SELECT id, title, customer_id, notes, layout_number, created_by_user_id, SELECT ordre_drafts.id, ordre_drafts.title, ordre_drafts.customer_id, ordre_drafts.notes, ordre_drafts.layout_number, ordre_drafts.created_by_user_id,
coverage_start, coverage_end, billing_direction, source_subscription_ids, coverage_start, coverage_end, billing_direction, source_subscription_ids,
invoice_aggregate_key, sync_status, export_idempotency_key, invoice_aggregate_key, sync_status, export_idempotency_key,
economic_order_number, economic_invoice_number, economic_order_number, economic_invoice_number,
@ -271,13 +271,13 @@ async def list_ordre_drafts(
FROM ordre_draft_sync_events ev FROM ordre_draft_sync_events ev
WHERE ev.draft_id = ordre_drafts.id WHERE ev.draft_id = ordre_drafts.id
) AS sync_event_count, ) AS sync_event_count,
last_sync_at, created_at, updated_at, last_exported_at ordre_drafts.last_sync_at, ordre_drafts.created_at, ordre_drafts.updated_at, ordre_drafts.last_exported_at
FROM ordre_drafts FROM ordre_drafts
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT event_type, created_at SELECT ordre_draft_sync_events.event_type, ordre_draft_sync_events.created_at
FROM ordre_draft_sync_events FROM ordre_draft_sync_events
WHERE draft_id = ordre_drafts.id WHERE draft_id = ordre_drafts.id
ORDER BY created_at DESC, id DESC ORDER BY ordre_draft_sync_events.created_at DESC, ordre_draft_sync_events.id DESC
LIMIT 1 LIMIT 1
) ev_latest ON TRUE ) ev_latest ON TRUE
ORDER BY updated_at DESC, id DESC ORDER BY updated_at DESC, id DESC

View File

@ -14,6 +14,7 @@ from app.core.database import execute_query, execute_query_single, get_db_connec
from app.core.config import settings from app.core.config import settings
from app.jobs.process_subscriptions import process_subscriptions from app.jobs.process_subscriptions import process_subscriptions
from app.subscriptions.backend.router import create_subscription as create_sag_subscription from app.subscriptions.backend.router import create_subscription as create_sag_subscription
from app.subscriptions.backend.router import create_subscription_asset_binding as create_sag_asset_binding
from app.subscriptions.backend.router import update_subscription as update_sag_subscription from app.subscriptions.backend.router import update_subscription as update_sag_subscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -538,6 +539,102 @@ class AssetBindingCreateInput(BaseModel):
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
class QuickRentCreateInput(BaseModel):
customer_id: int
sag_id: int
start_date: date = Field(default_factory=date.today)
start_price: float = Field(default=0, ge=0)
freight_price: float = Field(default=0, ge=0)
preparation_price: float = Field(default=0, ge=0)
operations_monthly_price: float = Field(gt=0)
initial_operations_months: int = Field(default=2, ge=1, le=12)
notice_period_days: int = Field(default=30, ge=0)
created_by_user_id: Optional[int] = None
@router.get("/hardware/{hardware_id}/quick-rent/preview", response_model=Dict[str, Any])
async def quick_rent_preview(hardware_id: int, customer_id: int = Query(...), sag_id: int = Query(...)):
"""Preview whether quick-rent will reuse an existing subscription or create a new one."""
hardware = execute_query_single(
"""
SELECT id, current_owner_type
FROM hardware_assets
WHERE id = %s
AND deleted_at IS NULL
""",
(hardware_id,),
)
if not hardware:
return {
"can_submit": False,
"action": "blocked",
"message": "Hardware blev ikke fundet.",
}
if (hardware.get("current_owner_type") or "").strip().lower() != "bmc":
return {
"can_submit": False,
"action": "blocked",
"message": "Kun BMC-ejede assets kan udlejes fra denne modal.",
}
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL",
(customer_id,),
)
if not customer:
return {
"can_submit": False,
"action": "blocked",
"message": "Kunde blev ikke fundet.",
}
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not sag:
return {
"can_submit": False,
"action": "blocked",
"message": "Sag blev ikke fundet.",
}
if int(sag.get("customer_id") or 0) != int(customer_id):
return {
"can_submit": False,
"action": "blocked",
"message": "Sag og kunde matcher ikke.",
}
existing_subscription = execute_query_single(
"""
SELECT id, status
FROM sag_subscriptions
WHERE sag_id = %s
AND status IN ('draft', 'active', 'paused')
ORDER BY id DESC
LIMIT 1
""",
(sag_id,),
)
if existing_subscription:
return {
"can_submit": True,
"action": "reuse",
"subscription_id": int(existing_subscription.get("id")),
"message": f"Genbruger abonnement #{int(existing_subscription.get('id'))} paa sagen.",
}
return {
"can_submit": True,
"action": "create",
"subscription_id": None,
"message": "Der findes intet aktivt abonnement paa sagen. Der oprettes et nyt.",
}
class InvoiceGenerateInput(BaseModel): class InvoiceGenerateInput(BaseModel):
preview: bool = False preview: bool = False
customer_id: Optional[int] = None customer_id: Optional[int] = None
@ -706,6 +803,273 @@ async def create_subscription_alias(payload: SubscriptionCreateInput):
return await create_sag_subscription(body) return await create_sag_subscription(body)
@router.post("/hardware/{hardware_id}/quick-rent", response_model=Dict[str, Any])
async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
"""Create a rental subscription + asset binding + startup order draft in one step."""
hardware = execute_query_single(
"""
SELECT id, brand, model, serial_number, current_owner_type
FROM hardware_assets
WHERE id = %s
AND deleted_at IS NULL
""",
(hardware_id,),
)
if not hardware:
raise HTTPException(status_code=404, detail="Hardware not found")
if (hardware.get("current_owner_type") or "").strip().lower() != "bmc":
raise HTTPException(status_code=409, detail="Only BMC-owned assets can be rented from this flow")
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL",
(payload.customer_id,),
)
if not customer:
raise HTTPException(status_code=400, detail="Customer not found")
sag = execute_query_single(
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(payload.sag_id,),
)
if not sag:
raise HTTPException(status_code=400, detail="Sag not found")
if int(sag.get("customer_id") or 0) != int(payload.customer_id):
raise HTTPException(status_code=400, detail="Sag customer mismatch")
_assert_asset_booking_available(
asset_id=hardware_id,
start_date=payload.start_date,
end_date=None,
)
hardware_label = f"{hardware.get('brand') or ''} {hardware.get('model') or ''}".strip() or f"Asset {hardware_id}"
existing_subscription = execute_query_single(
"""
SELECT id, customer_id
FROM sag_subscriptions
WHERE sag_id = %s
AND status IN ('draft', 'active', 'paused')
ORDER BY id DESC
LIMIT 1
""",
(payload.sag_id,),
)
created_new_subscription = False
if existing_subscription:
subscription_id = int(existing_subscription.get("id") or 0)
if int(existing_subscription.get("customer_id") or 0) != int(payload.customer_id):
raise HTTPException(status_code=400, detail="Existing subscription customer mismatch for sag")
duplicate_line = execute_query_single(
"""
SELECT id
FROM sag_subscription_items
WHERE subscription_id = %s
AND asset_id = %s
LIMIT 1
""",
(subscription_id, hardware_id),
)
if duplicate_line:
raise HTTPException(status_code=409, detail="Asset already exists on subscription line items for this sag")
next_line_row = execute_query_single(
"SELECT COALESCE(MAX(line_no), 0) + 1 AS next_line_no FROM sag_subscription_items WHERE subscription_id = %s",
(subscription_id,),
)
next_line_no = int((next_line_row or {}).get("next_line_no") or 1)
execute_query(
"""
INSERT INTO sag_subscription_items (
subscription_id,
line_no,
product_id,
asset_id,
description,
quantity,
unit_price,
line_total,
period_from,
period_to,
price_type,
custom_price_override,
requires_serial_number,
serial_number,
billing_blocked,
billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription_id,
next_line_no,
None,
hardware_id,
f"Drift - {hardware_label}",
1,
float(payload.operations_monthly_price),
float(payload.operations_monthly_price),
payload.start_date,
None,
"manual",
True,
False,
hardware.get("serial_number"),
False,
None,
),
)
execute_query(
"""
UPDATE sag_subscriptions
SET price = COALESCE(price, 0) + %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(float(payload.operations_monthly_price), subscription_id),
)
else:
subscription_payload = {
"customer_id": payload.customer_id,
"sag_id": payload.sag_id,
"product_name": f"Udlejning - {hardware_label}",
"price": float(payload.operations_monthly_price),
"billing_interval": "monthly",
"billing_day": min(max(payload.start_date.day, 1), 28),
"start_date": payload.start_date.isoformat(),
"end_date": None,
"binding_months": 0,
"billing_direction": "forward",
"price_type": "manual",
"custom_price_override": True,
"first_invoice_policy": "next_cycle",
"invoice_merge_key": f"rental-{payload.customer_id}-{payload.sag_id}",
"notes": f"Quick rent created from hardware #{hardware_id}",
"line_items": [
{
"description": f"Drift - {hardware_label}",
"quantity": 1,
"unit_price": float(payload.operations_monthly_price),
"asset_id": hardware_id,
"serial_number": hardware.get("serial_number"),
"price_type": "manual",
"custom_price_override": True,
}
],
}
subscription = await create_sag_subscription(subscription_payload)
subscription_id = int(subscription.get("id") or 0)
if subscription_id <= 0:
raise HTTPException(status_code=500, detail="Subscription creation did not return an id")
created_new_subscription = True
binding = await create_sag_asset_binding(
subscription_id,
{
"asset_id": hardware_id,
"start_date": payload.start_date.isoformat(),
"end_date": None,
"binding_months": 0,
"shared_binding_key": f"rental-{payload.customer_id}-{payload.sag_id}",
"notice_period_days": payload.notice_period_days,
"sag_id": payload.sag_id,
"created_by_user_id": payload.created_by_user_id,
},
)
startup_lines: List[Dict[str, Any]] = []
def _append_line(key: str, description: str, quantity: float, unit_price: float) -> None:
if quantity <= 0 or unit_price < 0:
return
startup_lines.append(
{
"line_key": key,
"source_type": "manual",
"source_id": hardware_id,
"description": description,
"quantity": float(quantity),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
}
)
_append_line("rental-start", f"Start - {hardware_label}", 1, float(payload.start_price))
_append_line("rental-freight", "Fragt", 1, float(payload.freight_price))
_append_line("rental-preparation", "Klargoring", 1, float(payload.preparation_price))
_append_line(
"rental-operations-initial",
f"Drift (forste {payload.initial_operations_months} maned(er))",
float(payload.initial_operations_months),
float(payload.operations_monthly_price),
)
if not startup_lines:
raise HTTPException(status_code=400, detail="At least one startup/order line must have a price")
coverage_end = payload.start_date + relativedelta(months=payload.initial_operations_months)
order_result = execute_query(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
coverage_start,
coverage_end,
billing_direction,
source_subscription_ids,
invoice_aggregate_key,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING id, title, customer_id, sync_status, created_at
""",
(
f"Udlejning opstart: {hardware_label}",
payload.customer_id,
json.dumps(startup_lines, ensure_ascii=False),
(
f"Quick rent startup order for {hardware_label}\n"
f"Sag: #{payload.sag_id}\n"
f"Subscription: #{subscription_id}\n"
f"Asset: #{hardware_id}"
),
payload.start_date,
coverage_end,
"forward",
[subscription_id],
f"quick-rent-{subscription_id}-{hardware_id}-{payload.start_date.isoformat()}",
1,
payload.created_by_user_id,
"pending",
json.dumps({"source": "quick_rent", "subscription_id": subscription_id}, ensure_ascii=False),
),
)
order_draft = (order_result or [None])[0]
return {
"status": "ok",
"hardware_id": hardware_id,
"subscription_id": subscription_id,
"created_new_subscription": created_new_subscription,
"asset_binding_id": binding.get("id") if isinstance(binding, dict) else None,
"ordre_draft_id": order_draft.get("id") if order_draft else None,
"message": "Rental flow completed: subscription, binding and startup order draft created",
}
@router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any]) @router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any])
async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput): async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput):
body = payload.model_dump(exclude_none=True) body = payload.model_dump(exclude_none=True)

View File

@ -858,6 +858,98 @@ async def get_sag(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to get case") raise HTTPException(status_code=500, detail="Failed to get case")
@router.post("/sag/recent/open/{sag_id:int}")
async def mark_sag_recent_open(sag_id: int, request: Request):
"""Persist that authenticated user opened a case (deduped, newest-first, max 10)."""
user_id = _get_user_id_from_request(request)
try:
case_row = execute_query_single(
"""
SELECT id, titel
FROM sag_sager
WHERE id = %s
AND deleted_at IS NULL
""",
(sag_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
upserted = execute_query(
"""
INSERT INTO sag_recent_cases (user_id, sag_id, opened_at)
VALUES (%s, %s, NOW())
ON CONFLICT (user_id, sag_id)
DO UPDATE SET opened_at = EXCLUDED.opened_at
RETURNING user_id, sag_id, opened_at
""",
(user_id, sag_id),
)
execute_query(
"""
DELETE FROM sag_recent_cases
WHERE user_id = %s
AND id IN (
SELECT id
FROM sag_recent_cases
WHERE user_id = %s
ORDER BY opened_at DESC, id DESC
OFFSET 10
)
""",
(user_id, user_id),
)
row = (upserted or [{}])[0]
return {
"sag_id": row.get("sag_id", sag_id),
"titel": case_row.get("titel"),
"opened_at": row.get("opened_at"),
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error persisting recent case open for user %s and case %s: %s", user_id, sag_id, e)
raise HTTPException(status_code=500, detail="Failed to persist recent case open")
@router.get("/sag/recent")
async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10)):
"""List authenticated user's recently opened cases (newest first)."""
user_id = _get_user_id_from_request(request)
try:
rows = execute_query(
"""
SELECT
r.sag_id,
s.titel,
r.opened_at
FROM sag_recent_cases r
JOIN sag_sager s ON s.id = r.sag_id
WHERE r.user_id = %s
AND s.deleted_at IS NULL
ORDER BY r.opened_at DESC, r.id DESC
LIMIT %s
""",
(user_id, limit),
) or []
return [
{
"sag_id": row.get("sag_id"),
"titel": row.get("titel"),
"opened_at": row.get("opened_at"),
}
for row in rows
]
except Exception as e:
logger.error("❌ Error listing recent cases for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to list recent cases")
@router.get("/sag/{sag_id}/modules") @router.get("/sag/{sag_id}/modules")
async def get_case_module_prefs(sag_id: int): async def get_case_module_prefs(sag_id: int):
"""Get module visibility preferences for a case.""" """Get module visibility preferences for a case."""
@ -1104,6 +1196,23 @@ async def update_sag(sag_id: int, updates: dict):
if "customer_id" in updates: if "customer_id" in updates:
updates["customer_id"] = _coerce_optional_int(updates.get("customer_id"), "customer_id") updates["customer_id"] = _coerce_optional_int(updates.get("customer_id"), "customer_id")
_validate_customer_id(updates["customer_id"]) _validate_customer_id(updates["customer_id"])
if "supplier_flow_type" in updates:
flow_type = str(updates.get("supplier_flow_type") or "").strip().lower()
if flow_type and flow_type not in {"varekob", "ydelse"}:
raise HTTPException(status_code=400, detail="supplier_flow_type must be varekob or ydelse")
updates["supplier_flow_type"] = flow_type or None
if "supplier_flow_confidence" in updates:
conf = updates.get("supplier_flow_confidence")
if conf in (None, ""):
updates["supplier_flow_confidence"] = None
else:
try:
conf_num = float(conf)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="supplier_flow_confidence must be a number between 0 and 1")
if conf_num < 0 or conf_num > 1:
raise HTTPException(status_code=400, detail="supplier_flow_confidence must be between 0 and 1")
updates["supplier_flow_confidence"] = conf_num
# Build dynamic update query # Build dynamic update query
allowed_fields = [ allowed_fields = [
@ -1120,6 +1229,8 @@ async def update_sag(sag_id: int, updates: dict):
"deferred_until", "deferred_until",
"deferred_until_case_id", "deferred_until_case_id",
"deferred_until_status", "deferred_until_status",
"supplier_flow_type",
"supplier_flow_confidence",
] ]
set_clauses = [] set_clauses = []
params = [] params = []
@ -2240,6 +2351,73 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True):
# VAREKØB & SALG - CRUD (Case-linked sale items) # VAREKØB & SALG - CRUD (Case-linked sale items)
# ============================================================================ # ============================================================================
_PURCHASE_PURPOSE_VALUES = {
"salg",
"lager",
"asset",
"intern_brug",
"retur_reklamation",
"projekt_omkostning",
}
def _normalize_purchase_purpose(value: Optional[object], item_type: str) -> Optional[str]:
if item_type != "purchase":
return None
if value is None:
return None
normalized = str(value).strip().lower()
if not normalized:
return None
if normalized not in _PURCHASE_PURPOSE_VALUES:
allowed = ", ".join(sorted(_PURCHASE_PURPOSE_VALUES))
raise HTTPException(status_code=400, detail=f"purchase_purpose must be one of: {allowed}")
return normalized
def _resolve_purchase_traceability(
supplier_invoice_id_value: Optional[object],
supplier_invoice_line_id_value: Optional[object],
) -> tuple[Optional[int], Optional[int]]:
supplier_invoice_id = _coerce_optional_int(supplier_invoice_id_value, "supplier_invoice_id")
supplier_invoice_line_id = _coerce_optional_int(supplier_invoice_line_id_value, "supplier_invoice_line_id")
if supplier_invoice_line_id is not None and supplier_invoice_id is None:
line_row = execute_query_single(
"SELECT supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s",
(supplier_invoice_line_id,)
)
if not line_row:
raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id")
supplier_invoice_id = int(line_row["supplier_invoice_id"])
if supplier_invoice_id is not None:
invoice_exists = execute_query_single(
"SELECT id FROM supplier_invoices WHERE id = %s",
(supplier_invoice_id,)
)
if not invoice_exists:
raise HTTPException(status_code=400, detail="Invalid supplier_invoice_id")
if supplier_invoice_line_id is not None:
line_exists = execute_query_single(
"SELECT id, supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s",
(supplier_invoice_line_id,)
)
if not line_exists:
raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id")
if supplier_invoice_id is not None and int(line_exists["supplier_invoice_id"]) != supplier_invoice_id:
raise HTTPException(
status_code=400,
detail="supplier_invoice_line_id does not belong to supplier_invoice_id",
)
return supplier_invoice_id, supplier_invoice_line_id
@router.get("/sag/{sag_id}/sale-items") @router.get("/sag/{sag_id}/sale-items")
async def list_sale_items(sag_id: int): async def list_sale_items(sag_id: int):
"""List sale items for a case.""" """List sale items for a case."""
@ -2290,6 +2468,47 @@ async def create_sale_item(sag_id: int, data: dict):
if status not in ("draft", "confirmed", "cancelled"): if status not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
purchase_purpose = None
supplier_invoice_id = None
supplier_invoice_line_id = None
if has_purchase_columns:
purchase_purpose = _normalize_purchase_purpose(data.get("purchase_purpose"), item_type)
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
data.get("supplier_invoice_id"),
data.get("supplier_invoice_line_id"),
)
if item_type != "purchase":
supplier_invoice_id = None
supplier_invoice_line_id = None
if has_purchase_columns:
query = """
INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
purchase_purpose, supplier_invoice_id, supplier_invoice_line_id)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
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"),
data.get("product_id"),
purchase_purpose,
supplier_invoice_id,
supplier_invoice_line_id,
)
else:
query = """ query = """
INSERT INTO sag_salgsvarer INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id) (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
@ -2349,7 +2568,7 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
"""Update a sale item for a case.""" """Update a sale item for a case."""
try: try:
check = execute_query( check = execute_query(
"SELECT id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s", "SELECT id, type FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
(item_id, sag_id) (item_id, sag_id)
) )
if not check: if not check:
@ -2367,10 +2586,18 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
"line_date", "line_date",
"external_ref", "external_ref",
"product_id", "product_id",
"purchase_purpose",
"supplier_invoice_id",
"supplier_invoice_line_id",
] ]
set_clauses = [] set_clauses = []
params = [] params = []
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
current_type = (check[0].get("type") or "sale").lower()
next_type = current_type
if "type" in updates:
next_type = (updates.get("type") or "").lower()
for field in allowed_fields: for field in allowed_fields:
if field in updates: if field in updates:
@ -2386,10 +2613,56 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
set_clauses.append("status = %s") set_clauses.append("status = %s")
params.append(value) params.append(value)
elif field == "purchase_purpose":
if not has_purchase_columns:
continue
value = _normalize_purchase_purpose(updates.get(field), next_type)
set_clauses.append("purchase_purpose = %s")
params.append(value)
elif field in ("supplier_invoice_id", "supplier_invoice_line_id"):
# handled together below to keep consistency between IDs
continue
else: else:
set_clauses.append(f"{field} = %s") set_clauses.append(f"{field} = %s")
params.append(updates[field]) params.append(updates[field])
if has_purchase_columns and (
"supplier_invoice_id" in updates
or "supplier_invoice_line_id" in updates
or next_type != current_type
):
raw_supplier_invoice_id = updates.get("supplier_invoice_id") if "supplier_invoice_id" in updates else None
raw_supplier_invoice_line_id = updates.get("supplier_invoice_line_id") if "supplier_invoice_line_id" in updates else None
if "supplier_invoice_id" not in updates or "supplier_invoice_line_id" not in updates:
current_refs = execute_query_single(
"SELECT supplier_invoice_id, supplier_invoice_line_id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
(item_id, sag_id)
) or {}
if "supplier_invoice_id" not in updates:
raw_supplier_invoice_id = current_refs.get("supplier_invoice_id")
if "supplier_invoice_line_id" not in updates:
raw_supplier_invoice_line_id = current_refs.get("supplier_invoice_line_id")
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
raw_supplier_invoice_id,
raw_supplier_invoice_line_id,
)
if next_type != "purchase":
supplier_invoice_id = None
supplier_invoice_line_id = None
set_clauses.append("supplier_invoice_id = %s")
params.append(supplier_invoice_id)
set_clauses.append("supplier_invoice_line_id = %s")
params.append(supplier_invoice_line_id)
if has_purchase_columns and next_type != "purchase":
if "purchase_purpose" not in updates:
set_clauses.append("purchase_purpose = %s")
params.append(None)
if not set_clauses: if not set_clauses:
raise HTTPException(status_code=400, detail="No valid fields to update") raise HTTPException(status_code=400, detail="No valid fields to update")

View File

@ -166,13 +166,15 @@ async def sager_liste(
customer_id: str = Query(None), customer_id: str = Query(None),
ansvarlig_bruger_id: str = Query(None), ansvarlig_bruger_id: str = Query(None),
assigned_group_id: str = Query(None), assigned_group_id: str = Query(None),
unassigned: bool = Query(False),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: try:
# Coerce string params to optional ints # Coerce string params to optional ints
customer_id_int = _coerce_optional_int(customer_id) customer_id_int = _coerce_optional_int(customer_id)
ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id) requested_unassigned = bool(unassigned) or str(ansvarlig_bruger_id or "").strip().upper() == "__UNASSIGNED__"
ansvarlig_bruger_id_int = None if requested_unassigned else _coerce_optional_int(ansvarlig_bruger_id)
assigned_group_id_int = _coerce_optional_int(assigned_group_id) assigned_group_id_int = _coerce_optional_int(assigned_group_id)
query = """ query = """
SELECT s.*, SELECT s.*,
@ -245,7 +247,9 @@ async def sager_liste(
if customer_id_int: if customer_id_int:
query += " AND s.customer_id = %s" query += " AND s.customer_id = %s"
params.append(customer_id_int) params.append(customer_id_int)
if ansvarlig_bruger_id_int: if requested_unassigned:
query += " AND s.ansvarlig_bruger_id IS NULL"
elif ansvarlig_bruger_id_int:
query += " AND s.ansvarlig_bruger_id = %s" query += " AND s.ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id_int) params.append(ansvarlig_bruger_id_int)
if assigned_group_id_int: if assigned_group_id_int:
@ -300,7 +304,9 @@ async def sager_liste(
if customer_id_int: if customer_id_int:
fallback_query += " AND s.customer_id = %s" fallback_query += " AND s.customer_id = %s"
fallback_params.append(customer_id_int) fallback_params.append(customer_id_int)
if ansvarlig_bruger_id_int: if requested_unassigned:
fallback_query += " AND s.ansvarlig_bruger_id IS NULL"
elif ansvarlig_bruger_id_int:
fallback_query += " AND s.ansvarlig_bruger_id = %s" fallback_query += " AND s.ansvarlig_bruger_id = %s"
fallback_params.append(ansvarlig_bruger_id_int) fallback_params.append(ansvarlig_bruger_id_int)
@ -382,6 +388,7 @@ async def sager_liste(
"current_customer_id": customer_id_int, "current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
}) })
except Exception: except Exception:
logger.exception("❌ Error displaying case list") logger.exception("❌ Error displaying case list")
@ -401,6 +408,7 @@ async def sager_liste(
"current_customer_id": customer_id_int, "current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
}) })
@router.get("/sag/new", response_class=HTMLResponse) @router.get("/sag/new", response_class=HTMLResponse)

File diff suppressed because it is too large Load Diff

View File

@ -5,13 +5,36 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.search-bar { .search-bar {
margin-bottom: 1.5rem; margin-bottom: 0;
flex: 1 1 380px;
min-width: 240px;
} }
.search-bar input { .search-bar input {
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
padding: 0.6rem 1rem; padding: 0.45rem 0.85rem;
}
.top-controls-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.9rem;
}
.top-controls-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-left: auto;
flex-shrink: 0;
}
.top-controls-actions .btn {
white-space: nowrap;
padding-top: 0.4rem;
padding-bottom: 0.4rem;
} }
.table-wrapper { .table-wrapper {
@ -49,6 +72,10 @@
cursor: pointer; cursor: pointer;
} }
.sag-table tbody tr:nth-child(even) {
background: rgba(15, 76, 117, 0.04);
}
.sag-table tbody tr:hover { .sag-table tbody tr:hover {
background: var(--accent-light); background: var(--accent-light);
} }
@ -264,30 +291,72 @@
} }
.stats-bar { .stats-bar {
display: flex; display: inline-flex;
gap: 2rem; gap: 0.4rem;
margin-bottom: 1.5rem; margin-bottom: 0;
padding: 1rem; padding: 0.25rem;
background: var(--bg-card); background: var(--bg-card);
border-radius: 8px; border-radius: 999px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.08);
flex-shrink: 0;
} }
.stat-item { .stat-item {
text-align: center; text-align: center;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(0,0,0,0.03);
line-height: 1.15;
} }
.stat-value { .stat-value {
font-size: 1.5rem; font-size: 0.92rem;
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--accent);
} }
.stat-label { .stat-label {
font-size: 0.8rem; font-size: 0.62rem;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.35px;
}
.owner-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-avatar {
width: 1.6rem;
height: 1.6rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
background: var(--accent);
flex-shrink: 0;
}
.owner-name {
color: var(--text-secondary);
font-size: 0.85rem;
word-break: break-word;
}
@media (max-width: 1100px) {
.top-controls-row {
flex-wrap: wrap;
}
.top-controls-actions {
margin-left: 0;
}
} }
.empty-state { .empty-state {
@ -322,25 +391,14 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid" style="max-width: none; padding-top: 2rem;"> <div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div> <div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<!-- Header --> <div class="top-controls-row">
<div class="d-flex justify-content-between align-items-center mb-4"> <h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
<h1 style="margin: 0; color: var(--accent);"> <i class="bi bi-list-check"></i>
<i class="bi bi-list-check me-2"></i>Sager
</h1> </h1>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
<i class="bi bi-plus-lg me-2"></i>Ny Sag
</button>
</div>
</div>
<!-- Stats Bar -->
<div class="stats-bar"> <div class="stats-bar">
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ sager|length }}</div> <div class="stat-value">{{ sager|length }}</div>
@ -350,13 +408,8 @@
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div> <div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
<div class="stat-label">Åbne</div> <div class="stat-label">Åbne</div>
</div> </div>
<div class="stat-item">
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'lukket')|list|length }}</div>
<div class="stat-label">Lukkede</div>
</div>
</div> </div>
<!-- Search & Filters -->
<div class="search-bar"> <div class="search-bar">
<input type="text" <input type="text"
class="form-control" class="form-control"
@ -365,6 +418,16 @@
autocomplete="off"> autocomplete="off">
</div> </div>
<div class="top-controls-actions">
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
<i class="bi bi-plus-lg me-2"></i>Ny Sag
</button>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-3 mb-3"> <div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="filter-pills"> <div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div> <div class="filter-pill active" data-filter="all">Alle</div>
@ -380,6 +443,7 @@
<div style="min-width: 220px;"> <div style="min-width: 220px;">
<select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter"> <select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter">
<option value="">Alle medarbejdere</option> <option value="">Alle medarbejdere</option>
<option value="__UNASSIGNED__" {% if current_unassigned %}selected{% endif %}>Uden ansvarlig</option>
{% for user in assignment_users or [] %} {% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option> <option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %} {% endfor %}
@ -450,10 +514,7 @@
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }} {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td> </td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'"> <td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div> <div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
{% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
@ -461,8 +522,19 @@
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ sag.priority if sag.priority else 'normal' }} {{ sag.priority if sag.priority else 'normal' }}
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }} {% if sag.ansvarlig_navn %}
{% set owner_name = sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
<div class="owner-cell">
<span class="owner-avatar">
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
</span>
<span class="owner-name">{{ owner_name }}</span>
</div>
{% else %}
-
{% endif %}
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }} {{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
@ -517,10 +589,7 @@
{% for rt in all_rel_types %} {% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span> <span class="relation-badge">{{ rt }}</span>
{% endfor %} {% endfor %}
<div class="sag-titel" style="display: inline;">{{ related_sag.titel }}</div> <div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
{% if related_sag.beskrivelse %}
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
{% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
@ -528,8 +597,19 @@
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ related_sag.priority if related_sag.priority else 'normal' }} {{ related_sag.priority if related_sag.priority else 'normal' }}
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }} {% if related_sag.ansvarlig_navn %}
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
<div class="owner-cell">
<span class="owner-avatar">
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
</span>
<span class="owner-name">{{ owner_name }}</span>
</div>
{% else %}
-
{% endif %}
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }} {{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}

View File

@ -57,6 +57,40 @@ def _extract_create_table_block(sql: str, start_pos: int) -> str:
return "" return ""
def _migration_numeric_prefix(file_name: str) -> int | None:
match = re.match(r"^(\d+)_", file_name)
if not match:
return None
try:
return int(match.group(1))
except Exception:
return None
def _migration_sort_key(migration_path: Path) -> tuple[int, int, str]:
number = _migration_numeric_prefix(migration_path.name)
if number is None:
# Non-numbered files go last, alphabetically.
return (1, 0, migration_path.name.lower())
return (0, number, migration_path.name.lower())
def _find_duplicate_migration_numbers(file_names: list[str]) -> list[dict]:
grouped: dict[int, list[str]] = {}
for name in file_names:
number = _migration_numeric_prefix(name)
if number is None:
continue
grouped.setdefault(number, []).append(name)
duplicates = []
for number in sorted(grouped.keys()):
files = sorted(grouped[number], key=lambda n: n.lower())
if len(files) > 1:
duplicates.append({"number": number, "files": files})
return duplicates
def _parse_columns_from_create_block(block: str) -> set[str]: def _parse_columns_from_create_block(block: str) -> set[str]:
columns: set[str] = set() columns: set[str] = set()
known_types = { known_types = {
@ -226,16 +260,21 @@ async def migrations_page(request: Request):
"""Render database migrations page""" """Render database migrations page"""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations" migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
migrations = [] migrations = []
migration_file_names = []
if migrations_dir.exists(): if migrations_dir.exists():
for migration_file in sorted(migrations_dir.glob("*.sql")): for migration_file in sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key):
stat = migration_file.stat() stat = migration_file.stat()
migration_file_names.append(migration_file.name)
migrations.append({ migrations.append({
"name": migration_file.name, "name": migration_file.name,
"size_kb": round(stat.st_size / 1024, 1), "size_kb": round(stat.st_size / 1024, 1),
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
"number": _migration_numeric_prefix(migration_file.name),
}) })
duplicate_numbers = _find_duplicate_migration_numbers(migration_file_names)
return templates.TemplateResponse("settings/frontend/migrations.html", { return templates.TemplateResponse("settings/frontend/migrations.html", {
"request": request, "request": request,
"title": "Database Migrationer", "title": "Database Migrationer",
@ -243,7 +282,8 @@ async def migrations_page(request: Request):
"db_user": settings.POSTGRES_USER, "db_user": settings.POSTGRES_USER,
"db_name": settings.POSTGRES_DB, "db_name": settings.POSTGRES_DB,
"db_container": "bmc-hub-postgres", "db_container": "bmc-hub-postgres",
"is_production": request.url.hostname not in ['localhost', '127.0.0.1', '0.0.0.0'] "is_production": request.url.hostname not in ['localhost', '127.0.0.1', '0.0.0.0'],
"duplicate_numbers": duplicate_numbers,
}) })
@ -255,7 +295,7 @@ class MigrationExecution(BaseModel):
def migration_statuses(): def migration_statuses():
"""Check migration files against current schema and return per-file color status.""" """Check migration files against current schema and return per-file color status."""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations" migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
files = sorted(migrations_dir.glob("*.sql")) if migrations_dir.exists() else [] files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
conn = get_db_connection() conn = get_db_connection()
try: try:

View File

@ -46,6 +46,20 @@
</div> </div>
</div> </div>
{% if duplicate_numbers and duplicate_numbers|length > 0 %}
<div class="alert alert-danger d-flex align-items-start" role="alert">
<i class="bi bi-exclamation-octagon me-2 mt-1"></i>
<div>
<strong>Advarsel:</strong> Flere migrationer deler samme nummer.
<div class="small mt-1">
{% for duplicate in duplicate_numbers %}
#{{ duplicate.number }}: {{ duplicate.files | join(', ') }}{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">

View File

@ -68,6 +68,18 @@
transform: translateY(calc(100% + 12px)); transform: translateY(calc(100% + 12px));
opacity: 0; opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease; transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
isolation: isolate;
}
.global-bottom-bar::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
background: linear-gradient(120deg, rgba(15, 76, 117, 0.08), rgba(15, 76, 117, 0.02) 35%, rgba(255, 255, 255, 0));
z-index: -1;
} }
.global-bottom-bar.is-visible { .global-bottom-bar.is-visible {
@ -163,6 +175,11 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
transition: all 0.2s ease;
}
.global-bottom-bar .dropdown-menu {
z-index: calc(var(--bottom-bar-zindex) + 40);
} }
.global-bottom-bar .bb-action-btn:hover { .global-bottom-bar .bb-action-btn:hover {
@ -228,6 +245,24 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.global-bottom-bar .bb-chip .bb-chip-label {
opacity: 0.95;
}
.global-bottom-bar .bb-chip .bb-chip-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.35rem;
border-radius: 999px;
padding: 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
background: rgba(var(--text-primary-rgb), 0.15);
color: currentColor;
}
.global-bottom-bar .bb-chip:hover { .global-bottom-bar .bb-chip:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.08); box-shadow: 0 3px 8px rgba(0,0,0,0.08);
@ -246,18 +281,30 @@
color: #146c43; color: #146c43;
} }
.global-bottom-bar .bb-chip.sev-ok .bb-chip-bubble {
background: rgba(25, 135, 84, 0.2);
}
.global-bottom-bar .bb-chip.sev-warn { .global-bottom-bar .bb-chip.sev-warn {
background: rgba(255, 193, 7, 0.22); background: rgba(255, 193, 7, 0.22);
border-color: rgba(255, 193, 7, 0.5); border-color: rgba(255, 193, 7, 0.5);
color: #8a6d00; color: #8a6d00;
} }
.global-bottom-bar .bb-chip.sev-warn .bb-chip-bubble {
background: rgba(255, 193, 7, 0.3);
}
.global-bottom-bar .bb-chip.sev-critical { .global-bottom-bar .bb-chip.sev-critical {
background: rgba(220, 53, 69, 0.14); background: rgba(220, 53, 69, 0.14);
border-color: rgba(220, 53, 69, 0.4); border-color: rgba(220, 53, 69, 0.4);
color: #b02a37; color: #b02a37;
} }
.global-bottom-bar .bb-chip.sev-critical .bb-chip-bubble {
background: rgba(220, 53, 69, 0.22);
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-ok { [data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-ok {
color: #75d39a; color: #75d39a;
} }
@ -269,6 +316,10 @@
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-critical { [data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-critical {
color: #ff9aa2; color: #ff9aa2;
} }
[data-bs-theme="dark"] .global-bottom-bar .bb-chip .bb-chip-bubble {
background: rgba(255, 255, 255, 0.12);
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.is-active { [data-bs-theme="dark"] .global-bottom-bar .bb-chip.is-active {
color: #fff; color: #fff;
} }
@ -279,6 +330,7 @@
} }
.global-bottom-bar .bb-detail-line { .global-bottom-bar .bb-detail-line {
display: none;
margin-top: 0.5rem; margin-top: 0.5rem;
background: transparent; background: transparent;
padding: 0 0.5rem; padding: 0 0.5rem;
@ -287,12 +339,19 @@
white-space: nowrap; white-space: nowrap;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
transition: opacity 0.2s ease;
} }
.global-bottom-bar.is-expanded .bb-detail-line { .global-bottom-bar.is-expanded .bb-detail-line {
opacity: 0; display: block;
pointer-events: none; }
position: absolute;
.global-bottom-bar:not(.is-expanded) .bb-header {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.global-bottom-bar:not(.is-expanded) .bb-header::-webkit-scrollbar {
display: none;
} }
.global-bottom-bar .bb-sheet-panel { .global-bottom-bar .bb-sheet-panel {
@ -397,10 +456,40 @@
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03); box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: transform 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.global-bottom-bar .bb-tab-list li:hover { .global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px); transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
}
#bbSwitchCaseModal .modal-content {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
border-radius: 14px;
background: var(--bg-card);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
#bbSwitchCaseModal .modal-header {
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08);
}
#bbSwitchCaseModal .list-group-item {
border-color: rgba(var(--text-primary-rgb), 0.08);
transition: background-color 0.2s ease;
}
#bbSwitchCaseModal .list-group-item:hover {
background-color: rgba(var(--text-primary-rgb), 0.03);
}
#bbQuickNoteInput {
border-color: rgba(var(--text-primary-rgb), 0.12);
}
#bbQuickNoteInput:focus {
border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.12);
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
@ -419,14 +508,14 @@
min-height: 240px; min-height: 240px;
} }
.global-bottom-bar .bb-header { .global-bottom-bar.is-expanded .bb-header {
flex-wrap: wrap; flex-wrap: wrap;
row-gap: 0.4rem; row-gap: 0.4rem;
} }
.global-bottom-bar .bb-zone-left, .global-bottom-bar.is-expanded .bb-zone-left,
.global-bottom-bar .bb-zone-center, .global-bottom-bar.is-expanded .bb-zone-center,
.global-bottom-bar .bb-zone-right { .global-bottom-bar.is-expanded .bb-zone-right {
flex: 1 1 100%; flex: 1 1 100%;
justify-content: flex-start; justify-content: flex-start;
} }
@ -958,23 +1047,11 @@
<div id="globalBottomBar" class="global-bottom-bar" hidden> <div id="globalBottomBar" class="global-bottom-bar" hidden>
<div class="bb-header"> <div class="bb-header">
<div class="bb-zone bb-zone-left" role="status" aria-live="polite"> <div class="bb-zone bb-zone-left" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-text">Ulæste mails: 0</span></button> <button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-label">Ulæste mails</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Ulæste mails: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-text">Hastesager: 0</span></button> <button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-label">Hastesager</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Hastesager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-text">Uden ansvarlig: 0</span></button> <button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button>
</div> </div>
<div class="bb-zone bb-zone-center"> <div class="bb-zone bb-zone-center">
<div class="dropdown">
<button id="bbQuickCreateBtn" class="bb-action-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-plus-circle me-1"></i> Opret
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" type="button" data-bb-create="new_case">Ny sag</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="new_mail">Ny mail</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="start_timer">Start timer</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="log_time">Log tid</button></li>
<li><button class="dropdown-item" type="button" data-bb-create="add_note">Tilføj note</button></li>
</ul>
</div>
<button id="bbSearchBtn" class="bb-action-btn bb-search-btn" type="button" title="Søg (Cmd/Ctrl+K)"> <button id="bbSearchBtn" class="bb-action-btn bb-search-btn" type="button" title="Søg (Cmd/Ctrl+K)">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
</button> </button>
@ -988,10 +1065,6 @@
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
<span id="bbNotificationsCount" class="bb-notification-count">0</span> <span id="bbNotificationsCount" class="bb-notification-count">0</span>
</button> </button>
<button id="bbUserStatusBtn" class="bb-activity-chip" type="button" title="Profil">
<i class="bi bi-person-circle"></i>
<span id="bbUserStatusText">Bruger</span>
</button>
<button id="bbTimerPauseBtn" class="bb-action-btn" type="button" title="Pause timer"><i class="bi bi-pause-fill"></i></button> <button id="bbTimerPauseBtn" class="bb-action-btn" type="button" title="Pause timer"><i class="bi bi-pause-fill"></i></button>
<button id="bbTimerStopBtn" class="bb-action-btn" type="button" title="Stop timer"><i class="bi bi-stop-fill"></i></button> <button id="bbTimerStopBtn" class="bb-action-btn" type="button" title="Stop timer"><i class="bi bi-stop-fill"></i></button>
<button id="bbTimerSwitchBtn" class="bb-action-btn" type="button" title="Skift sag"><i class="bi bi-arrow-left-right"></i></button> <button id="bbTimerSwitchBtn" class="bb-action-btn" type="button" title="Skift sag"><i class="bi bi-arrow-left-right"></i></button>
@ -1009,6 +1082,7 @@
<button class="bb-tab-btn" type="button" data-bb-tab="timer" role="tab" aria-selected="false"><i class="bi bi-stopwatch"></i> Timer</button> <button class="bb-tab-btn" type="button" data-bb-tab="timer" role="tab" aria-selected="false"><i class="bi bi-stopwatch"></i> Timer</button>
<button class="bb-tab-btn" type="button" data-bb-tab="messages" role="tab" aria-selected="false"><i class="bi bi-chat-dots"></i> Beskeder</button> <button class="bb-tab-btn" type="button" data-bb-tab="messages" role="tab" aria-selected="false"><i class="bi bi-chat-dots"></i> Beskeder</button>
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button> <button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
<!-- Vises kun for chefer, men her i markup --> <!-- Vises kun for chefer, men her i markup -->
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-person-workspace"></i> Chef</button> <button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-person-workspace"></i> Chef</button>
</div> </div>
@ -1024,6 +1098,79 @@
</div> </div>
</div> </div>
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel">
<div class="modal-dialog modal-dialog-scrollable modal-lg modal-dialog-bottom">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbSwitchCaseStatus" class="small text-muted mb-3">Henter data...</div>
<div id="bbSwitchTimerActions" class="mb-3 d-none">
<div class="fw-semibold mb-2">Aktiv timer fundet</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-warning btn-sm" data-bb-switch-action="pause-now"><i class="bi bi-pause-fill me-1"></i>Pause nu</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-bb-switch-action="stop-now"><i class="bi bi-stop-fill me-1"></i>Stop nu</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bb-switch-action="continue-unchanged">Fortsæt uændret</button>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<h6 class="mb-2">Dine aktive/pausede timere</h6>
<div id="bbSwitchTimersList" class="list-group small">
<div class="list-group-item text-muted">Henter timere...</div>
</div>
</div>
<div class="col-12 col-lg-6">
<h6 class="mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="list-group small">
<div class="list-group-item text-muted">Henter sager...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bbNoteTargetModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbNoteTargetModalLabel">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbNoteTargetModalLabel"><i class="bi bi-journal-plus me-2"></i>Indsæt note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbNoteTargetStatus" class="small text-muted mb-3">Vælg mål og indsæt tekst.</div>
<div class="mb-3">
<label for="bbNoteTargetIdInput" id="bbNoteTargetIdLabel" class="form-label">Mål ID</label>
<input type="number" class="form-control" id="bbNoteTargetIdInput" min="1" step="1" placeholder="ID">
</div>
<div class="mb-3 d-none" id="bbNoteTargetFieldWrap">
<label for="bbNoteTargetFieldSelect" id="bbNoteTargetFieldLabel" class="form-label">Felt</label>
<select id="bbNoteTargetFieldSelect" class="form-select"></select>
</div>
<div class="mb-3">
<label for="bbNoteTargetTextInput" class="form-label">Tekst der indsættes</label>
<textarea id="bbNoteTargetTextInput" class="form-control" rows="6" placeholder="Tekst fra note"></textarea>
</div>
<div class="form-text">Tip: brug kun den del af noten du vil gemme i målet.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="bbNoteTargetSubmitBtn">Indsæt</button>
</div>
</div>
</div>
</div>
<script> <script>
window.addEventListener('unhandledrejection', function(event) { window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason; const reason = event && event.reason;
@ -1042,7 +1189,7 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/notifications.js?v=1.0"></script> <script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script> <script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script> <script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.15"></script> <script src="/static/js/bottom-bar.js?v=2.22"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle'); const darkModeToggle = document.getElementById('darkModeToggle');

View File

@ -91,6 +91,35 @@ def _minutes_between(start: Optional[datetime], end: Optional[datetime]) -> Opti
return max(0, diff_seconds // 60) return max(0, diff_seconds // 60)
def _seconds_between(start: Optional[datetime], end: Optional[datetime]) -> int:
if not start or not end:
return 0
return max(0, int((end - start).total_seconds()))
def _elapsed_minutes_excluding_pause(entry: Dict[str, Any], end: datetime) -> int:
start_tid = entry.get("start_tid")
if not start_tid:
return 0
total_seconds = _seconds_between(start_tid, end)
paused_seconds = int(entry.get("pause_total_seconds") or 0)
paused_at = entry.get("paused_at")
if paused_at:
paused_seconds += _seconds_between(paused_at, end)
effective_seconds = max(0, total_seconds - paused_seconds)
return effective_seconds // 60
def _pause_total_seconds_at(entry: Dict[str, Any], end: datetime) -> int:
paused_seconds = int(entry.get("pause_total_seconds") or 0)
paused_at = entry.get("paused_at")
if paused_at:
paused_seconds += _seconds_between(paused_at, end)
return max(0, paused_seconds)
def _round_up_minutes(minutes: int, block_minutes: int = 30) -> int: def _round_up_minutes(minutes: int, block_minutes: int = 30) -> int:
safe_minutes = max(0, int(minutes or 0)) safe_minutes = max(0, int(minutes or 0))
safe_block = max(1, int(block_minutes or 30)) safe_block = max(1, int(block_minutes or 30))
@ -145,6 +174,7 @@ def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> O
if hub_customer_id is None: if hub_customer_id is None:
return None return None
def _get_existing_mapping() -> Optional[int]:
mapped = execute_query_single( mapped = execute_query_single(
""" """
SELECT id SELECT id
@ -162,6 +192,121 @@ def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> O
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
mapped_id = _get_existing_mapping()
if mapped_id is not None:
return mapped_id
# Auto-link fallback: try targeted linking by economic number/name before failing.
try:
hub_customer = execute_query_single(
"""
SELECT id, name, economic_customer_number
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
) or {}
economic_number = hub_customer.get("economic_customer_number")
customer_name = str(hub_customer.get("name") or "").strip()
if economic_number not in (None, ""):
execute_update(
"""
UPDATE tmodule_customers
SET hub_customer_id = %s,
economic_customer_number = COALESCE(economic_customer_number, %s),
updated_at = NOW()
WHERE hub_customer_id IS NULL
AND economic_customer_number = %s
""",
(hub_customer_id, economic_number, economic_number),
)
if customer_name:
execute_update(
"""
UPDATE tmodule_customers
SET hub_customer_id = %s,
updated_at = NOW()
WHERE hub_customer_id IS NULL
AND LOWER(TRIM(COALESCE(name, ''))) = LOWER(TRIM(%s))
""",
(hub_customer_id, customer_name),
)
# Last fallback: run global relink function if available.
try:
execute_query("SELECT * FROM link_tmodule_customers_to_hub()")
except Exception:
pass
except Exception as link_error:
logger.warning("⚠️ Auto-link fallback for hub customer %s failed: %s", hub_customer_id, link_error)
mapped_id = _get_existing_mapping()
if mapped_id is not None:
return mapped_id
# Last-resort fallback: create a local tmodule customer stub from Hub customer.
# This keeps timer registration operational even if vTiger sync/linking has not run yet.
try:
hub_customer = execute_query_single(
"""
SELECT id, name, email, economic_customer_number
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
) or {}
hub_name = str(hub_customer.get("name") or "").strip()
if hub_name:
synthetic_vtiger_id = f"HUB-{hub_customer_id}"
created = execute_query(
"""
INSERT INTO tmodule_customers (
vtiger_id,
name,
email,
hub_customer_id,
economic_customer_number,
vtiger_data,
sync_hash,
updated_at,
last_synced_at
) VALUES (
%s, %s, %s, %s, %s,
%s::jsonb,
%s,
NOW(), NOW()
)
ON CONFLICT (vtiger_id)
DO UPDATE SET
name = EXCLUDED.name,
email = COALESCE(EXCLUDED.email, tmodule_customers.email),
hub_customer_id = EXCLUDED.hub_customer_id,
economic_customer_number = COALESCE(EXCLUDED.economic_customer_number, tmodule_customers.economic_customer_number),
updated_at = NOW(),
last_synced_at = NOW()
RETURNING id
""",
(
synthetic_vtiger_id,
hub_name,
hub_customer.get("email"),
hub_customer_id,
hub_customer.get("economic_customer_number"),
'{"source":"hub_fallback"}',
f"hub-fallback-{hub_customer_id}",
),
) or []
if created and created[0].get("id") is not None:
return int(created[0]["id"])
except Exception as create_error:
logger.warning("⚠️ Could not create tmodule customer fallback for hub customer %s: %s", hub_customer_id, create_error)
return None
# ============================================================================ # ============================================================================
# SYNC ENDPOINTS # SYNC ENDPOINTS
@ -1927,7 +2072,7 @@ async def start_live_timer_v1(
now = datetime.now() now = datetime.now()
existing = execute_query_single( existing = execute_query_single(
""" """
SELECT id, start_tid, round_block_min SELECT id, start_tid, round_block_min, pause_total_seconds, paused_at
FROM tmodule_times FROM tmodule_times
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC ORDER BY start_tid DESC NULLS LAST, id DESC
@ -1938,13 +2083,16 @@ async def start_live_timer_v1(
paused_entry = None paused_entry = None
if existing: if existing:
actual_minutes = _minutes_between(existing.get("start_tid"), now) or 0 actual_minutes = _elapsed_minutes_excluding_pause(existing, now)
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30) rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
pause_total_seconds = _pause_total_seconds_at(existing, now)
execute_update( execute_update(
""" """
UPDATE tmodule_times UPDATE tmodule_times
SET slut_tid = %s, SET slut_tid = %s,
aktiv_timer = FALSE, aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s, faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END, fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01), original_hours = GREATEST(%s::numeric / 60.0, 0.01),
@ -1954,7 +2102,16 @@ async def start_live_timer_v1(
status = 'pending' status = 'pending'
WHERE id = %s WHERE id = %s
""", """,
(now, actual_minutes, rounded_minutes, actual_minutes, rounded_minutes, existing.get("round_block_min") or 30, existing["id"]) (
now,
pause_total_seconds,
actual_minutes,
rounded_minutes,
actual_minutes,
rounded_minutes,
existing.get("round_block_min") or 30,
existing["id"],
)
) )
paused_entry = existing["id"] paused_entry = existing["id"]
@ -2049,11 +2206,11 @@ async def stop_live_timer_v1(
if not entry: if not entry:
raise HTTPException(status_code=404, detail="No active timer found") raise HTTPException(status_code=404, detail="No active timer found")
start_tid = entry.get("start_tid")
actual_minutes = payload.get("faktisk_tid_min") actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None: if actual_minutes is None:
actual_minutes = _minutes_between(start_tid, now) actual_minutes = _elapsed_minutes_excluding_pause(entry, now)
actual_minutes = max(0, int(actual_minutes or 0)) actual_minutes = max(0, int(actual_minutes or 0))
pause_total_seconds = _pause_total_seconds_at(entry, now)
block_minutes = int(payload.get("round_block_min") or entry.get("round_block_min") or 30) block_minutes = int(payload.get("round_block_min") or entry.get("round_block_min") or 30)
manual_billable = payload.get("fakturerbar_tid_min") manual_billable = payload.get("fakturerbar_tid_min")
@ -2066,6 +2223,8 @@ async def stop_live_timer_v1(
UPDATE tmodule_times UPDATE tmodule_times
SET slut_tid = %s, SET slut_tid = %s,
aktiv_timer = FALSE, aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s, faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END, fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01), original_hours = GREATEST(%s::numeric / 60.0, 0.01),
@ -2080,6 +2239,7 @@ async def stop_live_timer_v1(
""", """,
( (
now, now,
pause_total_seconds,
actual_minutes, actual_minutes,
billable_minutes, billable_minutes,
actual_minutes, actual_minutes,
@ -2100,6 +2260,186 @@ async def stop_live_timer_v1(
raise HTTPException(status_code=500, detail="Failed to stop timer") raise HTTPException(status_code=500, detail="Failed to stop timer")
@router.post("/time/pause", tags=["Internal"])
async def pause_live_timer_v1(
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Pause current active timer for authenticated user without closing the entry."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
now = datetime.now()
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE medarbejder_id = %s
AND aktiv_timer = TRUE
AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
LIMIT 1
""",
(bruger_id,),
)
if not entry:
raise HTTPException(status_code=404, detail="No active timer found")
if entry.get("paused_at"):
raise HTTPException(status_code=409, detail="Timer is already paused")
paused = execute_query(
"""
UPDATE tmodule_times
SET paused_at = %s,
aktiv_timer = FALSE
WHERE id = %s AND medarbejder_id = %s
RETURNING *
""",
(now, entry["id"], bruger_id),
)
return paused[0] if paused else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error pausing live timer: %s", e)
raise HTTPException(status_code=500, detail="Failed to pause timer")
@router.post("/time/resume", tags=["Internal"])
async def resume_live_timer_v1(
payload: Dict[str, Any] = Body(default={}),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Resume paused timer for authenticated user on the same entry."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
now = datetime.now()
time_id = payload.get("time_id")
active = execute_query_single(
"""
SELECT id
FROM tmodule_times
WHERE medarbejder_id = %s
AND aktiv_timer = TRUE
AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
LIMIT 1
""",
(bruger_id,),
)
if active:
raise HTTPException(status_code=409, detail="An active timer already exists")
if time_id:
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE id = %s
AND medarbejder_id = %s
AND slut_tid IS NULL
""",
(time_id, bruger_id),
)
else:
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE medarbejder_id = %s
AND paused_at IS NOT NULL
AND aktiv_timer = FALSE
AND slut_tid IS NULL
ORDER BY paused_at DESC, id DESC
LIMIT 1
""",
(bruger_id,),
)
if not entry:
raise HTTPException(status_code=404, detail="No paused timer found")
if not entry.get("paused_at"):
raise HTTPException(status_code=409, detail="Timer is not paused")
updated_pause_total = _pause_total_seconds_at(entry, now)
resumed = execute_query(
"""
UPDATE tmodule_times
SET pause_total_seconds = %s,
paused_at = NULL,
aktiv_timer = TRUE
WHERE id = %s AND medarbejder_id = %s
RETURNING *
""",
(updated_pause_total, entry["id"], bruger_id),
)
return resumed[0] if resumed else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error resuming live timer: %s", e)
raise HTTPException(status_code=500, detail="Failed to resume timer")
@router.get("/time/my-switchable", tags=["Internal"])
async def list_my_switchable_timers_v1(
current_user: Optional[dict] = Depends(get_optional_user)
):
"""List authenticated user's currently active and paused timers for switch-case UI."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
active = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
AND t.aktiv_timer = TRUE
AND t.paused_at IS NULL
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
""",
(bruger_id,),
)
paused = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
AND t.paused_at IS NOT NULL
AND t.aktiv_timer = FALSE
ORDER BY t.paused_at DESC, t.id DESC
""",
(bruger_id,),
)
return {
"active": active or [],
"paused": paused or [],
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error listing switchable timers: %s", e)
raise HTTPException(status_code=500, detail="Failed to list switchable timers")
@router.post("/time/manual", tags=["Internal"]) @router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1( async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...), payload: Dict[str, Any] = Body(...),

View File

@ -5,14 +5,56 @@ Endpoints for managing suppliers and vendors
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel
from app.models.schemas import Vendor, VendorCreate, VendorUpdate from app.models.schemas import Vendor, VendorCreate, VendorUpdate
from app.core.database import execute_query from app.core.database import execute_query, execute_query_single, execute_update
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _ensure_customer_supplier_tag(customer_id: int) -> None:
"""Ensure linked customers are tagged as suppliers."""
try:
tag = execute_query_single(
"SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1"
)
if tag and tag.get("id") is not None:
tag_id = int(tag["id"])
else:
created = execute_query_single(
"""
INSERT INTO tags (name, type, description, color, is_active)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, type)
DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
RETURNING id
""",
("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True),
)
tag_id = int(created["id"]) if created and created.get("id") is not None else None
if not tag_id:
return
execute_query(
"""
INSERT INTO entity_tags (entity_type, entity_id, tag_id)
VALUES (%s, %s, %s)
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
""",
("customer", customer_id, tag_id),
)
except Exception as tag_error:
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
class VendorCustomerLinkCreate(BaseModel):
customer_id: int
relationship_type: Optional[str] = "supplier"
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"]) @router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
async def list_vendors( async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"), search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
@ -172,3 +214,75 @@ async def delete_vendor(vendor_id: int):
logger.info(f"✅ Deleted vendor: {vendor_id}") logger.info(f"✅ Deleted vendor: {vendor_id}")
return {"message": "Vendor deleted successfully"} return {"message": "Vendor deleted successfully"}
@router.get("/vendors/{vendor_id}/customers", tags=["Vendors"])
async def list_vendor_customers(vendor_id: int):
"""List customers linked to a vendor."""
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
rows = execute_query(
"""
SELECT
l.id,
l.customer_id,
l.vendor_id,
l.relationship_type,
l.created_at,
l.updated_at,
c.name AS customer_name,
c.email AS customer_email,
c.cvr_number AS customer_cvr
FROM customer_vendor_links l
JOIN customers c ON c.id = l.customer_id
WHERE l.vendor_id = %s
ORDER BY c.name ASC, l.id ASC
""",
(vendor_id,),
) or []
return rows
@router.post("/vendors/{vendor_id}/customers", tags=["Vendors"])
async def link_vendor_to_customer(vendor_id: int, payload: VendorCustomerLinkCreate):
"""Create link between vendor and customer."""
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
relationship_type = str(payload.relationship_type or "supplier").strip().lower()
if relationship_type not in {"supplier", "reseller", "partner"}:
raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
row = execute_query_single(
"""
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
VALUES (%s, %s, %s)
ON CONFLICT (customer_id, vendor_id)
DO UPDATE SET
relationship_type = EXCLUDED.relationship_type,
updated_at = CURRENT_TIMESTAMP
RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
""",
(payload.customer_id, vendor_id, relationship_type),
)
_ensure_customer_supplier_tag(int(payload.customer_id))
return row
@router.delete("/vendors/{vendor_id}/customers/{customer_id}", tags=["Vendors"])
async def unlink_vendor_from_customer(vendor_id: int, customer_id: int):
"""Delete link between vendor and customer."""
deleted = execute_update(
"DELETE FROM customer_vendor_links WHERE vendor_id = %s AND customer_id = %s",
(vendor_id, customer_id),
)
if not deleted:
raise HTTPException(status_code=404, detail="Link not found")
return {"success": True, "vendor_id": vendor_id, "customer_id": customer_id}

View File

@ -131,6 +131,9 @@
<a class="nav-link" href="#fakturaer" data-tab="fakturaer"> <a class="nav-link" href="#fakturaer" data-tab="fakturaer">
<i class="bi bi-receipt me-2"></i>Fakturaer <i class="bi bi-receipt me-2"></i>Fakturaer
</a> </a>
<a class="nav-link" href="#kunder" data-tab="kunder">
<i class="bi bi-building me-2"></i>Kunder
</a>
<a class="nav-link" href="#aktivitet" data-tab="aktivitet"> <a class="nav-link" href="#aktivitet" data-tab="aktivitet">
<i class="bi bi-clock-history me-2"></i>Aktivitet <i class="bi bi-clock-history me-2"></i>Aktivitet
</a> </a>
@ -224,6 +227,35 @@
</div> </div>
</div> </div>
<!-- Kunder Tab -->
<div class="tab-pane fade" id="kunder">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold">Linked kunder</h5>
<span class="badge bg-primary" id="vendorCustomersCount">0</span>
</div>
<div class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
class="form-control"
id="vendorCustomerSearch"
placeholder="Søg kunde (navn, email, CVR)"
oninput="searchCustomersForVendor(this.value)"
>
</div>
<div class="col-md-4 text-md-end">
<small class="text-muted">Link leverandør til kunde</small>
</div>
</div>
<div id="vendorCustomerSearchResults" class="list-group mb-3" style="display:none;"></div>
<div id="vendorCustomersList" class="list-group mb-2"></div>
<div id="vendorCustomersEmpty" class="text-muted">Ingen linked kunder endnu.</div>
</div>
</div>
<!-- Aktivitet Tab --> <!-- Aktivitet Tab -->
<div class="tab-pane fade" id="aktivitet"> <div class="tab-pane fade" id="aktivitet">
<div class="card p-4"> <div class="card p-4">
@ -340,12 +372,144 @@ async function loadVendor() {
} }
const vendor = await response.json(); const vendor = await response.json();
displayVendor(vendor); displayVendor(vendor);
await loadVendorCustomers();
} catch (error) { } catch (error) {
console.error('Error loading vendor:', error); console.error('Error loading vendor:', error);
document.getElementById('vendorName').textContent = 'Fejl ved indlæsning'; document.getElementById('vendorName').textContent = 'Fejl ved indlæsning';
} }
} }
async function loadVendorCustomers() {
const listEl = document.getElementById('vendorCustomersList');
const emptyEl = document.getElementById('vendorCustomersEmpty');
const countEl = document.getElementById('vendorCustomersCount');
if (!listEl || !emptyEl || !countEl) return;
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/customers`);
if (!response.ok) throw new Error('Kunne ikke hente linked kunder');
const links = await response.json();
const rows = Array.isArray(links) ? links : [];
countEl.textContent = String(rows.length);
if (!rows.length) {
listEl.innerHTML = '';
emptyEl.classList.remove('d-none');
return;
}
emptyEl.classList.add('d-none');
listEl.innerHTML = rows.map((row) => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(row.customer_name || `Kunde #${row.customer_id}`)}</div>
<div class="small text-muted">${row.customer_cvr ? `CVR ${escapeHtml(row.customer_cvr)} · ` : ''}${row.customer_email ? escapeHtml(row.customer_email) : '-'}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
<a class="btn btn-sm btn-outline-primary" href="/customers/${row.customer_id}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="unlinkCustomerFromVendor(${row.customer_id})">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading vendor customers:', error);
listEl.innerHTML = '';
emptyEl.classList.remove('d-none');
}
}
let customerSearchDebounce = null;
async function searchCustomersForVendor(query) {
const resultsEl = document.getElementById('vendorCustomerSearchResults');
if (!resultsEl) return;
const q = String(query || '').trim();
if (!q) {
resultsEl.style.display = 'none';
resultsEl.innerHTML = '';
return;
}
if (customerSearchDebounce) window.clearTimeout(customerSearchDebounce);
customerSearchDebounce = window.setTimeout(async () => {
try {
const response = await fetch(`/api/v1/customers?search=${encodeURIComponent(q)}&limit=10&offset=0`);
if (!response.ok) throw new Error('Søgning fejlede');
const payload = await response.json();
const customers = Array.isArray(payload?.customers) ? payload.customers : [];
if (!customers.length) {
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen kunder fundet</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = customers.map((c) => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(c.name || '')}</div>
<div class="small text-muted">${c.cvr_number ? `CVR ${escapeHtml(c.cvr_number)} · ` : ''}${c.email ? escapeHtml(c.email) : '-'}</div>
</div>
<button class="btn btn-sm btn-primary" onclick="linkCustomerToVendorFromUI(${c.id})">
<i class="bi bi-link-45deg me-1"></i>Link
</button>
</div>
`).join('');
resultsEl.style.display = 'block';
} catch (error) {
console.error('Customer search failed:', error);
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
resultsEl.style.display = 'block';
}
}, 220);
}
async function linkCustomerToVendorFromUI(customerId) {
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/customers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId, relationship_type: 'supplier' })
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke linke kunde');
}
const input = document.getElementById('vendorCustomerSearch');
const results = document.getElementById('vendorCustomerSearchResults');
if (input) input.value = '';
if (results) {
results.innerHTML = '';
results.style.display = 'none';
}
await loadVendorCustomers();
} catch (error) {
alert(error.message || 'Kunne ikke linke kunde');
}
}
async function unlinkCustomerFromVendor(customerId) {
if (!confirm('Fjern link mellem leverandør og kunde?')) return;
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/customers/${customerId}`, {
method: 'DELETE'
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke fjerne link');
}
await loadVendorCustomers();
} catch (error) {
alert(error.message || 'Kunne ikke fjerne link');
}
}
function displayVendor(vendor) { function displayVendor(vendor) {
// Header // Header
document.getElementById('vendorName').textContent = vendor.name; document.getElementById('vendorName').textContent = vendor.name;

View File

@ -0,0 +1,13 @@
-- Persist recently opened cases per user for bottom bar quick access
CREATE TABLE IF NOT EXISTS sag_recent_cases (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
opened_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_sag_recent_cases_user_sag UNIQUE (user_id, sag_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_recent_cases_user_opened
ON sag_recent_cases (user_id, opened_at DESC, id DESC);

View File

@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS user_module_preferences (
INSERT INTO settings (key, value, category, description, value_type, is_public) INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES VALUES
('bottom_bar_enabled', 'false', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false) ('bottom_bar_enabled', 'true', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
-- Default role access: admins, managers and technicians enabled. viewers disabled. -- Default role access: admins, managers and technicians enabled. viewers disabled.

View File

@ -0,0 +1,47 @@
-- Migration 169: Supplier invoice -> case traceability and purchase line classification
-- Created: 2026-04-12
-- Link supplier invoices to cases for procurement workflow
ALTER TABLE supplier_invoices
ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_sag_id
ON supplier_invoices(sag_id);
-- Add explicit purchase line classification + invoice traceability on case purchase lines
ALTER TABLE sag_salgsvarer
ADD COLUMN IF NOT EXISTS purchase_purpose VARCHAR(50),
ADD COLUMN IF NOT EXISTS supplier_invoice_id INTEGER REFERENCES supplier_invoices(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS supplier_invoice_line_id INTEGER REFERENCES supplier_invoice_lines(id) ON DELETE SET NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_sag_salgsvarer_purchase_purpose'
) THEN
ALTER TABLE sag_salgsvarer
ADD CONSTRAINT chk_sag_salgsvarer_purchase_purpose
CHECK (
purchase_purpose IS NULL
OR purchase_purpose IN (
'salg',
'lager',
'asset',
'intern_brug',
'retur_reklamation',
'projekt_omkostning'
)
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_purchase_purpose
ON sag_salgsvarer(purchase_purpose);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_supplier_invoice_id
ON sag_salgsvarer(supplier_invoice_id);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_supplier_invoice_line_id
ON sag_salgsvarer(supplier_invoice_line_id);

View File

@ -0,0 +1,33 @@
-- Migration 170: Trusted sender-domain to customer mappings for email triage
-- Created: 2026-04-12
CREATE TABLE IF NOT EXISTS email_domain_customer_mappings (
domain VARCHAR(255) PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
source VARCHAR(50) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_email_domain_customer_mappings_customer_id
ON email_domain_customer_mappings(customer_id);
CREATE INDEX IF NOT EXISTS idx_email_domain_customer_mappings_source
ON email_domain_customer_mappings(source);
CREATE OR REPLACE FUNCTION update_email_domain_customer_mappings_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_email_domain_customer_mappings_updated_at ON email_domain_customer_mappings;
CREATE TRIGGER trigger_email_domain_customer_mappings_updated_at
BEFORE UPDATE ON email_domain_customer_mappings
FOR EACH ROW
EXECUTE FUNCTION update_email_domain_customer_mappings_updated_at();
COMMENT ON TABLE email_domain_customer_mappings IS 'Trusted mappings from sender email domain to customer for fast auto-linking in email triage';
COMMENT ON COLUMN email_domain_customer_mappings.source IS 'manual, auto_link, import, etc.';

View File

@ -0,0 +1,17 @@
-- Migration 170: Default rental prices on hardware assets
-- Stores asset-level default prices used to prefill quick-rent flow.
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS rental_default_start_price DECIMAL(10,2)
CHECK (rental_default_start_price IS NULL OR rental_default_start_price >= 0),
ADD COLUMN IF NOT EXISTS rental_default_freight_price DECIMAL(10,2)
CHECK (rental_default_freight_price IS NULL OR rental_default_freight_price >= 0),
ADD COLUMN IF NOT EXISTS rental_default_preparation_price DECIMAL(10,2)
CHECK (rental_default_preparation_price IS NULL OR rental_default_preparation_price >= 0),
ADD COLUMN IF NOT EXISTS rental_default_operations_monthly_price DECIMAL(10,2)
CHECK (rental_default_operations_monthly_price IS NULL OR rental_default_operations_monthly_price >= 0);
COMMENT ON COLUMN hardware_assets.rental_default_start_price IS 'Default startup price for quick-rent orders.';
COMMENT ON COLUMN hardware_assets.rental_default_freight_price IS 'Default freight price for quick-rent orders.';
COMMENT ON COLUMN hardware_assets.rental_default_preparation_price IS 'Default preparation price for quick-rent orders.';
COMMENT ON COLUMN hardware_assets.rental_default_operations_monthly_price IS 'Default monthly operations price for quick-rent orders.';

View File

@ -0,0 +1,84 @@
-- Migration 171: Supplier invoice v2 lifecycle (status, approval, split payments, event log)
-- Created: 2026-04-12
ALTER TABLE supplier_invoices
ADD COLUMN IF NOT EXISTS workflow_status_v2 VARCHAR(20),
ADD COLUMN IF NOT EXISTS rejected_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
ADD COLUMN IF NOT EXISTS payment_method VARCHAR(100);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoices_workflow_status_v2'
) THEN
ALTER TABLE supplier_invoices
ADD CONSTRAINT chk_supplier_invoices_workflow_status_v2
CHECK (workflow_status_v2 IN ('modtaget', 'godkendt', 'betalt', 'afvist'));
END IF;
END $$;
-- Backfill v2 statuses from legacy status values.
UPDATE supplier_invoices
SET workflow_status_v2 = CASE
WHEN status IN ('approved', 'sent_to_economic') THEN 'godkendt'
WHEN status = 'paid' THEN 'betalt'
WHEN status IN ('cancelled', 'credited', 'rejected') THEN 'afvist'
ELSE 'modtaget'
END
WHERE workflow_status_v2 IS NULL;
ALTER TABLE supplier_invoices
ALTER COLUMN workflow_status_v2 SET DEFAULT 'modtaget';
ALTER TABLE supplier_invoices
ALTER COLUMN workflow_status_v2 SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_workflow_status_v2
ON supplier_invoices(workflow_status_v2);
-- Split payments on supplier invoices.
CREATE TABLE IF NOT EXISTS supplier_invoice_payments (
id SERIAL PRIMARY KEY,
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
payment_date DATE NOT NULL,
amount DECIMAL(15, 2) NOT NULL CHECK (amount > 0),
currency VARCHAR(10) NOT NULL DEFAULT 'DKK',
payment_method VARCHAR(100),
payment_reference VARCHAR(100),
notes TEXT,
paid_by VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_invoice_id
ON supplier_invoice_payments(supplier_invoice_id);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_date
ON supplier_invoice_payments(payment_date);
-- Event log / outbox-style table for later webhook activation.
CREATE TABLE IF NOT EXISTS supplier_invoice_events (
id BIGSERIAL PRIMARY KEY,
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
event_type VARCHAR(60) NOT NULL,
from_status VARCHAR(20),
to_status VARCHAR(20),
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
webhook_status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_events_invoice_id
ON supplier_invoice_events(supplier_invoice_id);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_events_webhook_status
ON supplier_invoice_events(webhook_status);
COMMENT ON COLUMN supplier_invoices.workflow_status_v2 IS 'v2 lifecycle: modtaget, godkendt, betalt, afvist';
COMMENT ON TABLE supplier_invoice_payments IS 'Partial/split payments for supplier invoices';
COMMENT ON TABLE supplier_invoice_events IS 'Supplier invoice event log prepared for future webhook/outbox processing';

View File

@ -0,0 +1,39 @@
-- Migration 172: Supplier flow type fields on cases
-- Created: 2026-04-12
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS supplier_flow_type VARCHAR(20),
ADD COLUMN IF NOT EXISTS supplier_flow_confidence NUMERIC(4,3);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_sag_sager_supplier_flow_type'
) THEN
ALTER TABLE sag_sager
ADD CONSTRAINT chk_sag_sager_supplier_flow_type
CHECK (supplier_flow_type IS NULL OR supplier_flow_type IN ('varekob', 'ydelse'));
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_sag_sager_supplier_flow_confidence'
) THEN
ALTER TABLE sag_sager
ADD CONSTRAINT chk_sag_sager_supplier_flow_confidence
CHECK (supplier_flow_confidence IS NULL OR (supplier_flow_confidence >= 0 AND supplier_flow_confidence <= 1));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sag_sager_supplier_flow_type
ON sag_sager(supplier_flow_type)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN sag_sager.supplier_flow_type IS 'Supplier module type selection: varekob or ydelse';
COMMENT ON COLUMN sag_sager.supplier_flow_confidence IS 'AI suggestion confidence (0-1) for supplier flow type';

View File

@ -0,0 +1,68 @@
-- Migration 173: Supplier invoice flow metadata
-- Created: 2026-04-13
ALTER TABLE supplier_invoices
ADD COLUMN IF NOT EXISTS supplier_flow_type VARCHAR(20),
ADD COLUMN IF NOT EXISTS tags_json JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS linked_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS linked_order_id BIGINT,
ADD COLUMN IF NOT EXISTS linked_order_source VARCHAR(50);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoices_supplier_flow_type'
) THEN
ALTER TABLE supplier_invoices
ADD CONSTRAINT chk_supplier_invoices_supplier_flow_type
CHECK (
supplier_flow_type IS NULL
OR supplier_flow_type IN ('varekob', 'ydelse')
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoices_linked_order_source'
) THEN
ALTER TABLE supplier_invoices
ADD CONSTRAINT chk_supplier_invoices_linked_order_source
CHECK (
linked_order_source IS NULL
OR linked_order_source IN ('tmodule_orders', 'webshop_orders')
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_supplier_flow_type
ON supplier_invoices(supplier_flow_type)
WHERE supplier_flow_type IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_source_email_id
ON supplier_invoices(source_email_id)
WHERE source_email_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_linked_customer_id
ON supplier_invoices(linked_customer_id)
WHERE linked_customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_linked_order_id
ON supplier_invoices(linked_order_id)
WHERE linked_order_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_tags_json
ON supplier_invoices USING GIN (tags_json);
COMMENT ON COLUMN supplier_invoices.supplier_flow_type IS 'Flow type: varekob or ydelse';
COMMENT ON COLUMN supplier_invoices.tags_json IS 'Tag list as JSON array';
COMMENT ON COLUMN supplier_invoices.source_email_id IS 'Source email message id';
COMMENT ON COLUMN supplier_invoices.linked_customer_id IS 'Linked customer context';
COMMENT ON COLUMN supplier_invoices.linked_order_id IS 'Linked order id';
COMMENT ON COLUMN supplier_invoices.linked_order_source IS 'Order table source: tmodule_orders or webshop_orders';

View File

@ -0,0 +1,88 @@
-- Migration 174: Supplier invoice line handling fields
-- Created: 2026-04-13
ALTER TABLE supplier_invoice_lines
ADD COLUMN IF NOT EXISTS handling_type VARCHAR(40),
ADD COLUMN IF NOT EXISTS target_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS target_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS target_employee_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS requires_serial BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(120),
ADD COLUMN IF NOT EXISTS asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS resale_ready BOOLEAN NOT NULL DEFAULT FALSE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_lines_handling_type'
) THEN
ALTER TABLE supplier_invoice_lines
ADD CONSTRAINT chk_supplier_invoice_lines_handling_type
CHECK (
handling_type IS NULL
OR handling_type IN (
'fakturer_videre',
'asset',
'intern_brug',
'projekt_omkostning',
'lager',
'retur_reklamation'
)
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_lines_serial_requirement'
) THEN
ALTER TABLE supplier_invoice_lines
ADD CONSTRAINT chk_supplier_invoice_lines_serial_requirement
CHECK (
requires_serial = FALSE
OR (serial_number IS NOT NULL AND btrim(serial_number) <> '')
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_handling_type
ON supplier_invoice_lines(handling_type)
WHERE handling_type IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_customer_id
ON supplier_invoice_lines(target_customer_id)
WHERE target_customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_sag_id
ON supplier_invoice_lines(target_sag_id)
WHERE target_sag_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_employee_id
ON supplier_invoice_lines(target_employee_id)
WHERE target_employee_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_asset_id
ON supplier_invoice_lines(asset_id)
WHERE asset_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_resale_ready
ON supplier_invoice_lines(resale_ready)
WHERE resale_ready = TRUE;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_serial_number
ON supplier_invoice_lines(serial_number)
WHERE serial_number IS NOT NULL;
COMMENT ON COLUMN supplier_invoice_lines.handling_type IS 'Line handling: fakturer_videre, asset, intern_brug, projekt_omkostning, lager, retur_reklamation';
COMMENT ON COLUMN supplier_invoice_lines.target_customer_id IS 'Customer target for line allocation';
COMMENT ON COLUMN supplier_invoice_lines.target_sag_id IS 'Case target for line allocation';
COMMENT ON COLUMN supplier_invoice_lines.target_employee_id IS 'Employee target for line allocation';
COMMENT ON COLUMN supplier_invoice_lines.requires_serial IS 'Whether serial number is required';
COMMENT ON COLUMN supplier_invoice_lines.serial_number IS 'Serial number when required';
COMMENT ON COLUMN supplier_invoice_lines.asset_id IS 'Linked hardware asset id';
COMMENT ON COLUMN supplier_invoice_lines.resale_ready IS 'Whether line is ready for resale flow';

View File

@ -0,0 +1,48 @@
-- Migration 175: Supplier invoice attachments
-- Created: 2026-04-13
CREATE TABLE IF NOT EXISTS supplier_invoice_attachments (
id BIGSERIAL PRIMARY KEY,
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
source_type VARCHAR(30) NOT NULL DEFAULT 'upload',
source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
source_email_attachment_id INTEGER REFERENCES email_attachments(id) ON DELETE SET NULL,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(120),
file_path TEXT,
size_bytes BIGINT,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_attachments_source_type'
) THEN
ALTER TABLE supplier_invoice_attachments
ADD CONSTRAINT chk_supplier_invoice_attachments_source_type
CHECK (source_type IN ('email', 'upload', 'manual'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_supplier_invoice_id
ON supplier_invoice_attachments(supplier_invoice_id);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_email_id
ON supplier_invoice_attachments(source_email_id)
WHERE source_email_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_email_attachment_id
ON supplier_invoice_attachments(source_email_attachment_id)
WHERE source_email_attachment_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_type
ON supplier_invoice_attachments(source_type);
COMMENT ON TABLE supplier_invoice_attachments IS 'Attachments linked to supplier invoices';
COMMENT ON COLUMN supplier_invoice_attachments.source_type IS 'Attachment source: email, upload, or manual';
COMMENT ON COLUMN supplier_invoice_attachments.source_email_id IS 'Source email id when attachment came from email';
COMMENT ON COLUMN supplier_invoice_attachments.source_email_attachment_id IS 'Source email attachment id when available';

View File

@ -0,0 +1,42 @@
-- Migration 176: Supplier invoice generic relations
-- Created: 2026-04-13
CREATE TABLE IF NOT EXISTS supplier_invoice_relations (
id BIGSERIAL PRIMARY KEY,
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
relation_type VARCHAR(40) NOT NULL,
relation_id BIGINT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_relations_relation_type'
) THEN
ALTER TABLE supplier_invoice_relations
ADD CONSTRAINT chk_supplier_invoice_relations_relation_type
CHECK (
relation_type IN ('sag', 'kunde', 'ordre', 'asset', 'reklamation_sag', 'email')
);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_invoice_relations_type_id
ON supplier_invoice_relations(supplier_invoice_id, relation_type, relation_id);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_relations_lookup
ON supplier_invoice_relations(relation_type, relation_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_invoice_relations_primary_per_type
ON supplier_invoice_relations(supplier_invoice_id, relation_type)
WHERE is_primary = TRUE;
COMMENT ON TABLE supplier_invoice_relations IS 'Generic relation map from supplier invoices to related entities';
COMMENT ON COLUMN supplier_invoice_relations.relation_type IS 'Relation type: sag, kunde, ordre, asset, reklamation_sag, email';
COMMENT ON COLUMN supplier_invoice_relations.relation_id IS 'Id of the related entity';
COMMENT ON COLUMN supplier_invoice_relations.is_primary IS 'Primary relation flag within relation type';

View File

@ -0,0 +1,85 @@
-- Migration 177: Supplier invoice reminders
-- Created: 2026-04-13
CREATE TABLE IF NOT EXISTS supplier_invoice_reminders (
id BIGSERIAL PRIMARY KEY,
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
reminder_type VARCHAR(30) NOT NULL,
remind_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
sent_at TIMESTAMP,
channel VARCHAR(20) NOT NULL DEFAULT 'in_app',
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_reminders_type'
) THEN
ALTER TABLE supplier_invoice_reminders
ADD CONSTRAINT chk_supplier_invoice_reminders_type
CHECK (reminder_type IN ('due_soon', 'overdue', 'manual'));
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_reminders_status'
) THEN
ALTER TABLE supplier_invoice_reminders
ADD CONSTRAINT chk_supplier_invoice_reminders_status
CHECK (status IN ('pending', 'sent', 'cancelled', 'failed'));
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_supplier_invoice_reminders_channel'
) THEN
ALTER TABLE supplier_invoice_reminders
ADD CONSTRAINT chk_supplier_invoice_reminders_channel
CHECK (channel IN ('in_app', 'email'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_supplier_invoice_id
ON supplier_invoice_reminders(supplier_invoice_id);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_remind_at
ON supplier_invoice_reminders(remind_at);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_status
ON supplier_invoice_reminders(status);
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_payload_json
ON supplier_invoice_reminders USING GIN (payload_json);
CREATE OR REPLACE FUNCTION update_supplier_invoice_reminders_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_supplier_invoice_reminders_updated_at ON supplier_invoice_reminders;
CREATE TRIGGER trg_supplier_invoice_reminders_updated_at
BEFORE UPDATE ON supplier_invoice_reminders
FOR EACH ROW
EXECUTE FUNCTION update_supplier_invoice_reminders_updated_at();
COMMENT ON TABLE supplier_invoice_reminders IS 'Scheduled reminders for supplier invoice follow-up';
COMMENT ON COLUMN supplier_invoice_reminders.reminder_type IS 'due_soon, overdue, or manual';
COMMENT ON COLUMN supplier_invoice_reminders.status IS 'pending, sent, cancelled, or failed';
COMMENT ON COLUMN supplier_invoice_reminders.channel IS 'Delivery channel: in_app or email';

View File

@ -0,0 +1,81 @@
-- Migration 178: Link customers and vendors (many-to-many)
-- Created: 2026-04-13
CREATE TABLE IF NOT EXISTS customer_vendor_links (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
vendor_id INTEGER NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
relationship_type VARCHAR(50) NOT NULL DEFAULT 'supplier',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (customer_id, vendor_id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_customer_vendor_links_relationship_type'
) THEN
ALTER TABLE customer_vendor_links
ADD CONSTRAINT chk_customer_vendor_links_relationship_type
CHECK (relationship_type IN ('supplier', 'reseller', 'partner'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_customer_id
ON customer_vendor_links(customer_id);
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_vendor_id
ON customer_vendor_links(vendor_id);
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_relationship_type
ON customer_vendor_links(relationship_type);
CREATE OR REPLACE FUNCTION update_customer_vendor_links_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_customer_vendor_links_updated_at ON customer_vendor_links;
CREATE TRIGGER trg_customer_vendor_links_updated_at
BEFORE UPDATE ON customer_vendor_links
FOR EACH ROW
EXECUTE FUNCTION update_customer_vendor_links_updated_at();
-- Backfill links by CVR first (most reliable)
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
SELECT c.id, v.id, 'supplier'
FROM customers c
JOIN vendors v
ON regexp_replace(COALESCE(c.cvr_number, ''), '\\D', '', 'g') <> ''
AND regexp_replace(COALESCE(v.cvr_number, ''), '\\D', '', 'g') <> ''
AND regexp_replace(COALESCE(c.cvr_number, ''), '\\D', '', 'g') = regexp_replace(COALESCE(v.cvr_number, ''), '\\D', '', 'g')
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
-- Backfill by exact email match
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
SELECT c.id, v.id, 'supplier'
FROM customers c
JOIN vendors v
ON LOWER(TRIM(COALESCE(c.email, ''))) <> ''
AND LOWER(TRIM(COALESCE(v.email, ''))) <> ''
AND LOWER(TRIM(COALESCE(c.email, ''))) = LOWER(TRIM(COALESCE(v.email, '')))
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
-- Backfill by exact normalized name match
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
SELECT c.id, v.id, 'supplier'
FROM customers c
JOIN vendors v
ON LOWER(TRIM(COALESCE(c.name, ''))) <> ''
AND LOWER(TRIM(COALESCE(v.name, ''))) <> ''
AND LOWER(TRIM(COALESCE(c.name, ''))) = LOWER(TRIM(COALESCE(v.name, '')))
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
COMMENT ON TABLE customer_vendor_links IS 'Links customers to vendors to support entities that are both customer and supplier';
COMMENT ON COLUMN customer_vendor_links.relationship_type IS 'supplier, reseller, or partner';

View File

@ -0,0 +1,13 @@
-- Migration 179: Add pause/resume support for live timers
-- Date: 2026-04-23
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS paused_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS pause_total_seconds INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_tmodule_times_active_unfinished
ON tmodule_times (medarbejder_id, aktiv_timer, slut_tid);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_paused_unfinished
ON tmodule_times (medarbejder_id, paused_at, slut_tid)
WHERE paused_at IS NOT NULL AND slut_tid IS NULL;

View File

@ -0,0 +1,36 @@
-- Migration 180: Personal user notes for bottom bar
-- Date: 2026-04-24
CREATE TABLE IF NOT EXISTS user_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL DEFAULT '',
content TEXT NOT NULL,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_user_notes_user_active
ON user_notes (user_id, is_archived, is_pinned, updated_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated
ON user_notes (user_id, updated_at DESC)
WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_user_notes_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes;
CREATE TRIGGER trg_user_notes_updated_at
BEFORE UPDATE ON user_notes
FOR EACH ROW
EXECUTE FUNCTION update_user_notes_updated_at();

File diff suppressed because it is too large Load Diff