Compare commits
No commits in common. "3452472ba9b09440ad9d8e0a9ad784e65accb050" and "ceb560e2f24236fd5f65c0ae1b1126559b6a061e" have entirely different histories.
3452472ba9
...
ceb560e2f2
File diff suppressed because it is too large
Load Diff
@ -23,42 +23,6 @@ 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
|
||||||
@ -553,78 +517,6 @@ 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"""
|
||||||
@ -1204,69 +1096,7 @@ 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:
|
||||||
normalized_email = (contact.email or "").strip().lower() or None
|
# Create contact
|
||||||
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)
|
||||||
@ -1275,7 +1105,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
(
|
(
|
||||||
contact.first_name,
|
contact.first_name,
|
||||||
contact.last_name,
|
contact.last_name,
|
||||||
normalized_email,
|
contact.email,
|
||||||
contact.phone,
|
contact.phone,
|
||||||
contact.mobile,
|
contact.mobile,
|
||||||
contact.title,
|
contact.title,
|
||||||
@ -1284,12 +1114,11 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Link contact to customer
|
# Link contact to customer
|
||||||
execute_update(
|
execute_insert(
|
||||||
"""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)""",
|
||||||
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
|
(contact_id, customer_id, contact.is_primary, contact.role)
|
||||||
(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}")
|
||||||
|
|||||||
@ -498,32 +498,6 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@ -1449,7 +1423,6 @@ async function loadCustomer() {
|
|||||||
|
|
||||||
await loadUtilityCompany();
|
await loadUtilityCompany();
|
||||||
await loadCustomerTags();
|
await loadCustomerTags();
|
||||||
await loadCustomerVendorLinks();
|
|
||||||
|
|
||||||
// Check data consistency
|
// Check data consistency
|
||||||
await checkDataConsistency();
|
await checkDataConsistency();
|
||||||
@ -1460,141 +1433,6 @@ 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`;
|
||||||
|
|||||||
@ -8,7 +8,6 @@ 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
|
||||||
@ -21,218 +20,6 @@ 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):
|
||||||
@ -438,12 +225,6 @@ 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."""
|
||||||
@ -513,222 +294,6 @@ 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."""
|
||||||
@ -1043,16 +608,6 @@ 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"}
|
||||||
|
|
||||||
@ -1075,59 +630,13 @@ 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 = requested_case_type[:50]
|
template_key = (payload.case_type or 'support').strip().lower()[: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'}:
|
||||||
@ -1183,25 +692,6 @@ 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(
|
||||||
"""
|
"""
|
||||||
@ -1220,7 +710,6 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -283,62 +283,6 @@
|
|||||||
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;
|
||||||
@ -356,17 +300,6 @@
|
|||||||
background: #0a3a5c;
|
background: #0a3a5c;
|
||||||
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;
|
||||||
@ -1810,7 +1743,6 @@ 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>
|
||||||
@ -1861,7 +1793,6 @@ 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);
|
||||||
@ -1874,176 +1805,6 @@ 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 = [];
|
||||||
|
|
||||||
@ -2157,51 +1918,31 @@ function renderEmailDetail(email) {
|
|||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="email-actions">
|
<div class="email-actions d-flex justify-content-between align-items-center">
|
||||||
<div class="quick-actions-grid">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-primary" onclick="createSupplierInvoice(${email.id}, this)" title="Leverandørfaktura">
|
<button class="btn btn-sm btn-light border" onclick="archiveEmail()" title="Arkivér (e)">
|
||||||
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
|
<i class="bi bi-archive"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'support')" title="Kundesag">
|
<button class="btn btn-sm btn-light border" onclick="markAsSpam()" title="Marker som spam">
|
||||||
<i class="bi bi-folder-plus me-1"></i>Kundesag
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'bogholderi', 'Fakturasporgsmal')" title="Fakturaspørgsmål">
|
<button class="btn btn-sm btn-light border" onclick="reprocessEmail()" title="Genbehandl (r)">
|
||||||
<i class="bi bi-question-circle me-1"></i>Fakturaspørgsmål
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="markAsSpam(this)" title="Marker som spam">
|
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
|
||||||
<i class="bi bi-slash-circle me-1"></i>Spam
|
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
||||||
</button>
|
</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">
|
${email.linked_case_id ? `
|
||||||
<i class="bi bi-building-add me-1"></i>Opret firma/kontakt
|
<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
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-light border" onclick="reprocessEmail(this)" title="Genbehandl (r)">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
|
||||||
</button>
|
|
||||||
<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
|
|
||||||
</button>
|
|
||||||
${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-${email.linked_case_id}
|
|
||||||
</a>
|
|
||||||
` : '<span class="triage-priority-badge">Ingen sag linket</span>'}
|
|
||||||
</div>
|
|
||||||
<div class="right">
|
|
||||||
<button class="btn btn-sm btn-danger-soft" onclick="deleteEmail(this)" title="Slet">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${email.attachments && email.attachments.length > 0 ? `
|
${email.attachments && email.attachments.length > 0 ? `
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap mt-2 pt-2 border-top">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<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);
|
||||||
@ -2292,7 +2033,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(this)">
|
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
|
||||||
<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()">
|
||||||
@ -2327,7 +2068,6 @@ 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">
|
||||||
@ -2373,7 +2113,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(this)">
|
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
|
||||||
<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()">
|
||||||
@ -2574,8 +2314,8 @@ async function searchSagerForCurrentEmail(query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmSuggestion(button = null) {
|
function confirmSuggestion() {
|
||||||
createCaseFromCurrentForm(button);
|
createCaseFromCurrentForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCaseFormPayload() {
|
function getCaseFormPayload() {
|
||||||
@ -2596,11 +2336,8 @@ function getCaseFormPayload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let caseCreateInFlight = false;
|
async function createCaseFromCurrentForm() {
|
||||||
|
|
||||||
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) {
|
||||||
@ -2608,9 +2345,6 @@ async function createCaseFromCurrentForm(button = null) {
|
|||||||
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',
|
||||||
@ -2624,18 +2358,11 @@ async function createCaseFromCurrentForm(button = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.idempotent) {
|
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
|
||||||
showInfo(`E-mail var allerede knyttet til SAG-${result.sag.id}`);
|
|
||||||
} else {
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2855,10 +2582,9 @@ function formatEventType(eventType) {
|
|||||||
return labels[eventType] || eventType;
|
return labels[eventType] || eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveEmail(button = null) {
|
async function archiveEmail() {
|
||||||
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`, {
|
||||||
method: 'PUT'
|
method: 'PUT'
|
||||||
@ -2873,17 +2599,13 @@ async function archiveEmail(button = null) {
|
|||||||
loadEmails();
|
loadEmails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke arkivere email');
|
showError('Kunne ikke arkivere email');
|
||||||
} finally {
|
|
||||||
done();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAsSpam(button = null) {
|
async function markAsSpam() {
|
||||||
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`, {
|
||||||
@ -2901,16 +2623,17 @@ async function markAsSpam(button = null) {
|
|||||||
loadEmails();
|
loadEmails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke markere som spam');
|
showError('Kunne ikke markere som spam');
|
||||||
} finally {
|
|
||||||
done();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reprocessEmail(button = null) {
|
async function reprocessEmail() {
|
||||||
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'
|
||||||
});
|
});
|
||||||
@ -2924,16 +2647,17 @@ async function reprocessEmail(button = null) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke genbehandle email');
|
showError('Kunne ikke genbehandle email');
|
||||||
} finally {
|
} finally {
|
||||||
done();
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEmail(button = null) {
|
async function deleteEmail() {
|
||||||
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}`, {
|
||||||
@ -2948,86 +2672,11 @@ async function deleteEmail(button = null) {
|
|||||||
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
|
||||||
const supplierInvoiceInFlightByEmail = new Set();
|
async function createSupplierInvoice(emailId) {
|
||||||
|
|
||||||
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...');
|
||||||
|
|
||||||
@ -3104,41 +2753,23 @@ async function createSupplierInvoice(emailId, button = null) {
|
|||||||
|
|
||||||
// Show result
|
// Show result
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
let linkedSagId = null;
|
showSuccess(`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}`);
|
||||||
|
|
||||||
|
// Mark email as processed and move to Processed folder
|
||||||
try {
|
try {
|
||||||
linkedSagId = await ensureSupplierCaseForEmail(email);
|
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
|
||||||
if (linkedSagId) {
|
method: 'POST'
|
||||||
email.linked_case_id = linkedSagId;
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
} catch (caseError) {
|
} catch (e) {
|
||||||
console.error('Error creating supplier-related case:', caseError);
|
console.error('Error marking email as processed:', e);
|
||||||
showError(`Faktura uploadet, men sag-oprettelse fejlede: ${caseError.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!linkedSagId) {
|
|
||||||
// Fallback: keep prior behavior if case link was not created.
|
|
||||||
try {
|
|
||||||
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!markResponse.ok) {
|
|
||||||
console.warn('⚠️ Could not mark email as processed');
|
|
||||||
}
|
|
||||||
} catch (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
|
||||||
@ -3152,9 +2783,6 @@ async function createSupplierInvoice(emailId, button = null) {
|
|||||||
} 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3190,16 +2818,11 @@ 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();
|
||||||
@ -3211,64 +2834,25 @@ 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 {
|
||||||
let customer = null;
|
// Create customer
|
||||||
|
const custResp = await fetch('/api/v1/customers', {
|
||||||
// Idempotent fast path: try to reuse existing customer by domain/email before create.
|
method: 'POST',
|
||||||
if (domain || email) {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const probe = domain || email;
|
body: JSON.stringify({
|
||||||
const searchResp = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(probe)}&limit=20`);
|
name,
|
||||||
if (searchResp.ok) {
|
email: email || null,
|
||||||
const matches = await searchResp.json();
|
email_domain: domain || null,
|
||||||
const exactByDomain = (matches || []).find((row) => normalizeDomain(row.email_domain) === domain);
|
phone: phone || null
|
||||||
const exactByEmail = !exactByDomain
|
})
|
||||||
? (matches || []).find((row) => String(row.email || '').trim().toLowerCase() === email.toLowerCase())
|
});
|
||||||
: null;
|
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||||
customer = exactByDomain || exactByEmail || null;
|
const customer = await custResp.json();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
const custResp = await fetch('/api/v1/customers', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
|
||||||
email: email || null,
|
|
||||||
email_domain: domain || null,
|
|
||||||
phone: phone || null
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
|
||||||
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`, {
|
||||||
@ -3278,7 +2862,7 @@ async function submitQuickCustomer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
|
||||||
showSuccess(`Kunde "${customer.name || name}" linket`);
|
showSuccess(`Kunde "${name}" oprettet og linket`);
|
||||||
loadEmailDetail(parseInt(emailId));
|
loadEmailDetail(parseInt(emailId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.className = 'text-danger small';
|
statusEl.className = 'text-danger small';
|
||||||
@ -3670,26 +3254,6 @@ 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';
|
||||||
@ -4492,7 +4056,7 @@ async function deleteWorkflow(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeWorkflowsForEmail(button = null) {
|
async function executeWorkflowsForEmail() {
|
||||||
if (!currentEmailId) {
|
if (!currentEmailId) {
|
||||||
alert('Ingen email valgt');
|
alert('Ingen email valgt');
|
||||||
return;
|
return;
|
||||||
@ -4500,8 +4064,6 @@ async function executeWorkflowsForEmail(button = null) {
|
|||||||
|
|
||||||
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');
|
||||||
|
|
||||||
@ -4538,8 +4100,6 @@ async function executeWorkflowsForEmail(button = null) {
|
|||||||
} 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5325,26 +4885,6 @@ 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">
|
||||||
|
|||||||
@ -324,16 +324,6 @@ 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
|
||||||
@ -342,8 +332,7 @@ async def sync_eset_hardware() -> None:
|
|||||||
"""
|
"""
|
||||||
execute_query(update_query, tuple(update_params))
|
execute_query(update_query, tuple(update_params))
|
||||||
else:
|
else:
|
||||||
# ESET sync auto-creates customer endpoints; ownership can be refined later if needed.
|
owner_type = "customer" if customer_id else "bmc"
|
||||||
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,
|
||||||
|
|||||||
@ -1,18 +1,15 @@
|
|||||||
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, execute_update
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases
|
from .service import build_bottom_bar_state
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
_USER_NOTES_SCHEMA_READY = False
|
|
||||||
|
|
||||||
|
|
||||||
class BossAssignPayload(BaseModel):
|
class BossAssignPayload(BaseModel):
|
||||||
@ -24,456 +21,6 @@ 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:
|
||||||
@ -509,27 +56,6 @@ 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
|
||||||
|
|
||||||
@ -572,8 +98,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::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
|
WHEN LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
|
||||||
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1
|
WHEN LOWER(COALESCE(priority, '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,
|
||||||
@ -633,7 +159,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::text, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority, '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
|
||||||
|
|||||||
@ -38,32 +38,6 @@ 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 []
|
||||||
@ -174,7 +148,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::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -253,222 +227,6 @@ 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}
|
||||||
@ -504,7 +262,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::text, 'normal'))
|
CASE LOWER(COALESCE(r.priority, '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
|
||||||
@ -609,59 +367,6 @@ 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 = "",
|
||||||
@ -673,11 +378,7 @@ 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(
|
||||||
"""
|
"""
|
||||||
@ -685,7 +386,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::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
AND LOWER(COALESCE(priority, '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
|
||||||
"""
|
"""
|
||||||
@ -746,7 +447,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::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority, '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
|
||||||
@ -772,7 +473,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::text, 'normal'),
|
'priority', COALESCE(t.priority, 'normal'),
|
||||||
'deadline', t.deadline
|
'deadline', t.deadline
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@ -830,21 +531,29 @@ 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::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
AND LOWER(COALESCE(s.priority, '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 = [
|
unassigned_cases = execute_query(
|
||||||
{
|
"""
|
||||||
"id": row.get("id"),
|
SELECT
|
||||||
"titel": row.get("title"),
|
s.id,
|
||||||
"priority": row.get("priority"),
|
s.titel,
|
||||||
}
|
s.priority,
|
||||||
for row in (unassigned_open_cases.get("items") or [])
|
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
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
sections = {
|
sections = {
|
||||||
"mail": {
|
"mail": {
|
||||||
@ -861,30 +570,11 @@ 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,
|
||||||
@ -902,8 +592,6 @@ 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": {
|
||||||
@ -964,7 +652,5 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -374,11 +374,9 @@ 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, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -404,11 +402,7 @@ 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:
|
||||||
@ -509,9 +503,7 @@ 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:
|
||||||
|
|||||||
@ -567,103 +567,6 @@ 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,
|
||||||
@ -678,9 +581,7 @@ 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 [],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -285,30 +285,6 @@ 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>
|
||||||
@ -380,13 +356,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,56 +105,6 @@
|
|||||||
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 %}
|
||||||
|
|
||||||
@ -272,16 +222,6 @@
|
|||||||
<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 }})
|
||||||
@ -467,12 +407,6 @@
|
|||||||
<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>
|
||||||
@ -744,175 +678,6 @@
|
|||||||
</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">
|
||||||
@ -963,99 +728,6 @@
|
|||||||
</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">
|
||||||
@ -1251,119 +923,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
@ -1481,24 +1040,6 @@
|
|||||||
|
|
||||||
// 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');
|
||||||
|
|||||||
@ -285,58 +285,6 @@
|
|||||||
</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>
|
||||||
@ -381,13 +329,6 @@
|
|||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ordre_drafts.id, ordre_drafts.title, ordre_drafts.customer_id, ordre_drafts.notes, ordre_drafts.layout_number, ordre_drafts.created_by_user_id,
|
SELECT id, title, customer_id, notes, layout_number, 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,
|
||||||
ordre_drafts.last_sync_at, ordre_drafts.created_at, ordre_drafts.updated_at, ordre_drafts.last_exported_at
|
last_sync_at, created_at, updated_at, last_exported_at
|
||||||
FROM ordre_drafts
|
FROM ordre_drafts
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT ordre_draft_sync_events.event_type, ordre_draft_sync_events.created_at
|
SELECT event_type, 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 ordre_draft_sync_events.created_at DESC, ordre_draft_sync_events.id DESC
|
ORDER BY created_at DESC, 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
|
||||||
|
|||||||
@ -14,7 +14,6 @@ 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__)
|
||||||
@ -539,102 +538,6 @@ 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
|
||||||
@ -803,273 +706,6 @@ 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)
|
||||||
|
|||||||
@ -858,98 +858,6 @@ 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."""
|
||||||
@ -1196,23 +1104,6 @@ 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 = [
|
||||||
@ -1229,8 +1120,6 @@ 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 = []
|
||||||
@ -2351,73 +2240,6 @@ 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."""
|
||||||
@ -2468,68 +2290,27 @@ 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")
|
query = """
|
||||||
purchase_purpose = None
|
INSERT INTO sag_salgsvarer
|
||||||
supplier_invoice_id = None
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
||||||
supplier_invoice_line_id = None
|
VALUES
|
||||||
if has_purchase_columns:
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
purchase_purpose = _normalize_purchase_purpose(data.get("purchase_purpose"), item_type)
|
RETURNING *
|
||||||
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
|
"""
|
||||||
data.get("supplier_invoice_id"),
|
params = (
|
||||||
data.get("supplier_invoice_line_id"),
|
sag_id,
|
||||||
)
|
item_type,
|
||||||
if item_type != "purchase":
|
description,
|
||||||
supplier_invoice_id = None
|
data.get("quantity"),
|
||||||
supplier_invoice_line_id = None
|
data.get("unit"),
|
||||||
|
data.get("unit_price"),
|
||||||
if has_purchase_columns:
|
amount,
|
||||||
query = """
|
data.get("currency", "DKK"),
|
||||||
INSERT INTO sag_salgsvarer
|
status,
|
||||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
|
data.get("line_date"),
|
||||||
purchase_purpose, supplier_invoice_id, supplier_invoice_line_id)
|
data.get("external_ref"),
|
||||||
VALUES
|
data.get("product_id"),
|
||||||
(%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 = """
|
|
||||||
INSERT INTO sag_salgsvarer
|
|
||||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
|
||||||
VALUES
|
|
||||||
(%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"),
|
|
||||||
)
|
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
if result:
|
if result:
|
||||||
logger.info("✅ Sale item created for case %s", sag_id)
|
logger.info("✅ Sale item created for case %s", sag_id)
|
||||||
@ -2568,7 +2349,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, type FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
|
"SELECT id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
|
||||||
(item_id, sag_id)
|
(item_id, sag_id)
|
||||||
)
|
)
|
||||||
if not check:
|
if not check:
|
||||||
@ -2586,18 +2367,10 @@ 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:
|
||||||
@ -2613,56 +2386,10 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@ -166,15 +166,13 @@ 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)
|
||||||
requested_unassigned = bool(unassigned) or str(ansvarlig_bruger_id or "").strip().upper() == "__UNASSIGNED__"
|
ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id)
|
||||||
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.*,
|
||||||
@ -247,9 +245,7 @@ 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 requested_unassigned:
|
if ansvarlig_bruger_id_int:
|
||||||
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:
|
||||||
@ -304,9 +300,7 @@ 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 requested_unassigned:
|
if ansvarlig_bruger_id_int:
|
||||||
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)
|
||||||
|
|
||||||
@ -388,7 +382,6 @@ 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")
|
||||||
@ -408,7 +401,6 @@ 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
@ -5,36 +5,13 @@
|
|||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.search-bar {
|
.search-bar {
|
||||||
margin-bottom: 0;
|
margin-bottom: 1.5rem;
|
||||||
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.45rem 0.85rem;
|
padding: 0.6rem 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
||||||
@ -71,10 +48,6 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
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);
|
||||||
@ -291,72 +264,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 2rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 1.5rem;
|
||||||
padding: 0.25rem;
|
padding: 1rem;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(0,0,0,0.08);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
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: 0.92rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.62rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.35px;
|
letter-spacing: 0.5px;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
||||||
@ -391,34 +322,15 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
|
||||||
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||||
|
|
||||||
<div class="top-controls-row">
|
<!-- Header -->
|
||||||
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<i class="bi bi-list-check"></i>
|
<h1 style="margin: 0; color: var(--accent);">
|
||||||
|
<i class="bi bi-list-check me-2"></i>Sager
|
||||||
</h1>
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<div class="stats-bar">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-value">{{ sager|length }}</div>
|
|
||||||
<div class="stat-label">Total</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
|
||||||
<div class="stat-label">Åbne</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-bar">
|
|
||||||
<input type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="searchInput"
|
|
||||||
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
|
|
||||||
autocomplete="off">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="top-controls-actions">
|
|
||||||
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
||||||
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||||
</button>
|
</button>
|
||||||
@ -428,6 +340,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Bar -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ sager|length }}</div>
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
||||||
|
<div class="stat-label">Åbne</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>
|
||||||
|
|
||||||
|
<!-- Search & Filters -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="searchInput"
|
||||||
|
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
|
||||||
|
autocomplete="off">
|
||||||
|
</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>
|
||||||
@ -443,7 +380,6 @@
|
|||||||
<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 %}
|
||||||
@ -514,7 +450,10 @@
|
|||||||
{{ 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" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
|
<div class="sag-titel">{{ 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>
|
||||||
@ -522,19 +461,8 @@
|
|||||||
<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 }}'">
|
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{% if sag.ansvarlig_navn %}
|
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
||||||
{% 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 '-' }}
|
||||||
@ -589,7 +517,10 @@
|
|||||||
{% 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;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
|
<div class="sag-titel" style="display: inline;">{{ 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>
|
||||||
@ -597,19 +528,8 @@
|
|||||||
<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 }}'">
|
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{% if related_sag.ansvarlig_navn %}
|
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
||||||
{% 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 '-' }}
|
||||||
|
|||||||
@ -57,40 +57,6 @@ 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 = {
|
||||||
@ -260,21 +226,16 @@ 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"), key=_migration_sort_key):
|
for migration_file in sorted(migrations_dir.glob("*.sql")):
|
||||||
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",
|
||||||
@ -282,8 +243,7 @@ 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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -295,7 +255,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"), key=_migration_sort_key) if migrations_dir.exists() else []
|
files = sorted(migrations_dir.glob("*.sql")) if migrations_dir.exists() else []
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -46,20 +46,6 @@
|
|||||||
</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">
|
||||||
|
|||||||
@ -68,18 +68,6 @@
|
|||||||
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 {
|
||||||
@ -175,11 +163,6 @@
|
|||||||
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 {
|
||||||
@ -245,24 +228,6 @@
|
|||||||
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);
|
||||||
@ -281,30 +246,18 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -316,10 +269,6 @@
|
|||||||
[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;
|
||||||
}
|
}
|
||||||
@ -330,7 +279,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
@ -339,19 +287,12 @@
|
|||||||
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 {
|
||||||
display: block;
|
opacity: 0;
|
||||||
}
|
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 {
|
||||||
@ -456,40 +397,10 @@
|
|||||||
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, box-shadow 0.2s ease;
|
transition: transform 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) {
|
||||||
@ -508,14 +419,14 @@
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar.is-expanded .bb-header {
|
.global-bottom-bar .bb-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
row-gap: 0.4rem;
|
row-gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar.is-expanded .bb-zone-left,
|
.global-bottom-bar .bb-zone-left,
|
||||||
.global-bottom-bar.is-expanded .bb-zone-center,
|
.global-bottom-bar .bb-zone-center,
|
||||||
.global-bottom-bar.is-expanded .bb-zone-right {
|
.global-bottom-bar .bb-zone-right {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@ -1047,11 +958,23 @@
|
|||||||
<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-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="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="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="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="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>
|
<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>
|
||||||
</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>
|
||||||
@ -1065,6 +988,10 @@
|
|||||||
<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>
|
||||||
@ -1082,7 +1009,6 @@
|
|||||||
<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>
|
||||||
@ -1098,79 +1024,6 @@
|
|||||||
</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;
|
||||||
@ -1189,7 +1042,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.22"></script>
|
<script src="/static/js/bottom-bar.js?v=2.15"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
|
|||||||
@ -91,35 +91,6 @@ 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))
|
||||||
@ -174,138 +145,22 @@ 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
|
FROM tmodule_customers
|
||||||
FROM tmodule_customers
|
WHERE hub_customer_id = %s
|
||||||
WHERE hub_customer_id = %s
|
ORDER BY id ASC
|
||||||
ORDER BY id ASC
|
LIMIT 1
|
||||||
LIMIT 1
|
""",
|
||||||
""",
|
(hub_customer_id,),
|
||||||
(hub_customer_id,),
|
)
|
||||||
)
|
if not mapped:
|
||||||
if not mapped:
|
return None
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(mapped.get("id")) if mapped.get("id") is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
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:
|
try:
|
||||||
hub_customer = execute_query_single(
|
return int(mapped.get("id")) if mapped.get("id") is not None else None
|
||||||
"""
|
except (TypeError, ValueError):
|
||||||
SELECT id, name, economic_customer_number
|
return None
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -2072,7 +1927,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, pause_total_seconds, paused_at
|
SELECT id, start_tid, round_block_min
|
||||||
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
|
||||||
@ -2083,16 +1938,13 @@ async def start_live_timer_v1(
|
|||||||
|
|
||||||
paused_entry = None
|
paused_entry = None
|
||||||
if existing:
|
if existing:
|
||||||
actual_minutes = _elapsed_minutes_excluding_pause(existing, now)
|
actual_minutes = _minutes_between(existing.get("start_tid"), now) or 0
|
||||||
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),
|
||||||
@ -2102,16 +1954,7 @@ 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"]
|
||||||
|
|
||||||
@ -2206,11 +2049,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 = _elapsed_minutes_excluding_pause(entry, now)
|
actual_minutes = _minutes_between(start_tid, 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")
|
||||||
@ -2223,8 +2066,6 @@ 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),
|
||||||
@ -2239,7 +2080,6 @@ async def stop_live_timer_v1(
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
now,
|
now,
|
||||||
pause_total_seconds,
|
|
||||||
actual_minutes,
|
actual_minutes,
|
||||||
billable_minutes,
|
billable_minutes,
|
||||||
actual_minutes,
|
actual_minutes,
|
||||||
@ -2260,186 +2100,6 @@ 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(...),
|
||||||
|
|||||||
116
app/vendors/backend/router.py
vendored
116
app/vendors/backend/router.py
vendored
@ -5,56 +5,14 @@ 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, execute_query_single, execute_update
|
from app.core.database import execute_query
|
||||||
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"),
|
||||||
@ -214,75 +172,3 @@ 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}
|
|
||||||
|
|||||||
164
app/vendors/frontend/vendor_detail.html
vendored
164
app/vendors/frontend/vendor_detail.html
vendored
@ -131,9 +131,6 @@
|
|||||||
<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>
|
||||||
@ -227,35 +224,6 @@
|
|||||||
</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">
|
||||||
@ -372,144 +340,12 @@ 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;
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@ -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', 'true', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
|
('bottom_bar_enabled', 'false', '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.
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
-- 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.';
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
-- 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.';
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
-- 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
Loading…
Reference in New Issue
Block a user