Release v2.2.79: economy queue, contact-company backfill, and production fixes

This commit is contained in:
Christian 2026-05-04 16:24:38 +02:00
parent 2cef28ff3b
commit 90a6496c48
16 changed files with 1588 additions and 29 deletions

View File

@ -1 +1 @@
2.2.52 2.2.79

View File

@ -126,7 +126,25 @@ async def create_backup(backup: BackupCreate):
"message": "Full backup created successfully" "message": "Full backup created successfully"
} }
else: else:
raise HTTPException(status_code=500, detail=f"Full backup partially failed: db={db_job_id}, files={files_job_id}") db_error = None
failed_db_row = execute_query_single(
"""
SELECT id, error_message
FROM backup_jobs
WHERE job_type = 'database'
AND status = 'failed'
ORDER BY created_at DESC
LIMIT 1
"""
)
if failed_db_row:
db_error = failed_db_row.get("error_message")
detail = f"Full backup partially failed: db={db_job_id}, files={files_job_id}"
if db_error:
detail = f"{detail}. Database error: {db_error}"
raise HTTPException(status_code=500, detail=detail)
else: else:
raise HTTPException(status_code=400, detail="Invalid job_type. Must be: database, files, or full") raise HTTPException(status_code=400, detail="Invalid job_type. Must be: database, files, or full")

View File

@ -131,7 +131,7 @@ async def get_contacts(
query = f""" query = f"""
SELECT SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.created_at, c.updated_at, c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at,
COUNT(DISTINCT cc.customer_id) as company_count, COUNT(DISTINCT cc.customer_id) as company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c FROM contacts c
@ -139,7 +139,7 @@ async def get_contacts(
LEFT JOIN customers cu ON cc.customer_id = cu.id LEFT JOIN customers cu ON cc.customer_id = cu.id
{where_sql} {where_sql}
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.created_at, c.updated_at c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
ORDER BY company_count DESC, c.last_name, c.first_name ORDER BY company_count DESC, c.last_name, c.first_name
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """
@ -325,6 +325,114 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/contacts/admin/backfill-company-links")
async def backfill_contact_company_links(dry_run: bool = Query(default=True)):
"""
Backfill missing contact_companies links by matching contacts.user_company to customers.name.
- Uses case-insensitive trimmed exact name matching
- Picks lowest customer ID if duplicate customer names exist
- Idempotent: will not create duplicate links
"""
try:
# Contacts that have a company name on the contact row.
contacts_with_company = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM contacts c
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
"""
)
# Contacts where the company name can be matched to a customer record.
matchable = execute_query_single(
"""
WITH company_match AS (
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
FROM customers
GROUP BY LOWER(TRIM(name))
)
SELECT COUNT(DISTINCT c.id)::int AS count
FROM contacts c
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
"""
)
# Contacts with no links at all (often the primary symptom).
unlinked = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM contacts c
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
AND NOT EXISTS (
SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id
)
"""
)
if dry_run:
return {
"dry_run": True,
"contacts_with_user_company": (contacts_with_company or {}).get("count", 0),
"matchable_contacts": (matchable or {}).get("count", 0),
"unlinked_contacts": (unlinked or {}).get("count", 0),
"message": "Dry run complete. Re-run with dry_run=false to insert links.",
}
inserted = execute_query(
"""
WITH company_match AS (
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
FROM customers
GROUP BY LOWER(TRIM(name))
),
candidates AS (
SELECT
c.id AS contact_id,
cm.customer_id,
CASE
WHEN EXISTS (
SELECT 1 FROM contact_companies cc1
WHERE cc1.contact_id = c.id
) THEN FALSE
ELSE TRUE
END AS is_primary
FROM contacts c
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
)
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
SELECT contact_id, customer_id, is_primary, 'inferred_user_company'
FROM candidates c
WHERE NOT EXISTS (
SELECT 1
FROM contact_companies cc
WHERE cc.contact_id = c.contact_id
AND cc.customer_id = c.customer_id
)
RETURNING contact_id, customer_id, is_primary
"""
)
inserted_count = len(inserted or [])
logger.info("✅ Contact-company backfill inserted %s link(s)", inserted_count)
return {
"dry_run": False,
"inserted": inserted_count,
"sample": (inserted or [])[:20],
"message": "Backfill completed",
}
except Exception as e:
logger.error("Failed backfill_contact_company_links: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/related-contacts") @router.get("/contacts/{contact_id}/related-contacts")
async def get_related_contacts(contact_id: int): async def get_related_contacts(contact_id: int):
"""Get contacts from the same companies as the contact (excluding itself).""" """Get contacts from the same companies as the contact (excluding itself)."""

View File

@ -989,9 +989,11 @@ function displayContacts(contacts) {
const companyCount = contact.company_count || 0; const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || []; const companyNames = contact.company_names || [];
const fallbackCompany = (contact.user_company || '').trim();
const companyDisplay = companyNames.length > 0 const companyDisplay = companyNames.length > 0
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '') ? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
: '-'; : (fallbackCompany || '-');
const effectiveCompanyCount = companyCount > 0 ? companyCount : (fallbackCompany ? 1 : 0);
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim(); const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const preferredPhone = contact.mobile || contact.phone || ''; const preferredPhone = contact.mobile || contact.phone || '';
const hasEmail = !!contact.email; const hasEmail = !!contact.email;
@ -1001,7 +1003,7 @@ function displayContacts(contacts) {
const safeEmail = escapeHtml(contact.email || '-'); const safeEmail = escapeHtml(contact.email || '-');
const safeTitle = escapeHtml(contact.title || '-'); const safeTitle = escapeHtml(contact.title || '-');
const safePhone = escapeHtml(preferredPhone || '-'); const safePhone = escapeHtml(preferredPhone || '-');
const companiesTitle = escapeHtml(companyNames.join(', ')); const companiesTitle = escapeHtml(companyNames.length ? companyNames.join(', ') : fallbackCompany);
const updatedAt = formatContactDate(contact.updated_at || contact.created_at); const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
return ` return `
@ -1040,7 +1042,7 @@ function displayContacts(contacts) {
<td class="text-muted col-title">${safeTitle}</td> <td class="text-muted col-title">${safeTitle}</td>
<td class="col-companies"> <td class="col-companies">
<span class="company-count-chip" title="${companiesTitle}"> <span class="company-count-chip" title="${companiesTitle}">
<i class="bi bi-building"></i>${companyCount} <i class="bi bi-building"></i>${effectiveCompanyCount}
</span> </span>
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''} ${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
</td> </td>

0
app/economy/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,530 @@
import logging
from collections import defaultdict
from decimal import Decimal
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.core.config import settings
from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
from app.timetracking.backend.economic_export import economic_service
from app.timetracking.backend.models import TModuleEconomicExportRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/economy", tags=["Economy"])
class BulkIdsRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
class BulkUpdateRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
description: Optional[str] = None
original_hours: Optional[float] = Field(None, gt=0)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkSoftDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
reason: Optional[str] = "Soft deleted from economy queue"
class BulkApproveRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkPrepaidRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
prepaid_card_id: int = Field(..., gt=0)
class BulkSendRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
def _ensure_ids(ids: List[int]) -> List[int]:
clean = sorted(set(int(i) for i in ids if int(i) > 0))
if not clean:
raise HTTPException(status_code=400, detail="No valid ids provided")
return clean
@router.get("/time-queue")
async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0),
status: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(500, ge=1, le=2000),
):
"""List non-billed Hub-created time entries for the economy queue."""
try:
conditions = [
"t.vtiger_id IS NULL",
"t.billed_via_thehub_id IS NULL",
"t.status <> 'billed'",
]
params: List[Any] = []
if customer_id is not None:
conditions.append("t.customer_id = %s")
params.append(customer_id)
if status:
conditions.append("t.status = %s")
params.append(status)
if billable is not None:
conditions.append("COALESCE(t.billable, true) = %s")
params.append(billable)
if q:
conditions.append(
"("
"COALESCE(t.description, '') ILIKE %s OR "
"COALESCE(cust.name, '') ILIKE %s OR "
"COALESCE(c.title, s.titel, '') ILIKE %s"
")"
)
like = f"%{q}%"
params.extend([like, like, like])
where_sql = " AND ".join(conditions)
query = f"""
SELECT
t.id,
t.customer_id,
cust.name AS customer_name,
t.status,
t.entry_status,
t.billable,
t.billing_method,
t.prepaid_card_id,
t.fixed_price_agreement_id,
t.original_hours,
t.approved_hours,
t.rounded_to,
t.worked_date,
t.description,
t.entry_type,
t.kilde,
t.case_id,
t.sag_id,
COALESCE(c.title, s.titel, 'No title') AS case_title,
t.created_at,
t.updated_at
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE {where_sql}
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
LIMIT %s
"""
params.append(limit)
rows = execute_query(query, tuple(params))
return {"items": rows, "count": len(rows)}
except HTTPException:
raise
except Exception as e:
logger.error("Failed listing economy time queue: %s", e)
raise HTTPException(status_code=500, detail="Failed to list time queue")
@router.get("/time-queue/customers")
async def list_time_queue_customers():
"""List customers that currently have queue-relevant (not billed) Hub entries."""
try:
rows = execute_query(
"""
SELECT
t.customer_id,
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
COUNT(*)::int AS open_count
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
WHERE t.customer_id IS NOT NULL
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status = 'pending'
GROUP BY t.customer_id, cust.name
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
"""
)
return {"items": rows, "count": len(rows)}
except Exception as e:
logger.error("Failed listing time queue customers: %s", e)
raise HTTPException(status_code=500, detail="Failed listing customer filter options")
@router.get("/time-queue/prepaid-cards")
async def list_prepaid_cards():
try:
cards = execute_query(
"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
FROM tticket_prepaid_cards
WHERE status IN ('active', 'depleted')
ORDER BY remaining_hours DESC, id DESC
"""
)
return {"items": cards, "count": len(cards)}
except Exception as e:
logger.error("Failed listing prepaid cards: %s", e)
raise HTTPException(status_code=500, detail="Failed to list prepaid cards")
@router.patch("/time-queue/bulk-update")
async def bulk_update_time_queue(payload: BulkUpdateRequest):
ids = _ensure_ids(payload.ids)
updates: List[str] = []
values: List[Any] = []
if payload.description is not None:
updates.append("description = %s")
values.append(payload.description)
if payload.original_hours is not None:
updates.append("original_hours = %s")
values.append(payload.original_hours)
if payload.billable is not None:
updates.append("billable = %s")
values.append(payload.billable)
if payload.billable is False and payload.billing_method is None:
updates.append("billing_method = 'internal'")
if payload.billing_method is not None:
updates.append("billing_method = %s")
values.append(payload.billing_method)
if not updates:
raise HTTPException(status_code=400, detail="No update fields provided")
try:
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(updates)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(values + ids))
return {"success": True, "updated": len(ids)}
except Exception as e:
logger.error("Failed bulk update: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk update")
@router.post("/time-queue/bulk-soft-delete")
async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
ids = _ensure_ids(payload.ids)
reason = (payload.reason or "Soft deleted from economy queue").strip()
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'rejected',
entry_status = 'kladde',
approval_note = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
""",
tuple([reason] + ids),
)
return {"success": True, "soft_deleted": len(ids)}
except Exception as e:
logger.error("Failed bulk soft delete: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk soft delete")
@router.post("/time-queue/bulk-approve")
async def bulk_approve_time_queue(payload: BulkApproveRequest):
ids = _ensure_ids(payload.ids)
try:
set_parts = [
"status = 'approved'",
"entry_status = 'godkendt'",
"approved_hours = COALESCE(approved_hours, original_hours)",
"approved_at = CURRENT_TIMESTAMP",
"updated_at = CURRENT_TIMESTAMP",
]
params: List[Any] = []
if payload.billable is not None:
set_parts.append("billable = %s")
params.append(payload.billable)
if payload.billing_method is not None:
set_parts.append("billing_method = %s")
params.append(payload.billing_method)
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(set_parts)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(params + ids))
return {"success": True, "approved": len(ids)}
except Exception as e:
logger.error("Failed bulk approve: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk approve")
@router.post("/time-queue/bulk-apply-prepaid")
async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
ids = _ensure_ids(payload.ids)
card = execute_query_single(
"SELECT id FROM tticket_prepaid_cards WHERE id = %s",
(payload.prepaid_card_id,),
)
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET prepaid_card_id = %s,
billing_method = 'prepaid',
billable = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
""",
tuple([payload.prepaid_card_id] + ids),
)
return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id}
except Exception as e:
logger.error("Failed applying prepaid card: %s", e)
raise HTTPException(status_code=500, detail="Failed applying prepaid card")
def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
total_hours = Decimal("0")
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
line_total = (qty * hourly_rate).quantize(Decimal("0.01"))
line_payloads.append(
{
"description": group["case_title"],
"quantity": qty,
"line_total": line_total,
"time_entry_ids": ids,
"case_id": group["case_id"],
"sag_id": group["sag_id"],
"time_date": latest_date,
}
)
total_hours += qty
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
vat_rate = Decimal("25.00")
vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
total_amount = subtotal + vat_amount
order_id = execute_insert(
"""
INSERT INTO tmodule_orders
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
VALUES
(%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
customer_id,
customer.get("hub_customer_id"),
total_hours,
hourly_rate,
subtotal,
vat_rate,
vat_amount,
total_amount,
user_id,
),
)
for idx, line in enumerate(line_payloads, start=1):
execute_insert(
"""
INSERT INTO tmodule_order_lines
(order_id, case_id, sag_id, line_number, description, quantity, unit_price,
line_total, time_entry_ids, case_contact, time_date, is_travel)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE)
RETURNING id
""",
(
order_id,
line["case_id"],
line["sag_id"],
idx,
line["description"],
line["quantity"],
hourly_rate,
line["line_total"],
line["time_entry_ids"],
line["time_date"],
),
)
return int(order_id)
@router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
ids = _ensure_ids(payload.ids)
user_id = getattr(request.state, "user_id", None)
try:
placeholders = ",".join(["%s"] * len(ids))
rows = execute_query(
f"""
SELECT
t.id,
t.customer_id,
t.case_id,
t.sag_id,
t.status,
t.billable,
t.billing_method,
t.original_hours,
t.approved_hours,
t.worked_date,
COALESCE(c.title, s.titel, 'Time entries') AS case_title
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.id IN ({placeholders})
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status <> 'billed'
""",
tuple(ids),
)
if not rows:
raise HTTPException(status_code=400, detail="No eligible entries found")
# Ensure selected invoice candidates are approved and invoice-billable.
selected_invoice_ids = [
int(r["id"])
for r in rows
if bool(r.get("billable", True))
and (r.get("billing_method") or "invoice") == "invoice"
]
if not selected_invoice_ids:
raise HTTPException(status_code=400, detail="No selected entries are invoice-billable")
placeholders_invoice = ",".join(["%s"] * len(selected_invoice_ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'approved',
entry_status = 'godkendt',
approved_hours = COALESCE(approved_hours, original_hours),
approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders_invoice})
AND status <> 'billed'
""",
tuple(selected_invoice_ids),
)
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in rows:
if int(row["id"]) in selected_invoice_ids:
rows_by_customer[int(row["customer_id"])].append(row)
export_results = []
for cust_id, cust_rows in rows_by_customer.items():
order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
export_result = await economic_service.export_order(
TModuleEconomicExportRequest(order_id=order_id, force=False),
user_id=user_id,
)
export_results.append(
{
"customer_id": cust_id,
"order_id": order_id,
"success": bool(export_result.success),
"dry_run": bool(export_result.dry_run),
"message": export_result.message,
"economic_draft_id": export_result.economic_draft_id,
"economic_order_number": export_result.economic_order_number,
}
)
return {
"success": True,
"selected": len(ids),
"invoice_candidates": len(selected_invoice_ids),
"exports": export_results,
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed send-to-invoices flow: %s", e)
raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")

View File

View File

@ -0,0 +1,501 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Economy Time Queue{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
<div>
<h2 class="mb-1">Economy Time Queue</h2>
<p class="text-muted mb-0">Hub-created, non-billed time entries.</p>
</div>
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
<button class="btn btn-success" id="sendInvoicesBtn">Send Selected To Invoices</button>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
</div>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="filterCustomer" class="form-label">Firma</label>
<select id="filterCustomer" class="form-select">
<option value="">Alle firmaer med ubehandlede registreringer</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterStatus" class="form-label">Status</label>
<select id="filterStatus" class="form-select">
<option value="">All</option>
<option value="pending">pending</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterBillable" class="form-label">Billable</label>
<select id="filterBillable" class="form-select">
<option value="">All</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterQuery" class="form-label">Search</label>
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="bulkDescription" class="form-label">Description</label>
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
</div>
<div class="col-12 col-md-2">
<label for="bulkHours" class="form-label">Hours</label>
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
</div>
<div class="col-12 col-md-2">
<label for="bulkBillingMethod" class="form-label">Billing method</label>
<select id="bulkBillingMethod" class="form-select">
<option value="">No change</option>
<option value="invoice">invoice</option>
<option value="internal">internal</option>
<option value="prepaid">prepaid</option>
<option value="fixed_price">fixed_price</option>
</select>
</div>
<div class="col-12 col-md-2">
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
<select id="bulkPrepaidCard" class="form-select">
<option value="">Select card</option>
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
<th>ID</th>
<th>Customer</th>
<th>Date</th>
<th>Case</th>
<th>Hours</th>
<th>Status</th>
<th>Billable</th>
<th>Method</th>
<th>Hours edit</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queueBody">
<tr>
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(() => {
const state = {
items: [],
selected: new Set(),
loading: false,
};
const queueBody = document.getElementById('queueBody');
const selectAll = document.getElementById('selectAll');
const selectedCountBadge = document.getElementById('selectedCountBadge');
const filterCustomer = document.getElementById('filterCustomer');
const filterStatus = document.getElementById('filterStatus');
const filterBillable = document.getElementById('filterBillable');
const filterQuery = document.getElementById('filterQuery');
const bulkDescription = document.getElementById('bulkDescription');
const bulkHours = document.getElementById('bulkHours');
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
function selectedIds() {
return Array.from(state.selected);
}
function renderRows() {
if (!state.items.length) {
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
return;
}
queueBody.innerHTML = state.items.map((item) => {
const id = Number(item.id);
const checked = state.selected.has(id) ? 'checked' : '';
const date = item.worked_date || '-';
const hours = item.approved_hours || item.original_hours || 0;
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
const title = item.case_title || '-';
const desc = (item.description || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const method = item.billing_method || 'invoice';
return `
<tr>
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
<td>${id}</td>
<td>${customer}</td>
<td>${date}</td>
<td>${title}</td>
<td>${hours}</td>
<td>${item.status || '-'}</td>
<td>${item.billable === false ? 'false' : 'true'}</td>
<td>
<select class="form-select form-select-sm inline-method" data-id="${id}">
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
</select>
</td>
<td>
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
</td>
<td>
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
</td>
<td>
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
</td>
</tr>
`;
}).join('');
document.querySelectorAll('.row-check').forEach((cb) => {
cb.addEventListener('change', (e) => {
const id = Number(e.target.dataset.id);
if (e.target.checked) state.selected.add(id);
else state.selected.delete(id);
syncSelectAll();
});
});
document.querySelectorAll('.inline-save').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const id = Number(e.target.dataset.id);
await saveInlineRow(id, e.target);
});
});
syncSelectAll();
}
function syncSelectAll() {
const ids = state.items.map((x) => Number(x.id));
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
selectAll.checked = Boolean(allSelected);
selectedCountBadge.textContent = `${state.selected.size} selected`;
}
async function api(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
function buildListUrl() {
const params = new URLSearchParams();
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (filterBillable.value) params.set('billable', filterBillable.value);
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
params.set('limit', '500');
return `/api/v1/economy/time-queue?${params.toString()}`;
}
async function loadEntries() {
if (state.loading) return;
state.loading = true;
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
try {
const data = await api(buildListUrl());
state.items = data.items || [];
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
renderRows();
} catch (err) {
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
} finally {
state.loading = false;
}
}
async function loadPrepaidCards() {
try {
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
const opts = ['<option value="">Select card</option>'];
(data.items || []).forEach((card) => {
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
opts.push(`<option value="${card.id}">${label}</option>`);
});
bulkPrepaidCard.innerHTML = opts.join('');
} catch (_) {
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
}
}
async function loadCustomers() {
try {
const data = await api('/api/v1/economy/time-queue/customers');
const current = filterCustomer.value;
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
(data.items || []).forEach((row) => {
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
opts.push(`<option value="${row.customer_id}">${label}</option>`);
});
filterCustomer.innerHTML = opts.join('');
if (current) {
filterCustomer.value = current;
}
} catch (_) {
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
}
}
async function clearFilters() {
filterCustomer.value = '';
filterStatus.value = '';
filterBillable.value = '';
filterQuery.value = '';
setActiveQuickFilter(null);
await loadEntries();
}
function setActiveQuickFilter(active) {
quickFilterBtns.forEach((btn) => {
const isActive = btn.dataset.filter === active;
btn.classList.toggle('btn-primary', isActive);
btn.classList.toggle('btn-outline-primary', !isActive);
});
}
async function applyQuickFilter(type) {
if (type === 'pending') {
filterStatus.value = 'pending';
filterBillable.value = '';
} else if (type === 'billable') {
filterStatus.value = '';
filterBillable.value = 'true';
} else if (type === 'ready') {
filterStatus.value = 'approved';
filterBillable.value = 'true';
}
setActiveQuickFilter(type);
await loadEntries();
}
async function saveInlineRow(id, buttonEl) {
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
if (!hoursInput || !descInput || !methodSelect) return;
const originalHours = Number(hoursInput.value);
const description = (descInput.value || '').trim();
const billingMethod = methodSelect.value;
if (!originalHours || originalHours <= 0) {
alert('Hours must be greater than 0');
return;
}
const prevText = buttonEl.textContent;
buttonEl.disabled = true;
buttonEl.textContent = 'Gemmer...';
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify({
ids: [id],
original_hours: originalHours,
description,
billing_method: billingMethod,
}),
});
await loadEntries();
await loadCustomers();
} catch (err) {
alert(err.message);
} finally {
buttonEl.disabled = false;
buttonEl.textContent = prevText;
}
}
async function doBulkUpdate() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
if (!payload.description && !payload.original_hours && !payload.billing_method) {
return alert('Set at least one update field');
}
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkApprove() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
try {
await api('/api/v1/economy/time-queue/bulk-approve', {
method: 'POST',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkDelete() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
if (reason === null) return;
try {
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
method: 'POST',
body: JSON.stringify({ ids, reason }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doApplyPrepaid() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
try {
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
method: 'POST',
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doSendInvoices() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Send selected entries to invoices now?');
if (!ok) return;
try {
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
method: 'POST',
body: JSON.stringify({ ids }),
});
const exports = (result.exports || []).map((x) => {
return `customer ${x.customer_id}, order ${x.order_id}, success=${x.success}, dry_run=${x.dry_run}`;
}).join('\n');
alert(exports || 'No export result');
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
selectAll.addEventListener('change', () => {
if (selectAll.checked) {
state.items.forEach((item) => state.selected.add(Number(item.id)));
} else {
state.items.forEach((item) => state.selected.delete(Number(item.id)));
}
renderRows();
});
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
quickFilterBtns.forEach((btn) => {
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
});
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
el.addEventListener('change', loadEntries);
});
filterQuery.addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadEntries();
});
loadCustomers();
loadPrepaidCards();
loadEntries();
})();
</script>
{% endblock %}

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/economy/time-queue", response_class=HTMLResponse)
async def economy_time_queue_page(request: Request):
return templates.TemplateResponse(
"economy/frontend/time_queue.html",
{"request": request, "title": "Economy Time Queue"},
)

View File

@ -1084,6 +1084,7 @@
<div class="card border-primary h-100"> <div class="card border-primary h-100">
<div class="card-header bg-primary-subtle fw-semibold">Ville blive opdateret</div> <div class="card-header bg-primary-subtle fw-semibold">Ville blive opdateret</div>
<div class="card-body"> <div class="card-body">
<div class="small text-muted mb-2">Klik på en virksomhed for at se feltændringer og rette dem on-the-fly.</div>
<div id="economicDryRunUpdateList" class="small"></div> <div id="economicDryRunUpdateList" class="small"></div>
</div> </div>
</div> </div>
@ -1117,6 +1118,55 @@
</div> </div>
</div> </div>
<div class="modal fade" id="economicDryRunUpdateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-building me-2"></i>Virksomhed - Dry-run ændringer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="economicDryRunUpdateMeta" class="alert alert-info small"></div>
<div id="economicDryRunUpdateChanges" class="mb-3"></div>
<h6 class="fw-semibold mb-2">Rediger værdier før anvendelse</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small">Email domæne</label>
<input type="text" class="form-control form-control-sm" id="economicUpdateEmailDomain" placeholder="example.com">
</div>
<div class="col-md-6">
<label class="form-label small">Website</label>
<input type="text" class="form-control form-control-sm" id="economicUpdateWebsite" placeholder="https://...">
</div>
<div class="col-12">
<label class="form-label small">Adresse</label>
<input type="text" class="form-control form-control-sm" id="economicUpdateAddress">
</div>
<div class="col-md-6">
<label class="form-label small">By</label>
<input type="text" class="form-control form-control-sm" id="economicUpdateCity">
</div>
<div class="col-md-3">
<label class="form-label small">Postnr</label>
<input type="text" class="form-control form-control-sm" id="economicUpdatePostalCode">
</div>
<div class="col-md-3">
<label class="form-label small">Land</label>
<input type="text" class="form-control form-control-sm text-uppercase" maxlength="2" id="economicUpdateCountry" placeholder="DK">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" id="btnApplySingleEconomicUpdate" onclick="applySingleEconomicDryRunUpdate()">
<i class="bi bi-check2-circle me-2"></i>Anvend denne opdatering nu
</button>
</div>
</div>
</div>
</div>
<!-- /div> <!-- /div>
</div> </div>
@ -4735,17 +4785,184 @@ function renderDryRunList(items, formatter) {
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
return '<div class="text-muted">Ingen</div>'; return '<div class="text-muted">Ingen</div>';
} }
return `<div class="list-group list-group-flush">${items.map(item => ` return `<div class="list-group list-group-flush">${items.map((item, index) => `
<div class="list-group-item px-0">${formatter(item)}</div> <div class="list-group-item px-0">${formatter(item, index)}</div>
`).join('')}</div>`; `).join('')}</div>`;
} }
let economicDryRunUpdateItems = [];
let economicDryRunSelectedUpdateIndex = null;
function ensureEconomicDryRunModal() { function ensureEconomicDryRunModal() {
const modalEl = document.getElementById('economicDryRunModal'); const modalEl = document.getElementById('economicDryRunModal');
if (!modalEl || !window.bootstrap) return null; if (!modalEl || !window.bootstrap) return null;
return bootstrap.Modal.getOrCreateInstance(modalEl); return bootstrap.Modal.getOrCreateInstance(modalEl);
} }
function ensureEconomicDryRunUpdateModal() {
const modalEl = document.getElementById('economicDryRunUpdateModal');
if (!modalEl || !window.bootstrap) return null;
return bootstrap.Modal.getOrCreateInstance(modalEl);
}
function renderEconomicUpdateList() {
const updateList = document.getElementById('economicDryRunUpdateList');
if (!updateList) return;
updateList.innerHTML = renderDryRunList(economicDryRunUpdateItems, (item, index) => {
const name = item.customer_name || '-';
const id = item.customer_id || '-';
const num = item.economic_customer_number || '-';
const changes = Object.keys(item.changes || {});
const changeCount = changes.length;
const changeSummary = changeCount > 0
? `${changeCount} felt(er) ændres: ${escapeHtml(changes.join(', '))}`
: 'Ingen feltændringer fundet';
const appliedBadge = item._applied
? '<span class="badge bg-success ms-2">Anvendt</span>'
: '';
return `
<button type="button" class="btn btn-link text-start w-100 p-0 text-decoration-none" onclick="openEconomicDryRunUpdateDetails(${index})">
<strong>${escapeHtml(name)}</strong>${appliedBadge}<br>
<span class="text-muted">Lokal ID ${escapeHtml(String(id))} · e-conomic #${escapeHtml(String(num))}</span><br>
<span class="small ${changeCount > 0 ? 'text-primary' : 'text-muted'}">${changeSummary}</span>
</button>
`;
});
}
function openEconomicDryRunUpdateDetails(index) {
const item = economicDryRunUpdateItems[index];
if (!item) return;
economicDryRunSelectedUpdateIndex = index;
const meta = document.getElementById('economicDryRunUpdateMeta');
const changesEl = document.getElementById('economicDryRunUpdateChanges');
if (!meta || !changesEl) return;
meta.innerHTML = `
<div><strong>${escapeHtml(item.customer_name || '-')}</strong></div>
<div>Lokal ID: ${escapeHtml(String(item.customer_id || '-'))} · e-conomic #: ${escapeHtml(String(item.economic_customer_number || '-'))}</div>
`;
const changes = item.changes || {};
const changeEntries = Object.entries(changes);
if (changeEntries.length === 0) {
changesEl.innerHTML = '<div class="alert alert-secondary small mb-0">Ingen registrerede ændringer for denne virksomhed.</div>';
} else {
changesEl.innerHTML = `
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Felt</th>
<th>Nuværende</th>
<th>Ny værdi</th>
</tr>
</thead>
<tbody>
${changeEntries.map(([field, values]) => `
<tr>
<td><code>${escapeHtml(field)}</code></td>
<td>${escapeHtml(values.from || '-')}</td>
<td class="text-primary">${escapeHtml(values.to || '-')}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
const newValues = item.new_values || {};
document.getElementById('economicUpdateEmailDomain').value = newValues.email_domain || '';
document.getElementById('economicUpdateWebsite').value = newValues.website || '';
document.getElementById('economicUpdateAddress').value = newValues.address || '';
document.getElementById('economicUpdateCity').value = newValues.city || '';
document.getElementById('economicUpdatePostalCode').value = newValues.postal_code || '';
document.getElementById('economicUpdateCountry').value = newValues.country || 'DK';
const modal = ensureEconomicDryRunUpdateModal();
if (modal) modal.show();
}
function collectEconomicOverrideValues() {
return {
email_domain: (document.getElementById('economicUpdateEmailDomain')?.value || '').trim(),
website: (document.getElementById('economicUpdateWebsite')?.value || '').trim(),
address: (document.getElementById('economicUpdateAddress')?.value || '').trim(),
city: (document.getElementById('economicUpdateCity')?.value || '').trim(),
postal_code: (document.getElementById('economicUpdatePostalCode')?.value || '').trim(),
country: ((document.getElementById('economicUpdateCountry')?.value || 'DK').trim().toUpperCase().slice(0, 2) || 'DK')
};
}
async function applySingleEconomicDryRunUpdate() {
const index = economicDryRunSelectedUpdateIndex;
const item = economicDryRunUpdateItems[index];
if (!item) {
showNotification('Ingen virksomhed valgt', 'error');
return;
}
const btn = document.getElementById('btnApplySingleEconomicUpdate');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Anvender...';
}
const overrides = collectEconomicOverrideValues();
try {
const response = await fetch('/api/v1/system/sync/economic/apply-update', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
customer_id: item.customer_id,
economic_customer_number: item.economic_customer_number,
new_values: item.new_values || {},
overrides
})
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Kunne ikke anvende opdateringen');
throw new Error(errorMessage);
}
const result = await response.json();
item.new_values = result.final_values || item.new_values || {};
item.current_values = item.new_values;
item.changes = {};
item.has_changes = false;
item._applied = true;
renderEconomicUpdateList();
const modal = ensureEconomicDryRunUpdateModal();
if (modal) modal.hide();
addSyncLogEntry(
'e-conomic On-the-fly Opdatering',
`${item.customer_name || 'Virksomhed'} blev opdateret direkte fra dry-run preview.`,
'success'
);
showNotification('Opdatering anvendt', 'success');
} catch (error) {
showNotification('Fejl: ' + error.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2-circle me-2"></i>Anvend denne opdatering nu';
}
}
}
async function dryRunEconomicSync() { async function dryRunEconomicSync() {
const btn = document.getElementById('btnDryRunEconomic'); const btn = document.getElementById('btnDryRunEconomic');
const modal = ensureEconomicDryRunModal(); const modal = ensureEconomicDryRunModal();
@ -4767,6 +4984,8 @@ async function dryRunEconomicSync() {
conflictList.innerHTML = ''; conflictList.innerHTML = '';
skippedList.innerHTML = ''; skippedList.innerHTML = '';
contactsNote.textContent = ''; contactsNote.textContent = '';
economicDryRunUpdateItems = [];
economicDryRunSelectedUpdateIndex = null;
if (modal) modal.show(); if (modal) modal.show();
@ -4839,12 +5058,8 @@ async function dryRunEconomicSync() {
return `<strong>${name}</strong><br><span class="text-muted">e-conomic #${num} · CVR ${cvr}</span>`; return `<strong>${name}</strong><br><span class="text-muted">e-conomic #${num} · CVR ${cvr}</span>`;
}); });
updateList.innerHTML = renderDryRunList(preview.would_update, (item) => { economicDryRunUpdateItems = Array.isArray(preview.would_update) ? preview.would_update : [];
const name = item.customer_name || '-'; renderEconomicUpdateList();
const id = item.customer_id || '-';
const num = item.economic_customer_number || '-';
return `<strong>${name}</strong><br><span class="text-muted">Lokal ID ${id} · e-conomic #${num}</span>`;
});
conflictList.innerHTML = renderDryRunList(preview.conflicts, (item) => { conflictList.innerHTML = renderDryRunList(preview.conflicts, (item) => {
const name = item.name || '-'; const name = item.name || '-';

View File

@ -846,6 +846,7 @@
<i class="bi bi-currency-dollar me-2"></i>Økonomi <i class="bi bi-currency-dollar me-2"></i>Økonomi
</a> </a>
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li> <li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li> <li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li> <li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
@ -869,6 +870,7 @@
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard2"><i class="bi bi-check2-square me-2"></i>Godkend Tider V2</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li> <li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>

View File

@ -31,7 +31,7 @@ SYNC RULES:
""" """
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Dict, Any from typing import Dict, Any
from app.core.database import execute_query from app.core.database import execute_query
from app.core.auth_dependencies import require_any_permission from app.core.auth_dependencies import require_any_permission
@ -55,6 +55,26 @@ def normalize_name(name: str) -> str:
return name.lower().strip() return name.lower().strip()
def _normalize_sync_value(value: Any) -> str:
"""Normalize values so dry-run change detection is stable across None/empty/whitespace."""
if value is None:
return ""
return str(value).strip()
def _build_customer_change_set(current_values: Dict[str, Any], new_values: Dict[str, Any]) -> Dict[str, Dict[str, str]]:
"""Return only fields that would actually change on update."""
changes: Dict[str, Dict[str, str]] = {}
for field, new_value in new_values.items():
current_value = current_values.get(field)
if _normalize_sync_value(current_value) != _normalize_sync_value(new_value):
changes[field] = {
"from": _normalize_sync_value(current_value),
"to": _normalize_sync_value(new_value),
}
return changes
async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]: async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]:
""" """
Build economic sync plan and optionally apply changes. Build economic sync plan and optionally apply changes.
@ -171,7 +191,12 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
# Strict matching: ONLY match by economic_customer_number # Strict matching: ONLY match by economic_customer_number
existing = execute_query( existing = execute_query(
"SELECT id, name FROM customers WHERE economic_customer_number = %s ORDER BY id", """
SELECT id, name, email_domain, address, city, postal_code, country, website
FROM customers
WHERE economic_customer_number = %s
ORDER BY id
""",
(customer_number,) (customer_number,)
) )
@ -195,11 +220,15 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if existing: if existing:
target_customer_id = existing[0]['id'] target_customer_id = existing[0]['id']
would_update.append({ current_values = {
"customer_id": target_customer_id, "email_domain": existing[0].get("email_domain"),
"customer_name": existing[0].get('name'), "address": existing[0].get("address"),
"economic_customer_number": customer_number, "city": existing[0].get("city"),
"new_values": { "postal_code": existing[0].get("postal_code"),
"country": existing[0].get("country"),
"website": existing[0].get("website"),
}
proposed_values = {
"email_domain": email_domain, "email_domain": email_domain,
"address": address, "address": address,
"city": city, "city": city,
@ -207,6 +236,16 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
"country": country, "country": country,
"website": website, "website": website,
} }
changes = _build_customer_change_set(current_values, proposed_values)
would_update.append({
"customer_id": target_customer_id,
"customer_name": existing[0].get('name'),
"economic_customer_number": customer_number,
"current_values": current_values,
"new_values": proposed_values,
"changes": changes,
"has_changes": len(changes) > 0,
}) })
if apply_changes: if apply_changes:
@ -744,6 +783,124 @@ async def sync_from_economic_dry_run(current_user: dict = Depends(sync_admin_acc
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync/economic/apply-update")
async def apply_single_economic_update(
payload: Dict[str, Any] = Body(...),
current_user: dict = Depends(sync_admin_access)
) -> Dict[str, Any]:
"""
Apply one selected dry-run customer update with optional field overrides.
This lets admins adjust values on-the-fly from the preview UI.
"""
customer_id = payload.get("customer_id")
economic_customer_number = payload.get("economic_customer_number")
new_values = payload.get("new_values") or {}
overrides = payload.get("overrides") or {}
try:
customer_id = int(customer_id)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Ugyldigt customer_id")
if not isinstance(new_values, dict):
raise HTTPException(status_code=400, detail="new_values skal være et objekt")
if not isinstance(overrides, dict):
raise HTTPException(status_code=400, detail="overrides skal være et objekt")
allowed_fields = {"email_domain", "address", "city", "postal_code", "country", "website"}
invalid_override_fields = [field for field in overrides.keys() if field not in allowed_fields]
if invalid_override_fields:
raise HTTPException(
status_code=400,
detail=f"Ugyldige override felter: {', '.join(sorted(invalid_override_fields))}"
)
customer = execute_query(
"""
SELECT id, name, economic_customer_number, email_domain, address, city, postal_code, country, website
FROM customers
WHERE id = %s
""",
(customer_id,)
)
if not customer:
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
customer = customer[0]
if economic_customer_number and str(customer.get("economic_customer_number") or "") != str(economic_customer_number):
raise HTTPException(
status_code=409,
detail="Kundens e-conomic nummer matcher ikke preview-data. Kør dry-run igen."
)
final_values: Dict[str, Any] = {}
for field in allowed_fields:
if field in overrides:
value = overrides.get(field)
elif field in new_values:
value = new_values.get(field)
else:
value = customer.get(field)
normalized = _normalize_sync_value(value)
if field == "country":
normalized = (normalized.upper()[:2] or "DK")
final_values[field] = normalized
update_query = """
UPDATE customers SET
email_domain = %s,
address = %s,
city = %s,
postal_code = %s,
country = %s,
website = %s,
last_synced_at = NOW()
WHERE id = %s
"""
execute_query(
update_query,
(
final_values["email_domain"],
final_values["address"],
final_values["city"],
final_values["postal_code"],
final_values["country"],
final_values["website"],
customer_id,
)
)
applied_changes = _build_customer_change_set(
{
"email_domain": customer.get("email_domain"),
"address": customer.get("address"),
"city": customer.get("city"),
"postal_code": customer.get("postal_code"),
"country": customer.get("country"),
"website": customer.get("website"),
},
final_values,
)
logger.info(
"✅ Applied on-the-fly e-conomic update for customer id=%s (%s), changed_fields=%s",
customer_id,
customer.get("name"),
", ".join(sorted(applied_changes.keys())) or "none",
)
return {
"status": "success",
"customer_id": customer_id,
"economic_customer_number": customer.get("economic_customer_number"),
"applied_changes": applied_changes,
"final_values": final_values,
}
@router.post("/sync/cvr-to-economic") @router.post("/sync/cvr-to-economic")
async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]: async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
""" """

View File

@ -1646,7 +1646,11 @@ async def list_customers(
@router.get("/customers/{customer_id}/times", tags=["Customers"]) @router.get("/customers/{customer_id}/times", tags=["Customers"])
async def get_customer_time_entries(customer_id: int, status: Optional[str] = None): async def get_customer_time_entries(
customer_id: int,
status: Optional[str] = None,
hub_only: bool = Query(True),
):
""" """
Hent alle tidsregistreringer for en kunde. Hent alle tidsregistreringer for en kunde.
@ -1683,6 +1687,10 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
params = [customer_id] params = [customer_id]
# Hub-only mode: hide imported vTiger timelogs and show only Hub-created entries.
if hub_only:
query += " AND t.vtiger_id IS NULL"
if status: if status:
query += " AND t.status = %s" query += " AND t.status = %s"
params.append(status) params.append(status)

View File

@ -410,7 +410,7 @@
// We need a way to get ALL pending entries for a customer. // We need a way to get ALL pending entries for a customer.
// Let's use the router endpoint: /api/v1/timetracking/customers/{id}/times (but filter for pending) // Let's use the router endpoint: /api/v1/timetracking/customers/{id}/times (but filter for pending)
const timesResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/times`); const timesResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/times?hub_only=true`);
const timesData = await timesResponse.json(); const timesData = await timesResponse.json();
// Filter only pending // Filter only pending
@ -473,7 +473,7 @@
<div> <div>
<div class="case-title d-flex align-items-center gap-2"> <div class="case-title d-flex align-items-center gap-2">
${caseId === 'no_case' ? '<i class="bi bi-person-workspace text-secondary"></i>' : '<i class="bi bi-folder-fill text-primary"></i>'} ${caseId === 'no_case' ? '<i class="bi bi-person-workspace text-secondary"></i>' : '<i class="bi bi-folder-fill text-primary"></i>'}
${meta.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${meta.case_vtiger_id.replace('39x', '')}" target="_blank" class="text-decoration-none text-dark">${group.title} <i class="bi bi-box-arrow-up-right small ms-1"></i></a>` : `<span>${group.title}</span>`} <span>${group.title}</span>
<span class="badge bg-white text-dark border ms-2">${meta.case_type || 'Support'}</span> <span class="badge bg-white text-dark border ms-2">${meta.case_type || 'Support'}</span>
<span class="badge ${getPriorityBadgeClass(meta.case_priority)} ms-1">${meta.case_priority || 'Normal'}</span> <span class="badge ${getPriorityBadgeClass(meta.case_priority)} ms-1">${meta.case_priority || 'Normal'}</span>
</div> </div>

View File

@ -139,6 +139,8 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
from app.modules.rentals.backend import router as rentals_api from app.modules.rentals.backend import router as rentals_api
from app.modules.task_templates.backend import router as task_templates_api from app.modules.task_templates.backend import router as task_templates_api
from app.economy.backend import router as economy_api
from app.economy.frontend import views as economy_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -457,6 +459,7 @@ app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bo
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"]) app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"]) app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"]) app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api from app.modules.links.backend import router as links_api
@ -492,6 +495,7 @@ app.include_router(orders_views.router, tags=["Frontend"])
app.include_router(fedex_views.router, tags=["Frontend"]) app.include_router(fedex_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"]) app.include_router(anydesk_views.router, tags=["Frontend"])
app.include_router(manual_views.router, tags=["Frontend"]) app.include_router(manual_views.router, tags=["Frontend"])
app.include_router(economy_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views from app.modules.links.frontend import views as links_views