Release v2.2.79: economy queue, contact-company backfill, and production fixes
This commit is contained in:
parent
2cef28ff3b
commit
90a6496c48
@ -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")
|
||||||
|
|||||||
@ -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)."""
|
||||||
|
|||||||
@ -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
0
app/economy/__init__.py
Normal file
0
app/economy/backend/__init__.py
Normal file
0
app/economy/backend/__init__.py
Normal file
530
app/economy/backend/router.py
Normal file
530
app/economy/backend/router.py
Normal 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")
|
||||||
0
app/economy/frontend/__init__.py
Normal file
0
app/economy/frontend/__init__.py
Normal file
501
app/economy/frontend/time_queue.html
Normal file
501
app/economy/frontend/time_queue.html
Normal 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, '<').replace(/>/g, '>');
|
||||||
|
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 %}
|
||||||
14
app/economy/frontend/views.py
Normal file
14
app/economy/frontend/views.py
Normal 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"},
|
||||||
|
)
|
||||||
@ -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 || '-';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
4
main.py
4
main.py
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user