feat: Enhance vendor and customer linking functionality

- Added endpoints to link and unlink customers to vendors, including validation for relationship types.
- Implemented a UI for managing linked customers in the vendor detail view.
- Introduced a search feature for customers when linking to vendors.
- Updated database schema to support customer-vendor relationships with necessary constraints and indices.
- Added migration scripts for new tables and fields related to supplier invoices and customer-vendor links.
- Modified bottom bar visibility in the frontend for improved user experience.
This commit is contained in:
Christian 2026-04-15 09:34:26 +02:00
parent 13dc1736b4
commit 8e8616c835
23 changed files with 3645 additions and 208 deletions

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,42 @@ logger = logging.getLogger(__name__)
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
class CustomerBase(BaseModel):
name: str
@ -517,6 +553,78 @@ async def get_customer_utility_company(customer_id: int):
"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")
async def create_customer(customer: CustomerCreate):
"""Create a new customer"""
@ -1096,7 +1204,69 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
raise HTTPException(status_code=404, detail="Customer not found")
try:
# Create contact
normalized_email = (contact.email or "").strip().lower() or None
existing_contact = None
# Prefer exact email match scoped to this customer, then global email match.
if normalized_email:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND LOWER(COALESCE(c.email, '')) = %s
ORDER BY c.id ASC
LIMIT 1
""",
(customer_id, normalized_email),
)
if not existing_contact:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
WHERE LOWER(COALESCE(c.email, '')) = %s
ORDER BY c.id ASC
LIMIT 1
""",
(normalized_email,),
)
# Fallback dedupe by full name within same customer when email is missing.
if not existing_contact and not normalized_email:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s)
AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s)
ORDER BY c.id ASC
LIMIT 1
""",
(customer_id, contact.first_name, contact.last_name),
)
if existing_contact:
contact_id = int(existing_contact["id"])
execute_update(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (contact_id, customer_id)
DO UPDATE SET
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
role = COALESCE(contact_companies.role, EXCLUDED.role)
""",
(contact_id, customer_id, bool(contact.is_primary), contact.role),
)
logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id)
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
# Create contact when no reusable match exists.
contact_id = execute_insert(
"""INSERT INTO contacts
(first_name, last_name, email, phone, mobile, title, department)
@ -1105,7 +1275,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
(
contact.first_name,
contact.last_name,
contact.email,
normalized_email,
contact.phone,
contact.mobile,
contact.title,
@ -1114,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
)
# Link contact to customer
execute_insert(
execute_update(
"""INSERT INTO contact_companies
(contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)""",
(contact_id, customer_id, contact.is_primary, contact.role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, customer_id, bool(contact.is_primary), contact.role)
)
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")

View File

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

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime, date
import unicodedata
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.services.email_processor_service import EmailProcessorService
@ -20,6 +21,218 @@ router = APIRouter()
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
class EmailListItem(BaseModel):
@ -225,6 +438,12 @@ class RewriteEmailTextResponse(BaseModel):
context: Optional[str] = None
class DomainMappingUpsertRequest(BaseModel):
domain: str
customer_id: int
source: Optional[str] = "manual"
@router.get("/emails/sag-options")
async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI."""
@ -294,6 +513,222 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e))
@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)
async def rewrite_email_text(request: RewriteEmailTextRequest):
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
@ -608,6 +1043,16 @@ async def link_email(email_id: int, payload: Dict):
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
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}")
return {"success": True, "message": "Email linket"}
@ -630,13 +1075,59 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
raise HTTPException(status_code=404, detail="Email not found")
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')
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:
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()
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
template_key = (payload.case_type or 'support').strip().lower()[:50]
template_key = requested_case_type[:50]
priority = (payload.priority or 'normal').strip().lower()
if priority not in {'low', 'normal', 'high', 'urgent'}:

View File

@ -283,6 +283,62 @@
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 {
max-width: 240px;
overflow: hidden;
@ -301,6 +357,17 @@
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 {
flex: 1;
padding: 1.5rem;
@ -1743,6 +1810,7 @@ function renderEmailList(emailList) {
<div class="email-preview">${escapeHtml(preview)}</div>
<div class="email-meta">
${!email.is_read ? '<span class="unread-indicator"></span>' : ''}
${getPriorityBadge(email)}
<span class="classification-badge classification-${classification}">
${formatClassification(classification)}
</span>
@ -1793,6 +1861,7 @@ async function loadEmailDetail(emailId) {
renderEmailDetail(email);
renderEmailAnalysis(email);
await maybeSuggestCustomerByDomain(email);
if (!email.is_read) {
await markAsRead(emailId);
@ -1805,6 +1874,176 @@ async function loadEmailDetail(emailId) {
}
}
function withActionLoading(button, loadingText = 'Arbejder...') {
const btn = button || null;
if (!btn) {
return () => {};
}
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${loadingText}`;
return () => {
btn.disabled = false;
btn.innerHTML = original;
};
}
function normalizeDomain(value) {
const domain = String(value || '').trim().toLowerCase();
if (!domain) return '';
return domain.startsWith('www.') ? domain.slice(4) : domain;
}
function splitSenderName(name, email) {
const raw = String(name || '').trim();
if (!raw) {
const fallback = String(email || '').split('@')[0] || 'Kontakt';
return { firstName: fallback.slice(0, 40), lastName: 'Fra email' };
}
const cleaned = raw.replace(/[<>]/g, '').trim();
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 1) {
return { firstName: parts[0].slice(0, 40), lastName: '-' };
}
return {
firstName: parts[0].slice(0, 40),
lastName: parts.slice(1).join(' ').slice(0, 60)
};
}
function setCaseCustomerSelection(customerId, customerName) {
const hidden = document.getElementById('caseCustomerId');
const input = document.getElementById('caseCustomerSearch');
if (hidden) hidden.value = customerId ? String(customerId) : '';
if (input && customerName) input.value = customerName;
}
function renderDomainCustomerHint(html, visible = true) {
const hint = document.getElementById('domainCustomerHint');
if (!hint) return;
hint.innerHTML = html || '';
hint.style.display = visible ? 'block' : 'none';
}
async function linkEmailToCustomerQuick(customerId, customerName) {
if (!currentEmailId || !customerId) return;
try {
const resp = await fetch(`/api/v1/emails/${currentEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId })
});
if (!resp.ok) throw new Error('Kunne ikke linke kunden');
try {
const senderEmail = String(document.querySelector('.sender-email')?.textContent || '').trim().toLowerCase();
const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
if (domain) {
await fetch('/api/v1/emails/domain-customer-mapping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain,
customer_id: customerId,
source: 'manual'
})
});
}
} catch (mappingError) {
console.warn('Could not save domain mapping', mappingError);
}
setCaseCustomerSelection(customerId, customerName);
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-check-circle text-success me-1"></i>Firma linket</div>
<div class="domain-suggestion-meta">Email er nu linket til ${escapeHtml(customerName || 'kunden')}.</div>`,
true
);
showSuccess(`Linket til ${customerName}`);
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunde-link fejlede');
}
}
async function maybeSuggestCustomerByDomain(email) {
if (!email || email.customer_id) {
renderDomainCustomerHint('', false);
return;
}
const senderEmail = String(email.sender_email || '').trim().toLowerCase();
const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
if (!domain) {
renderDomainCustomerHint('', false);
return;
}
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-search me-1"></i>Søger firma på domæne…</div>
<div class="domain-suggestion-meta">Domæne: ${escapeHtml(domain)}</div>`,
true
);
try {
const resp = await fetch(`/api/v1/emails/${email.id}/domain-customer-suggestion`);
if (!resp.ok) throw new Error('Domæneopslag fejlede');
const payload = await resp.json();
const best = payload?.suggestion
? {
id: payload.suggestion.customer_id,
name: payload.suggestion.customer_name,
email_domain: payload.suggestion.email_domain,
cvr_number: payload.suggestion.cvr_number,
source: payload.suggestion.source,
score: payload.suggestion.score,
confidence: payload.suggestion.confidence
}
: null;
if (!best) {
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-info-circle me-1"></i>Ingen firmamatch</div>
<div class="domain-suggestion-meta">Ingen kunde fundet for ${escapeHtml(domain)}. Brug “Opret firma/kontakt”.</div>`,
true
);
return;
}
const confidence = best.confidence === 'high' ? 'Høj' : 'Mellem';
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-building me-1"></i>Muligt firma fundet (${confidence} confidence)</div>
<div class="domain-suggestion-meta">
<strong>${escapeHtml(best.name)}</strong>
${best.email_domain ? `• ${escapeHtml(best.email_domain)}` : ''}
${best.cvr_number ? `• CVR ${escapeHtml(best.cvr_number)}` : ''}
${best.source ? `• Kilde: ${escapeHtml(best.source)}` : ''}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" onclick="linkEmailToCustomerQuick(${best.id}, '${escapeHtml(best.name).replace(/'/g, "\\'")}')">
<i class="bi bi-link-45deg me-1"></i>Link firma
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')">
<i class="bi bi-plus-circle me-1"></i>Opret ny
</button>
</div>`,
true
);
setCaseCustomerSelection(best.id, best.name);
} catch (error) {
renderDomainCustomerHint(
`<div class="title"><i class="bi bi-exclamation-circle me-1"></i>Domæneopslag fejlede</div>
<div class="domain-suggestion-meta">${escapeHtml(error.message || 'Ukendt fejl')}</div>`,
true
);
}
}
function getClassificationActions(email) {
const actions = [];
@ -1918,31 +2157,51 @@ function renderEmailDetail(email) {
` : ''}
</div>
<div class="email-actions d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
<button class="btn btn-sm btn-light border" onclick="archiveEmail()" title="Arkivér (e)">
<i class="bi bi-archive"></i>
<div class="email-actions">
<div class="quick-actions-grid">
<button class="btn btn-sm btn-primary" onclick="createSupplierInvoice(${email.id}, this)" title="Leverandørfaktura">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
</button>
<button class="btn btn-sm btn-light border" onclick="markAsSpam()" title="Marker som spam">
<i class="bi bi-exclamation-triangle"></i>
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'support')" title="Kundesag">
<i class="bi bi-folder-plus me-1"></i>Kundesag
</button>
<button class="btn btn-sm btn-light border" onclick="reprocessEmail()" title="Genbehandl (r)">
<i class="bi bi-arrow-clockwise"></i>
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'bogholderi', 'Fakturasporgsmal')" title="Fakturaspørgsmål">
<i class="bi bi-question-circle me-1"></i>Fakturaspørgsmål
</button>
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
<i class="bi bi-diagram-3 me-1"></i>Workflows
<button class="btn btn-sm btn-outline-secondary" onclick="markAsSpam(this)" title="Marker som spam">
<i class="bi bi-slash-circle me-1"></i>Spam
</button>
${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
</a>
` : ''}
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet">
<i class="bi bi-trash"></i>
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')" title="Opret firma/kontakt">
<i class="bi bi-building-add me-1"></i>Opret firma/kontakt
</button>
</div>
<div class="email-secondary-actions">
<div class="left">
<button class="btn btn-sm btn-light border" onclick="archiveEmail(this)" title="Arkivér (e)">
<i class="bi bi-archive"></i>
</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 ? `
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2 flex-wrap mt-2 pt-2 border-top">
<span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span>
${email.attachments.map(att => {
const canPreview = canPreviewFile(att.content_type);
@ -2033,7 +2292,7 @@ function renderEmailAnalysis(email) {
<div class="analysis-card">
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
<div class="quick-action-row mb-3">
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion(this)">
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
@ -2068,6 +2327,7 @@ function renderEmailAnalysis(email) {
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
</div>
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
<div id="domainCustomerHint" class="domain-suggestion-card" style="display:none;"></div>
</div>
<div class="suggestion-field">
@ -2113,7 +2373,7 @@ function renderEmailAnalysis(email) {
</div>
<div class="quick-action-row quick-action-row-case mt-3">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm(this)">
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
</button>
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
@ -2314,8 +2574,8 @@ async function searchSagerForCurrentEmail(query) {
}
}
function confirmSuggestion() {
createCaseFromCurrentForm();
function confirmSuggestion(button = null) {
createCaseFromCurrentForm(button);
}
function getCaseFormPayload() {
@ -2336,8 +2596,11 @@ function getCaseFormPayload() {
};
}
async function createCaseFromCurrentForm() {
let caseCreateInFlight = false;
async function createCaseFromCurrentForm(button = null) {
if (!currentEmailId) return;
if (caseCreateInFlight) return;
const payload = getCaseFormPayload();
if (!payload.customer_id) {
@ -2345,6 +2608,9 @@ async function createCaseFromCurrentForm() {
return;
}
caseCreateInFlight = true;
const done = withActionLoading(button, 'Opretter...');
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
method: 'POST',
@ -2358,11 +2624,18 @@ async function createCaseFromCurrentForm() {
}
const result = await response.json();
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
if (result.idempotent) {
showInfo(`E-mail var allerede knyttet til SAG-${result.sag.id}`);
} else {
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
}
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke oprette sag');
} finally {
caseCreateInFlight = false;
done();
}
}
@ -2582,8 +2855,9 @@ function formatEventType(eventType) {
return labels[eventType] || eventType;
}
async function archiveEmail() {
async function archiveEmail(button = null) {
if (!currentEmailId) return;
const done = withActionLoading(button, 'Arkiverer...');
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, {
@ -2599,14 +2873,18 @@ async function archiveEmail() {
loadEmails();
} catch (error) {
showError('Kunne ikke arkivere email');
} finally {
done();
}
}
async function markAsSpam() {
async function markAsSpam(button = null) {
if (!currentEmailId) return;
if (!confirm('Marker denne email som spam?')) return;
const done = withActionLoading(button, 'Spam...');
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
method: 'PUT',
@ -2623,17 +2901,16 @@ async function markAsSpam() {
loadEmails();
} catch (error) {
showError('Kunne ikke markere som spam');
} finally {
done();
}
}
async function reprocessEmail() {
async function reprocessEmail(button = null) {
if (!currentEmailId) return;
const done = withActionLoading(button, 'Genbehandler...');
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`, {
method: 'POST'
});
@ -2647,18 +2924,17 @@ async function reprocessEmail() {
} catch (error) {
showError('Kunne ikke genbehandle email');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
}
done();
}
}
async function deleteEmail() {
async function deleteEmail(button = null) {
if (!currentEmailId) return;
if (!confirm('Slet denne email permanent?')) return;
const done = withActionLoading(button, 'Sletter...');
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}`, {
method: 'DELETE'
@ -2672,11 +2948,86 @@ async function deleteEmail() {
loadEmails();
} catch (error) {
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
async function createSupplierInvoice(emailId) {
const supplierInvoiceInFlightByEmail = new Set();
async function ensureSupplierCaseForEmail(email) {
if (!email || !email.id) return null;
if (email.linked_case_id) return Number(email.linked_case_id);
const subject = String(email.subject || '').trim();
const sender = String(email.sender_email || '').trim();
const payload = {
case_type: 'indkoeb',
relation_type: 'mail',
priority: 'normal',
titel: subject ? `Leverandørmail: ${subject}` : `Leverandørmail fra ${sender || 'ukendt afsender'}`,
beskrivelse: [
'Auto-oprettet fra leverandør email-behandling',
`Afsender: ${sender || '-'}`,
`Subject: ${subject || '-'}`
].join('\n')
};
if (email.customer_id) {
payload.customer_id = Number(email.customer_id);
}
const response = await fetch(`/api/v1/emails/${email.id}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({}));
const detail = errorPayload?.detail || 'Kunne ikke oprette relateret sag';
throw new Error(detail);
}
const result = await response.json();
return result?.sag?.id ? Number(result.sag.id) : null;
}
async function createSupplierInvoice(emailId, button = null) {
if (supplierInvoiceInFlightByEmail.has(Number(emailId))) {
showInfo('Leverandørfaktura behandles allerede for denne email...');
return;
}
supplierInvoiceInFlightByEmail.add(Number(emailId));
const done = withActionLoading(button, 'Behandler...');
try {
console.log('📧 Behandler email...');
@ -2753,23 +3104,41 @@ async function createSupplierInvoice(emailId) {
// Show result
if (successCount > 0) {
showSuccess(`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}`);
let linkedSagId = null;
// Mark email as processed and move to Processed folder
try {
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
method: 'POST'
});
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');
linkedSagId = await ensureSupplierCaseForEmail(email);
if (linkedSagId) {
email.linked_case_id = linkedSagId;
}
} catch (e) {
console.error('Error marking email as processed:', e);
} catch (caseError) {
console.error('Error creating supplier-related case:', caseError);
showError(`Faktura uploadet, men sag-oprettelse fejlede: ${caseError.message}`);
}
if (!linkedSagId) {
// Fallback: keep prior behavior if case link was not created.
try {
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
@ -2783,6 +3152,9 @@ async function createSupplierInvoice(emailId) {
} catch (error) {
console.error('Error creating supplier invoice:', error);
showError('Kunne ikke behandle email: ' + error.message);
} finally {
supplierInvoiceInFlightByEmail.delete(Number(emailId));
done();
}
}
@ -2818,11 +3190,16 @@ async function linkToCustomer(emailId) {
// ─── Quick Create Customer ────────────────────────────────────────────────
function quickCreateCustomer(emailId, senderName, senderEmail) {
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
const nameParts = splitSenderName(senderName, senderEmail);
document.getElementById('qcEmailId').value = emailId;
document.getElementById('qcCustomerName').value = senderName || '';
document.getElementById('qcCustomerEmail').value = senderEmail || '';
document.getElementById('qcCustomerDomain').value = senderDomain;
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 = '';
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
modal.show();
@ -2834,25 +3211,64 @@ async function submitQuickCustomer() {
const email = document.getElementById('qcCustomerEmail').value.trim();
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
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');
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
try {
// Create 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');
const customer = await custResp.json();
let customer = null;
// Idempotent fast path: try to reuse existing customer by domain/email before create.
if (domain || email) {
const probe = domain || email;
const searchResp = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(probe)}&limit=20`);
if (searchResp.ok) {
const matches = await searchResp.json();
const exactByDomain = (matches || []).find((row) => normalizeDomain(row.email_domain) === domain);
const exactByEmail = !exactByDomain
? (matches || []).find((row) => String(row.email || '').trim().toLowerCase() === email.toLowerCase())
: null;
customer = exactByDomain || exactByEmail || null;
}
}
if (!customer) {
const custResp = await fetch('/api/v1/customers', {
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
await fetch(`/api/v1/emails/${emailId}/link`, {
@ -2862,7 +3278,7 @@ async function submitQuickCustomer() {
});
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
showSuccess(`Kunde "${name}" oprettet og linket`);
showSuccess(`Kunde "${customer.name || name}" linket`);
loadEmailDetail(parseInt(emailId));
} catch (e) {
statusEl.className = 'text-danger small';
@ -3254,6 +3670,26 @@ function getCaseBadge(email) {
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
}
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) {
if (contentType?.includes('pdf')) return 'pdf';
if (contentType?.includes('image')) return 'image';
@ -4056,7 +4492,7 @@ async function deleteWorkflow(id) {
}
}
async function executeWorkflowsForEmail() {
async function executeWorkflowsForEmail(button = null) {
if (!currentEmailId) {
alert('Ingen email valgt');
return;
@ -4064,6 +4500,8 @@ async function executeWorkflowsForEmail() {
if (!confirm('Vil du køre workflows for denne email?')) return;
const done = withActionLoading(button, 'Kører...');
try {
showNotification('Kører workflows...', 'info');
@ -4100,6 +4538,8 @@ async function executeWorkflowsForEmail() {
} catch (error) {
console.error('Error executing workflows:', error);
showNotification('❌ Kunne ikke køre workflows', 'danger');
} finally {
done();
}
}
@ -4885,6 +5325,26 @@ async function uploadEmailFiles(files) {
<label class="form-label fw-semibold">Telefon</label>
<input type="text" class="form-control" id="qcCustomerPhone">
</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>
<div class="modal-footer">

View File

@ -148,7 +148,7 @@ def get_dashboard_status() -> Dict[str, int]:
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
"""
)
)
@ -262,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.status IS NULL OR l.status != 'dismissed')
ORDER BY
CASE LOWER(COALESCE(r.priority, 'normal'))
CASE LOWER(COALESCE(r.priority::text, 'normal'))
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
@ -386,7 +386,7 @@ def build_bottom_bar_state(
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
@ -447,7 +447,7 @@ def build_bottom_bar_state(
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
@ -473,7 +473,7 @@ def build_bottom_bar_state(
json_build_object(
'id', t.id,
'title', t.titel,
'priority', COALESCE(t.priority, 'normal'),
'priority', COALESCE(t.priority::text, 'normal'),
'deadline', t.deadline
)
ORDER BY
@ -531,7 +531,7 @@ def build_bottom_bar_state(
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
LIMIT 8

View File

@ -1104,6 +1104,23 @@ async def update_sag(sag_id: int, updates: dict):
if "customer_id" in updates:
updates["customer_id"] = _coerce_optional_int(updates.get("customer_id"), "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
allowed_fields = [
@ -1120,6 +1137,8 @@ async def update_sag(sag_id: int, updates: dict):
"deferred_until",
"deferred_until_case_id",
"deferred_until_status",
"supplier_flow_type",
"supplier_flow_confidence",
]
set_clauses = []
params = []

View File

@ -2678,6 +2678,11 @@
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="supplier-tab" data-bs-toggle="tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="forceCaseTabActivation('supplier', this)">
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
@ -6112,6 +6117,136 @@
</div>
</div>
<!-- Leverandør Tab -->
<div class="tab-pane fade" id="supplier" role="tabpanel" tabindex="0" data-module="supplier" data-has-content="unknown" style="display:none;">
<div class="row g-3 mb-3">
<div class="col-xl-8 col-12">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-diagram-3 me-2"></i>Leverandørflow Type</h6>
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierFlowType()">
<i class="bi bi-save me-1"></i>Gem type
</button>
</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label">Type</label>
<select class="form-select" id="supplierFlowTypeSelect" onchange="toggleSupplierTypePanels()">
<option value="">Vælg type...</option>
<option value="varekob" {% if case.supplier_flow_type == 'varekob' %}selected{% endif %}>Varekøb</option>
<option value="ydelse" {% if case.supplier_flow_type == 'ydelse' %}selected{% endif %}>Købt ydelse</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">AI confidence</label>
<input type="number" class="form-control" id="supplierFlowConfidenceInput" min="0" max="1" step="0.01" value="{{ case.supplier_flow_confidence if case.supplier_flow_confidence is not none else '' }}" placeholder="0.00 - 1.00">
</div>
<div class="col-md-4">
<label class="form-label">Forslag</label>
<div id="supplierFlowSuggestion" class="small text-muted border rounded p-2">Ingen forslag endnu</div>
</div>
</div>
</div>
</div>
<div class="card mb-3" id="supplierVarekobPanel" style="display:none;">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-box-seam me-2"></i>VAREKØB handlinger pr. linje</h6>
<span class="badge bg-light text-dark border" id="supplierPurchaseLinesCount">0 linjer</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead class="bg-light">
<tr>
<th class="ps-3">Beskrivelse</th>
<th>Beløb</th>
<th>Handling</th>
<th class="text-end pe-3">Gem</th>
</tr>
</thead>
<tbody id="supplierPurchaseLinesBody">
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mb-3" id="supplierYdelsePanel" style="display:none;">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-tools me-2"></i>YDELSE handling</h6>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Ydelsestype</label>
<select class="form-select" id="supplierServiceType">
<option value="husleje">Husleje</option>
<option value="rengoering">Rengøring</option>
<option value="konsulent">Konsulent</option>
<option value="abonnement">Abonnement</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Periode fra</label>
<input type="date" class="form-control" id="supplierServiceFrom">
</div>
<div class="col-md-3">
<label class="form-label">Periode til</label>
<input type="date" class="form-control" id="supplierServiceTo">
</div>
<div class="col-md-2">
<label class="form-label">Beløb</label>
<input type="number" min="0" step="0.01" class="form-control" id="supplierServiceAmount" placeholder="0,00">
</div>
</div>
<div class="d-flex gap-2 mt-3 flex-wrap">
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('direkte_omkostning')">
<i class="bi bi-cash-stack me-1"></i>Direkte omkostning
</button>
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('fordel_kunder')">
<i class="bi bi-diagram-2 me-1"></i>Fordel på kunder
</button>
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('gentagende_ydelse')">
<i class="bi bi-arrow-repeat me-1"></i>Gentagende ydelse
</button>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-receipt me-2"></i>Leverandørfakturaer</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadSupplierModule()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="bg-light">
<tr>
<th class="ps-3">Faktura</th>
<th>Beløb</th>
<th>Status</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody id="supplierInvoicesBody">
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tidsforbrug Tab -->
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown" style="display:none;">
<div id="timeActiveBanner" class="alert alert-warning d-none d-flex justify-content-between align-items-center" role="alert">
@ -7527,6 +7662,313 @@
});
</script>
<script>
const supplierCaseId = {{ case.id }};
function supplierStatusBadge(status) {
const value = String(status || '').toLowerCase();
if (value === 'godkendt') return '<span class="badge bg-primary-subtle text-primary-emphasis">Godkendt</span>';
if (value === 'betalt') return '<span class="badge bg-success-subtle text-success-emphasis">Betalt</span>';
if (value === 'afvist') return '<span class="badge bg-danger-subtle text-danger-emphasis">Afvist</span>';
return '<span class="badge bg-warning-subtle text-warning-emphasis">Modtaget</span>';
}
function inferSupplierFlowSuggestion() {
const purchaseItems = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
if (purchaseItems.length > 0) {
return { type: 'varekob', confidence: 0.87, reason: 'Sag har indkøbslinjer fra leverandørflow' };
}
const purchaseText = (saleItemsCache || [])
.filter((item) => (item.type || '').toLowerCase() === 'purchase')
.map((item) => String(item.description || '').toLowerCase())
.join(' ');
if (/(abonnement|hosting|licens|service|konsulent|rengoering|husleje)/.test(purchaseText)) {
return { type: 'ydelse', confidence: 0.76, reason: 'Beskrivelser matcher ydelsesmønstre' };
}
return null;
}
function renderSupplierSuggestion() {
const suggestionEl = document.getElementById('supplierFlowSuggestion');
if (!suggestionEl) return;
const suggestion = inferSupplierFlowSuggestion();
if (!suggestion) {
suggestionEl.innerHTML = '<span class="text-muted">Ingen sikker AI-indikation endnu</span>';
return;
}
suggestionEl.innerHTML = `
<div><strong>Forslag:</strong> ${suggestion.type === 'varekob' ? 'Varekøb' : 'Købt ydelse'}</div>
<div class="small text-muted">Confidence: ${(suggestion.confidence * 100).toFixed(0)}% · ${suggestion.reason}</div>
<button class="btn btn-sm btn-outline-primary mt-2" onclick="applySupplierSuggestion('${suggestion.type}', ${suggestion.confidence})">
<i class="bi bi-magic me-1"></i>Anvend forslag
</button>
`;
}
function applySupplierSuggestion(type, confidence) {
const typeEl = document.getElementById('supplierFlowTypeSelect');
const confEl = document.getElementById('supplierFlowConfidenceInput');
if (typeEl) typeEl.value = type;
if (confEl) confEl.value = Number(confidence).toFixed(2);
toggleSupplierTypePanels();
}
function toggleSupplierTypePanels() {
const type = document.getElementById('supplierFlowTypeSelect')?.value || '';
const varekobPanel = document.getElementById('supplierVarekobPanel');
const ydelsePanel = document.getElementById('supplierYdelsePanel');
if (varekobPanel) varekobPanel.style.display = type === 'varekob' ? '' : 'none';
if (ydelsePanel) ydelsePanel.style.display = type === 'ydelse' ? '' : 'none';
if (type === 'varekob') {
renderSupplierPurchasePurposeRows();
}
}
async function saveSupplierFlowType() {
const type = document.getElementById('supplierFlowTypeSelect')?.value || null;
const confidenceRaw = document.getElementById('supplierFlowConfidenceInput')?.value || null;
const payload = {
supplier_flow_type: type,
supplier_flow_confidence: confidenceRaw === null || confidenceRaw === '' ? null : Number(confidenceRaw)
};
const res = await fetch(`/api/v1/sag/${supplierCaseId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
alert('Kunne ikke gemme leverandørflow type');
return;
}
alert('Leverandørflow type gemt');
toggleSupplierTypePanels();
}
function renderSupplierPurchasePurposeRows() {
const body = document.getElementById('supplierPurchaseLinesBody');
const countBadge = document.getElementById('supplierPurchaseLinesCount');
if (!body) return;
const purchases = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
if (countBadge) countBadge.textContent = `${purchases.length} linjer`;
if (!purchases.length) {
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen indkøbslinjer fundet på denne sag</td></tr>';
return;
}
body.innerHTML = purchases.map((item) => `
<tr>
<td class="ps-3">${item.description || '-'}</td>
<td>${formatCurrency(item.amount || 0)}</td>
<td>
<select class="form-select form-select-sm" id="supplierPurpose-${item.id}">
<option value="">Vælg...</option>
<option value="salg" ${item.purchase_purpose === 'salg' ? 'selected' : ''}>Videresalg</option>
<option value="asset" ${item.purchase_purpose === 'asset' ? 'selected' : ''}>Opret som Asset</option>
<option value="intern_brug" ${item.purchase_purpose === 'intern_brug' ? 'selected' : ''}>Intern brug</option>
<option value="projekt_omkostning" ${item.purchase_purpose === 'projekt_omkostning' ? 'selected' : ''}>Forbrug/omkostning</option>
<option value="lager" ${item.purchase_purpose === 'lager' ? 'selected' : ''}>Lager</option>
<option value="retur_reklamation" ${item.purchase_purpose === 'retur_reklamation' ? 'selected' : ''}>Retur/reklamation</option>
</select>
</td>
<td class="text-end pe-3">
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierPurchasePurpose(${item.id})">
<i class="bi bi-save"></i>
</button>
</td>
</tr>
`).join('');
}
async function saveSupplierPurchasePurpose(itemId) {
const purpose = document.getElementById(`supplierPurpose-${itemId}`)?.value || null;
const res = await fetch(`/api/v1/sag/${supplierCaseId}/sale-items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'purchase',
purchase_purpose: purpose
})
});
if (!res.ok) {
alert('Kunne ikke gemme linjehandling');
return;
}
await loadVarekobSalg();
renderSupplierPurchasePurposeRows();
}
async function registerSupplierServiceHandling(actionCode) {
const serviceType = document.getElementById('supplierServiceType')?.value || '';
const fromDate = document.getElementById('supplierServiceFrom')?.value || '';
const toDate = document.getElementById('supplierServiceTo')?.value || '';
const amount = document.getElementById('supplierServiceAmount')?.value || '';
const content = [
'Supplier ydelse-handling registreret',
`Handling: ${actionCode}`,
`Type: ${serviceType}`,
`Periode: ${fromDate || '-'} -> ${toDate || '-'}`,
`Beløb: ${amount || '-'} DKK`,
].join('\n');
const res = await fetch(`/api/v1/sag/${supplierCaseId}/kommentarer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
indhold: content,
er_system_besked: true,
forfatter: 'Supplier Flow'
})
});
if (!res.ok) {
alert('Kunne ikke registrere ydelseshandling');
return;
}
alert('Ydelseshandling registreret på sagen');
}
async function supplierApproveInvoice(invoiceId) {
const approvedBy = prompt('Hvem godkender?', 'System');
if (!approvedBy) return;
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved_by: approvedBy })
});
if (!res.ok) {
const msg = await res.text();
alert(`Kunne ikke godkende faktura: ${msg}`);
return;
}
await loadSupplierModule();
}
async function supplierRejectInvoice(invoiceId) {
const rejectedBy = prompt('Hvem afviser?', 'System');
if (!rejectedBy) return;
const reason = prompt('Årsag til afvisning (valgfri):', '') || null;
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rejected_by: rejectedBy, reason })
});
if (!res.ok) {
const msg = await res.text();
alert(`Kunne ikke afvise faktura: ${msg}`);
return;
}
await loadSupplierModule();
}
async function supplierMarkPaid(invoiceId) {
const amountRaw = prompt('Betalingsbeløb (tom = restbeløb):', '');
const amount = amountRaw && amountRaw.trim() !== '' ? Number(amountRaw) : null;
if (amountRaw && (!Number.isFinite(amount) || amount <= 0)) {
alert('Ugyldigt beløb');
return;
}
const method = prompt('Betalingsmetode (fx bank):', '') || null;
const reference = prompt('Betalingsreference (valgfri):', '') || null;
const paidBy = prompt('Registreret af:', 'System') || 'System';
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount,
payment_method: method,
payment_reference: reference,
paid_by: paidBy
})
});
if (!res.ok) {
const msg = await res.text();
alert(`Kunne ikke registrere betaling: ${msg}`);
return;
}
await loadSupplierModule();
}
function renderSupplierInvoices(items) {
const body = document.getElementById('supplierInvoicesBody');
if (!body) return;
if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen leverandørfakturaer på sagen</td></tr>';
return;
}
body.innerHTML = items.map((inv) => {
const statusV2 = inv.status_v2 || 'modtaget';
return `
<tr>
<td class="ps-3">
<div class="fw-semibold">${inv.invoice_number || `ID-${inv.id}`}</div>
<div class="small text-muted">${inv.invoice_date || '-'}</div>
</td>
<td>${formatCurrency(inv.total_amount || 0)}</td>
<td>${supplierStatusBadge(statusV2)}</td>
<td class="text-end pe-3">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="supplierApproveInvoice(${inv.id})" title="Godkend"><i class="bi bi-check2"></i></button>
<button class="btn btn-outline-danger" onclick="supplierRejectInvoice(${inv.id})" title="Afvis"><i class="bi bi-x"></i></button>
<button class="btn btn-outline-success" onclick="supplierMarkPaid(${inv.id})" title="Registrer betaling"><i class="bi bi-cash"></i></button>
</div>
</td>
</tr>
`;
}).join('');
}
async function loadSupplierModule() {
const body = document.getElementById('supplierInvoicesBody');
if (body) {
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>';
}
try {
const res = await fetch(`/api/v1/supplier-invoices?sag_id=${supplierCaseId}`);
if (!res.ok) throw new Error('Kunne ikke hente leverandørfakturaer');
const items = await res.json();
renderSupplierInvoices(items || []);
renderSupplierSuggestion();
toggleSupplierTypePanels();
setModuleContentState('supplier', (items || []).length > 0);
} catch (error) {
console.error(error);
if (body) {
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Kunne ikke hente leverandørfakturaer</td></tr>';
}
setModuleContentState('supplier', true);
}
}
document.addEventListener('DOMContentLoaded', function() {
loadSupplierModule();
toggleSupplierTypePanels();
});
</script>
<script>
const timeCaseId = {{ case.id }};
let timeV1EntriesById = {};

View File

@ -49,6 +49,10 @@
cursor: pointer;
}
.sag-table tbody tr:nth-child(even) {
background: rgba(15, 76, 117, 0.04);
}
.sag-table tbody tr:hover {
background: var(--accent-light);
}
@ -264,30 +268,61 @@
}
.stats-bar {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
padding: 1rem;
display: inline-flex;
gap: 0.4rem;
margin-bottom: 0.8rem;
padding: 0.25rem;
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.08);
}
.stat-item {
text-align: center;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(0,0,0,0.03);
line-height: 1.15;
}
.stat-value {
font-size: 1.5rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.8rem;
font-size: 0.62rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.35px;
}
.owner-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-avatar {
width: 1.6rem;
height: 1.6rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
background: var(--accent);
flex-shrink: 0;
}
.owner-name {
color: var(--text-secondary);
font-size: 0.85rem;
word-break: break-word;
}
.empty-state {
@ -322,13 +357,13 @@
{% endblock %}
{% block content %}
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h1 style="margin: 0; color: var(--accent);">
<i class="bi bi-list-check me-2"></i>Sager
<i class="bi bi-list-check"></i>
</h1>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
@ -350,10 +385,6 @@
<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 -->
@ -461,8 +492,19 @@
<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' }}
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'">
{% if sag.ansvarlig_navn %}
{% set owner_name = sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
<div class="owner-cell">
<span class="owner-avatar">
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
</span>
<span class="owner-name">{{ owner_name }}</span>
</div>
{% else %}
-
{% endif %}
</td>
<td 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 '-' }}
@ -528,8 +570,19 @@
<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' }}
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% if related_sag.ansvarlig_navn %}
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
<div class="owner-cell">
<span class="owner-avatar">
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
</span>
<span class="owner-name">{{ owner_name }}</span>
</div>
{% else %}
-
{% endif %}
</td>
<td 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 '-' }}

View File

@ -279,6 +279,7 @@
}
.global-bottom-bar .bb-detail-line {
display: none;
margin-top: 0.5rem;
background: transparent;
padding: 0 0.5rem;
@ -287,12 +288,19 @@
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none;
transition: opacity 0.2s ease;
}
.global-bottom-bar.is-expanded .bb-detail-line {
opacity: 0;
pointer-events: none;
position: absolute;
display: block;
}
.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 {
@ -419,14 +427,14 @@
min-height: 240px;
}
.global-bottom-bar .bb-header {
.global-bottom-bar.is-expanded .bb-header {
flex-wrap: wrap;
row-gap: 0.4rem;
}
.global-bottom-bar .bb-zone-left,
.global-bottom-bar .bb-zone-center,
.global-bottom-bar .bb-zone-right {
.global-bottom-bar.is-expanded .bb-zone-left,
.global-bottom-bar.is-expanded .bb-zone-center,
.global-bottom-bar.is-expanded .bb-zone-right {
flex: 1 1 100%;
justify-content: flex-start;
}

View File

@ -145,22 +145,138 @@ def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> O
if hub_customer_id is None:
return None
mapped = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
if not mapped:
return None
def _get_existing_mapping() -> Optional[int]:
mapped = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
if not mapped:
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:
return int(mapped.get("id")) if mapped.get("id") is not None else None
except (TypeError, ValueError):
return None
hub_customer = execute_query_single(
"""
SELECT id, name, economic_customer_number
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
) or {}
economic_number = hub_customer.get("economic_customer_number")
customer_name = str(hub_customer.get("name") or "").strip()
if economic_number not in (None, ""):
execute_update(
"""
UPDATE tmodule_customers
SET hub_customer_id = %s,
economic_customer_number = COALESCE(economic_customer_number, %s),
updated_at = NOW()
WHERE hub_customer_id IS NULL
AND economic_customer_number = %s
""",
(hub_customer_id, economic_number, economic_number),
)
if customer_name:
execute_update(
"""
UPDATE tmodule_customers
SET hub_customer_id = %s,
updated_at = NOW()
WHERE hub_customer_id IS NULL
AND LOWER(TRIM(COALESCE(name, ''))) = LOWER(TRIM(%s))
""",
(hub_customer_id, customer_name),
)
# Last fallback: run global relink function if available.
try:
execute_query("SELECT * FROM link_tmodule_customers_to_hub()")
except Exception:
pass
except Exception as link_error:
logger.warning("⚠️ Auto-link fallback for hub customer %s failed: %s", hub_customer_id, link_error)
mapped_id = _get_existing_mapping()
if mapped_id is not None:
return mapped_id
# Last-resort fallback: create a local tmodule customer stub from Hub customer.
# This keeps timer registration operational even if vTiger sync/linking has not run yet.
try:
hub_customer = execute_query_single(
"""
SELECT id, name, email, economic_customer_number
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
) or {}
hub_name = str(hub_customer.get("name") or "").strip()
if hub_name:
synthetic_vtiger_id = f"HUB-{hub_customer_id}"
created = execute_query(
"""
INSERT INTO tmodule_customers (
vtiger_id,
name,
email,
hub_customer_id,
economic_customer_number,
vtiger_data,
sync_hash,
updated_at,
last_synced_at
) VALUES (
%s, %s, %s, %s, %s,
%s::jsonb,
%s,
NOW(), NOW()
)
ON CONFLICT (vtiger_id)
DO UPDATE SET
name = EXCLUDED.name,
email = COALESCE(EXCLUDED.email, tmodule_customers.email),
hub_customer_id = EXCLUDED.hub_customer_id,
economic_customer_number = COALESCE(EXCLUDED.economic_customer_number, tmodule_customers.economic_customer_number),
updated_at = NOW(),
last_synced_at = NOW()
RETURNING id
""",
(
synthetic_vtiger_id,
hub_name,
hub_customer.get("email"),
hub_customer_id,
hub_customer.get("economic_customer_number"),
'{"source":"hub_fallback"}',
f"hub-fallback-{hub_customer_id}",
),
) or []
if created and created[0].get("id") is not None:
return int(created[0]["id"])
except Exception as create_error:
logger.warning("⚠️ Could not create tmodule customer fallback for hub customer %s: %s", hub_customer_id, create_error)
return None
# ============================================================================

View File

@ -5,14 +5,56 @@ Endpoints for managing suppliers and vendors
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
from pydantic import BaseModel
from app.models.schemas import Vendor, VendorCreate, VendorUpdate
from app.core.database import execute_query
from app.core.database import execute_query, execute_query_single, execute_update
import logging
logger = logging.getLogger(__name__)
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"])
async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
@ -172,3 +214,75 @@ async def delete_vendor(vendor_id: int):
logger.info(f"✅ Deleted vendor: {vendor_id}")
return {"message": "Vendor deleted successfully"}
@router.get("/vendors/{vendor_id}/customers", tags=["Vendors"])
async def list_vendor_customers(vendor_id: int):
"""List customers linked to a vendor."""
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
rows = execute_query(
"""
SELECT
l.id,
l.customer_id,
l.vendor_id,
l.relationship_type,
l.created_at,
l.updated_at,
c.name AS customer_name,
c.email AS customer_email,
c.cvr_number AS customer_cvr
FROM customer_vendor_links l
JOIN customers c ON c.id = l.customer_id
WHERE l.vendor_id = %s
ORDER BY c.name ASC, l.id ASC
""",
(vendor_id,),
) or []
return rows
@router.post("/vendors/{vendor_id}/customers", tags=["Vendors"])
async def link_vendor_to_customer(vendor_id: int, payload: VendorCustomerLinkCreate):
"""Create link between vendor and customer."""
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
relationship_type = str(payload.relationship_type or "supplier").strip().lower()
if relationship_type not in {"supplier", "reseller", "partner"}:
raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
row = execute_query_single(
"""
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
VALUES (%s, %s, %s)
ON CONFLICT (customer_id, vendor_id)
DO UPDATE SET
relationship_type = EXCLUDED.relationship_type,
updated_at = CURRENT_TIMESTAMP
RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
""",
(payload.customer_id, vendor_id, relationship_type),
)
_ensure_customer_supplier_tag(int(payload.customer_id))
return row
@router.delete("/vendors/{vendor_id}/customers/{customer_id}", tags=["Vendors"])
async def unlink_vendor_from_customer(vendor_id: int, customer_id: int):
"""Delete link between vendor and customer."""
deleted = execute_update(
"DELETE FROM customer_vendor_links WHERE vendor_id = %s AND customer_id = %s",
(vendor_id, customer_id),
)
if not deleted:
raise HTTPException(status_code=404, detail="Link not found")
return {"success": True, "vendor_id": vendor_id, "customer_id": customer_id}

View File

@ -131,6 +131,9 @@
<a class="nav-link" href="#fakturaer" data-tab="fakturaer">
<i class="bi bi-receipt me-2"></i>Fakturaer
</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">
<i class="bi bi-clock-history me-2"></i>Aktivitet
</a>
@ -224,6 +227,35 @@
</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 -->
<div class="tab-pane fade" id="aktivitet">
<div class="card p-4">
@ -340,12 +372,144 @@ async function loadVendor() {
}
const vendor = await response.json();
displayVendor(vendor);
await loadVendorCustomers();
} catch (error) {
console.error('Error loading vendor:', error);
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) {
// Header
document.getElementById('vendorName').textContent = vendor.name;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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