diff --git a/app/jobs/eset_sync.py b/app/jobs/eset_sync.py
index a68bf9c..5b9e65b 100644
--- a/app/jobs/eset_sync.py
+++ b/app/jobs/eset_sync.py
@@ -324,6 +324,16 @@ async def sync_eset_hardware() -> None:
update_fields.append("brand = %s")
update_params.append(brand)
+ # Auto-created ESET devices are customer devices by default unless explicitly reassigned.
+ if customer_id:
+ update_fields.append("current_owner_type = %s")
+ update_params.append("customer")
+ update_fields.append("current_owner_customer_id = %s")
+ update_params.append(customer_id)
+ elif existing[0].get("notes") == "Auto-created from ESET" and existing[0].get("current_owner_type") != "customer":
+ update_fields.append("current_owner_type = %s")
+ update_params.append("customer")
+
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
@@ -332,7 +342,8 @@ async def sync_eset_hardware() -> None:
"""
execute_query(update_query, tuple(update_params))
else:
- owner_type = "customer" if customer_id else "bmc"
+ # ESET sync auto-creates customer endpoints; ownership can be refined later if needed.
+ owner_type = "customer"
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,
diff --git a/app/modules/hardware/backend/router.py b/app/modules/hardware/backend/router.py
index 238e9d1..c23207e 100644
--- a/app/modules/hardware/backend/router.py
+++ b/app/modules/hardware/backend/router.py
@@ -374,9 +374,11 @@ async def create_hardware(data: dict):
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link,
- eset_uuid, hardware_specs, eset_group
+ eset_uuid, hardware_specs, eset_group,
+ rental_default_start_price, rental_default_freight_price,
+ rental_default_preparation_price, rental_default_operations_monthly_price
)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
@@ -402,7 +404,11 @@ async def create_hardware(data: dict):
data.get("anydesk_link"),
data.get("eset_uuid"),
specs,
- data.get("eset_group")
+ data.get("eset_group"),
+ data.get("rental_default_start_price"),
+ data.get("rental_default_freight_price"),
+ data.get("rental_default_preparation_price"),
+ data.get("rental_default_operations_monthly_price"),
)
result = execute_query(query, params)
if not result:
@@ -503,7 +509,9 @@ async def update_hardware(hardware_id: int, data: dict):
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
- "eset_uuid", "hardware_specs", "eset_group"
+ "eset_uuid", "hardware_specs", "eset_group",
+ "rental_default_start_price", "rental_default_freight_price",
+ "rental_default_preparation_price", "rental_default_operations_monthly_price"
]
for field in allowed_fields:
diff --git a/app/modules/hardware/templates/create.html b/app/modules/hardware/templates/create.html
index 48d4557..7433956 100644
--- a/app/modules/hardware/templates/create.html
+++ b/app/modules/hardware/templates/create.html
@@ -285,6 +285,30 @@ js{% extends "shared/frontend/base.html" %}
+
+
+
@@ -356,6 +380,13 @@ js{% extends "shared/frontend/base.html" %}
// Convert customer_id to integer
if (key === 'current_owner_customer_id') {
data[key] = parseInt(value);
+ } else if (
+ key === 'rental_default_start_price' ||
+ key === 'rental_default_freight_price' ||
+ key === 'rental_default_preparation_price' ||
+ key === 'rental_default_operations_monthly_price'
+ ) {
+ data[key] = Number(value);
} else {
data[key] = value;
}
diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html
index 95ce2d7..6edc041 100644
--- a/app/modules/hardware/templates/detail.html
+++ b/app/modules/hardware/templates/detail.html
@@ -407,6 +407,12 @@
Opret Sag
+
+
+
+
+
+
+
+
+ Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
+
+
+ Vaelg kunde og sag for at se hvad der bliver oprettet.
+
+
+
Standarden er 2 mdr. drift i opstartsordren (1+1).
+
+
+
+
+
+
@@ -923,6 +1022,119 @@
}
}
+ async function submitQuickRent() {
+ const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
+ const sagId = Number(document.getElementById('quickRentSagId').value || 0);
+ const startDate = document.getElementById('quickRentStartDate').value;
+ const startPrice = Number(document.getElementById('quickRentStartPrice').value || 0);
+ const freightPrice = Number(document.getElementById('quickRentFreightPrice').value || 0);
+ const preparationPrice = Number(document.getElementById('quickRentPreparationPrice').value || 0);
+ const operationsMonthlyPrice = Number(document.getElementById('quickRentOperationsMonthlyPrice').value || 0);
+ const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
+
+ if (!customerId || !sagId || !startDate) {
+ alert('Kunde, sag og startdato er paakraevet.');
+ return;
+ }
+ if (operationsMonthlyPrice <= 0) {
+ alert('Drift pr. maned skal vaere over 0.');
+ return;
+ }
+
+ const submitBtn = document.getElementById('quickRentSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Opretter...';
+
+ try {
+ const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ customer_id: customerId,
+ sag_id: sagId,
+ start_date: startDate,
+ start_price: startPrice,
+ freight_price: freightPrice,
+ preparation_price: preparationPrice,
+ operations_monthly_price: operationsMonthlyPrice,
+ initial_operations_months: initialOperationsMonths,
+ notice_period_days: 30
+ })
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error((data && data.detail) || 'Quick rent fejlede');
+ }
+
+ const modalEl = document.getElementById('quickRentModal');
+ const modal = bootstrap.Modal.getInstance(modalEl);
+ if (modal) {
+ modal.hide();
+ }
+
+ const draftId = data.ordre_draft_id;
+ if (draftId) {
+ window.location.href = `/ordre/${draftId}`;
+ return;
+ }
+
+ alert('Udlejning oprettet: abonnement + binding oprettet.');
+ window.location.reload();
+ } catch (error) {
+ alert(`Udlejning fejlede: ${error.message}`);
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Opret udlejning';
+ }
+ }
+
+ async function refreshQuickRentPlan() {
+ const infoEl = document.getElementById('quickRentPlanInfo');
+ const submitBtn = document.getElementById('quickRentSubmitBtn');
+ const customerId = Number(document.getElementById('quickRentCustomerId')?.value || 0);
+ const sagId = Number(document.getElementById('quickRentSagId')?.value || 0);
+
+ if (!infoEl || !submitBtn) {
+ return;
+ }
+
+ if (!customerId || !sagId) {
+ infoEl.className = 'alert alert-secondary py-2 small mb-3';
+ infoEl.textContent = 'Vaelg kunde og sag for at se hvad der bliver oprettet.';
+ submitBtn.disabled = false;
+ return;
+ }
+
+ try {
+ infoEl.className = 'alert alert-secondary py-2 small mb-3';
+ infoEl.textContent = 'Tjekker abonnement paa sagen...';
+
+ const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error((data && data.detail) || 'Preview fejlede');
+ }
+
+ submitBtn.disabled = !data.can_submit;
+
+ if (data.action === 'reuse') {
+ infoEl.className = 'alert alert-success py-2 small mb-3';
+ } else if (data.action === 'create') {
+ infoEl.className = 'alert alert-primary py-2 small mb-3';
+ } else {
+ infoEl.className = 'alert alert-danger py-2 small mb-3';
+ }
+
+ infoEl.textContent = data.message || 'Klar.';
+ } catch (error) {
+ submitBtn.disabled = false;
+ infoEl.className = 'alert alert-danger py-2 small mb-3';
+ infoEl.textContent = `Preview fejl: ${error.message}. Du kan stadig prove at oprette.`;
+ }
+ }
+
// Tree Toggle Function
function toggleLocationChildren(event, nodeId) {
event.preventDefault();
@@ -1040,6 +1252,24 @@
// Initialize Tags
document.addEventListener('DOMContentLoaded', function() {
+ const quickRentStartDate = document.getElementById('quickRentStartDate');
+ const quickRentCustomerId = document.getElementById('quickRentCustomerId');
+ const quickRentSagId = document.getElementById('quickRentSagId');
+ const quickRentModal = document.getElementById('quickRentModal');
+ if (quickRentStartDate && !quickRentStartDate.value) {
+ quickRentStartDate.value = new Date().toISOString().slice(0, 10);
+ }
+ if (quickRentCustomerId) {
+ quickRentCustomerId.addEventListener('change', refreshQuickRentPlan);
+ }
+ if (quickRentSagId) {
+ quickRentSagId.addEventListener('input', refreshQuickRentPlan);
+ }
+ if (quickRentModal) {
+ quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan);
+ }
+ refreshQuickRentPlan();
+
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSearch = document.getElementById('ownerContactSearch');
diff --git a/app/modules/hardware/templates/edit.html b/app/modules/hardware/templates/edit.html
index 0494057..f0dbb3d 100644
--- a/app/modules/hardware/templates/edit.html
+++ b/app/modules/hardware/templates/edit.html
@@ -285,6 +285,58 @@
+
+
+
@@ -329,6 +381,13 @@
// Convert customer_id to integer
if (key === 'current_owner_customer_id') {
data[key] = parseInt(value);
+ } else if (
+ key === 'rental_default_start_price' ||
+ key === 'rental_default_freight_price' ||
+ key === 'rental_default_preparation_price' ||
+ key === 'rental_default_operations_monthly_price'
+ ) {
+ data[key] = Number(value);
} else {
data[key] = value;
}
diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py
index 87dd089..bda971c 100644
--- a/app/modules/orders/backend/router.py
+++ b/app/modules/orders/backend/router.py
@@ -260,7 +260,7 @@ async def list_ordre_drafts(
"""List all ordre drafts (no user filtering)."""
try:
query = """
- SELECT id, title, customer_id, notes, layout_number, created_by_user_id,
+ SELECT ordre_drafts.id, ordre_drafts.title, ordre_drafts.customer_id, ordre_drafts.notes, ordre_drafts.layout_number, ordre_drafts.created_by_user_id,
coverage_start, coverage_end, billing_direction, source_subscription_ids,
invoice_aggregate_key, sync_status, export_idempotency_key,
economic_order_number, economic_invoice_number,
@@ -271,13 +271,13 @@ async def list_ordre_drafts(
FROM ordre_draft_sync_events ev
WHERE ev.draft_id = ordre_drafts.id
) AS sync_event_count,
- last_sync_at, created_at, updated_at, last_exported_at
+ ordre_drafts.last_sync_at, ordre_drafts.created_at, ordre_drafts.updated_at, ordre_drafts.last_exported_at
FROM ordre_drafts
LEFT JOIN LATERAL (
- SELECT event_type, created_at
+ SELECT ordre_draft_sync_events.event_type, ordre_draft_sync_events.created_at
FROM ordre_draft_sync_events
WHERE draft_id = ordre_drafts.id
- ORDER BY created_at DESC, id DESC
+ ORDER BY ordre_draft_sync_events.created_at DESC, ordre_draft_sync_events.id DESC
LIMIT 1
) ev_latest ON TRUE
ORDER BY updated_at DESC, id DESC
diff --git a/app/modules/rentals/backend/router.py b/app/modules/rentals/backend/router.py
index 38c80c9..4f634a8 100644
--- a/app/modules/rentals/backend/router.py
+++ b/app/modules/rentals/backend/router.py
@@ -14,6 +14,7 @@ from app.core.database import execute_query, execute_query_single, get_db_connec
from app.core.config import settings
from app.jobs.process_subscriptions import process_subscriptions
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
+from app.subscriptions.backend.router import create_subscription_asset_binding as create_sag_asset_binding
from app.subscriptions.backend.router import update_subscription as update_sag_subscription
logger = logging.getLogger(__name__)
@@ -538,6 +539,102 @@ class AssetBindingCreateInput(BaseModel):
created_by_user_id: Optional[int] = None
+class QuickRentCreateInput(BaseModel):
+ customer_id: int
+ sag_id: int
+ start_date: date = Field(default_factory=date.today)
+ start_price: float = Field(default=0, ge=0)
+ freight_price: float = Field(default=0, ge=0)
+ preparation_price: float = Field(default=0, ge=0)
+ operations_monthly_price: float = Field(gt=0)
+ initial_operations_months: int = Field(default=2, ge=1, le=12)
+ notice_period_days: int = Field(default=30, ge=0)
+ created_by_user_id: Optional[int] = None
+
+
+@router.get("/hardware/{hardware_id}/quick-rent/preview", response_model=Dict[str, Any])
+async def quick_rent_preview(hardware_id: int, customer_id: int = Query(...), sag_id: int = Query(...)):
+ """Preview whether quick-rent will reuse an existing subscription or create a new one."""
+ hardware = execute_query_single(
+ """
+ SELECT id, current_owner_type
+ FROM hardware_assets
+ WHERE id = %s
+ AND deleted_at IS NULL
+ """,
+ (hardware_id,),
+ )
+ if not hardware:
+ return {
+ "can_submit": False,
+ "action": "blocked",
+ "message": "Hardware blev ikke fundet.",
+ }
+
+ if (hardware.get("current_owner_type") or "").strip().lower() != "bmc":
+ return {
+ "can_submit": False,
+ "action": "blocked",
+ "message": "Kun BMC-ejede assets kan udlejes fra denne modal.",
+ }
+
+ customer = execute_query_single(
+ "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL",
+ (customer_id,),
+ )
+ if not customer:
+ return {
+ "can_submit": False,
+ "action": "blocked",
+ "message": "Kunde blev ikke fundet.",
+ }
+
+ sag = execute_query_single(
+ "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (sag_id,),
+ )
+ if not sag:
+ return {
+ "can_submit": False,
+ "action": "blocked",
+ "message": "Sag blev ikke fundet.",
+ }
+
+ if int(sag.get("customer_id") or 0) != int(customer_id):
+ return {
+ "can_submit": False,
+ "action": "blocked",
+ "message": "Sag og kunde matcher ikke.",
+ }
+
+ existing_subscription = execute_query_single(
+ """
+ SELECT id, status
+ FROM sag_subscriptions
+ WHERE sag_id = %s
+ AND status IN ('draft', 'active', 'paused')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (sag_id,),
+ )
+
+ if existing_subscription:
+ return {
+ "can_submit": True,
+ "action": "reuse",
+ "subscription_id": int(existing_subscription.get("id")),
+ "message": f"Genbruger abonnement #{int(existing_subscription.get('id'))} paa sagen.",
+ }
+
+ return {
+ "can_submit": True,
+ "action": "create",
+ "subscription_id": None,
+ "message": "Der findes intet aktivt abonnement paa sagen. Der oprettes et nyt.",
+ }
+
+
class InvoiceGenerateInput(BaseModel):
preview: bool = False
customer_id: Optional[int] = None
@@ -706,6 +803,273 @@ async def create_subscription_alias(payload: SubscriptionCreateInput):
return await create_sag_subscription(body)
+@router.post("/hardware/{hardware_id}/quick-rent", response_model=Dict[str, Any])
+async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
+ """Create a rental subscription + asset binding + startup order draft in one step."""
+ hardware = execute_query_single(
+ """
+ SELECT id, brand, model, serial_number, current_owner_type
+ FROM hardware_assets
+ WHERE id = %s
+ AND deleted_at IS NULL
+ """,
+ (hardware_id,),
+ )
+ if not hardware:
+ raise HTTPException(status_code=404, detail="Hardware not found")
+
+ if (hardware.get("current_owner_type") or "").strip().lower() != "bmc":
+ raise HTTPException(status_code=409, detail="Only BMC-owned assets can be rented from this flow")
+
+ customer = execute_query_single(
+ "SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL",
+ (payload.customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=400, detail="Customer not found")
+
+ sag = execute_query_single(
+ "SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (payload.sag_id,),
+ )
+ if not sag:
+ raise HTTPException(status_code=400, detail="Sag not found")
+
+ if int(sag.get("customer_id") or 0) != int(payload.customer_id):
+ raise HTTPException(status_code=400, detail="Sag customer mismatch")
+
+ _assert_asset_booking_available(
+ asset_id=hardware_id,
+ start_date=payload.start_date,
+ end_date=None,
+ )
+
+ hardware_label = f"{hardware.get('brand') or ''} {hardware.get('model') or ''}".strip() or f"Asset {hardware_id}"
+ existing_subscription = execute_query_single(
+ """
+ SELECT id, customer_id
+ FROM sag_subscriptions
+ WHERE sag_id = %s
+ AND status IN ('draft', 'active', 'paused')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (payload.sag_id,),
+ )
+
+ created_new_subscription = False
+ if existing_subscription:
+ subscription_id = int(existing_subscription.get("id") or 0)
+ if int(existing_subscription.get("customer_id") or 0) != int(payload.customer_id):
+ raise HTTPException(status_code=400, detail="Existing subscription customer mismatch for sag")
+
+ duplicate_line = execute_query_single(
+ """
+ SELECT id
+ FROM sag_subscription_items
+ WHERE subscription_id = %s
+ AND asset_id = %s
+ LIMIT 1
+ """,
+ (subscription_id, hardware_id),
+ )
+ if duplicate_line:
+ raise HTTPException(status_code=409, detail="Asset already exists on subscription line items for this sag")
+
+ next_line_row = execute_query_single(
+ "SELECT COALESCE(MAX(line_no), 0) + 1 AS next_line_no FROM sag_subscription_items WHERE subscription_id = %s",
+ (subscription_id,),
+ )
+ next_line_no = int((next_line_row or {}).get("next_line_no") or 1)
+
+ execute_query(
+ """
+ INSERT INTO sag_subscription_items (
+ subscription_id,
+ line_no,
+ product_id,
+ asset_id,
+ description,
+ quantity,
+ unit_price,
+ line_total,
+ period_from,
+ period_to,
+ price_type,
+ custom_price_override,
+ requires_serial_number,
+ serial_number,
+ billing_blocked,
+ billing_block_reason
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ subscription_id,
+ next_line_no,
+ None,
+ hardware_id,
+ f"Drift - {hardware_label}",
+ 1,
+ float(payload.operations_monthly_price),
+ float(payload.operations_monthly_price),
+ payload.start_date,
+ None,
+ "manual",
+ True,
+ False,
+ hardware.get("serial_number"),
+ False,
+ None,
+ ),
+ )
+
+ execute_query(
+ """
+ UPDATE sag_subscriptions
+ SET price = COALESCE(price, 0) + %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (float(payload.operations_monthly_price), subscription_id),
+ )
+ else:
+ subscription_payload = {
+ "customer_id": payload.customer_id,
+ "sag_id": payload.sag_id,
+ "product_name": f"Udlejning - {hardware_label}",
+ "price": float(payload.operations_monthly_price),
+ "billing_interval": "monthly",
+ "billing_day": min(max(payload.start_date.day, 1), 28),
+ "start_date": payload.start_date.isoformat(),
+ "end_date": None,
+ "binding_months": 0,
+ "billing_direction": "forward",
+ "price_type": "manual",
+ "custom_price_override": True,
+ "first_invoice_policy": "next_cycle",
+ "invoice_merge_key": f"rental-{payload.customer_id}-{payload.sag_id}",
+ "notes": f"Quick rent created from hardware #{hardware_id}",
+ "line_items": [
+ {
+ "description": f"Drift - {hardware_label}",
+ "quantity": 1,
+ "unit_price": float(payload.operations_monthly_price),
+ "asset_id": hardware_id,
+ "serial_number": hardware.get("serial_number"),
+ "price_type": "manual",
+ "custom_price_override": True,
+ }
+ ],
+ }
+
+ subscription = await create_sag_subscription(subscription_payload)
+ subscription_id = int(subscription.get("id") or 0)
+ if subscription_id <= 0:
+ raise HTTPException(status_code=500, detail="Subscription creation did not return an id")
+ created_new_subscription = True
+
+ binding = await create_sag_asset_binding(
+ subscription_id,
+ {
+ "asset_id": hardware_id,
+ "start_date": payload.start_date.isoformat(),
+ "end_date": None,
+ "binding_months": 0,
+ "shared_binding_key": f"rental-{payload.customer_id}-{payload.sag_id}",
+ "notice_period_days": payload.notice_period_days,
+ "sag_id": payload.sag_id,
+ "created_by_user_id": payload.created_by_user_id,
+ },
+ )
+
+ startup_lines: List[Dict[str, Any]] = []
+
+ def _append_line(key: str, description: str, quantity: float, unit_price: float) -> None:
+ if quantity <= 0 or unit_price < 0:
+ return
+ startup_lines.append(
+ {
+ "line_key": key,
+ "source_type": "manual",
+ "source_id": hardware_id,
+ "description": description,
+ "quantity": float(quantity),
+ "unit_price": float(unit_price),
+ "discount_percentage": 0,
+ "unit": "stk",
+ "product_id": None,
+ "selected": True,
+ }
+ )
+
+ _append_line("rental-start", f"Start - {hardware_label}", 1, float(payload.start_price))
+ _append_line("rental-freight", "Fragt", 1, float(payload.freight_price))
+ _append_line("rental-preparation", "Klargoring", 1, float(payload.preparation_price))
+ _append_line(
+ "rental-operations-initial",
+ f"Drift (forste {payload.initial_operations_months} maned(er))",
+ float(payload.initial_operations_months),
+ float(payload.operations_monthly_price),
+ )
+
+ if not startup_lines:
+ raise HTTPException(status_code=400, detail="At least one startup/order line must have a price")
+
+ coverage_end = payload.start_date + relativedelta(months=payload.initial_operations_months)
+ order_result = execute_query(
+ """
+ INSERT INTO ordre_drafts (
+ title,
+ customer_id,
+ lines_json,
+ notes,
+ coverage_start,
+ coverage_end,
+ billing_direction,
+ source_subscription_ids,
+ invoice_aggregate_key,
+ layout_number,
+ created_by_user_id,
+ sync_status,
+ export_status_json,
+ updated_at
+ ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
+ RETURNING id, title, customer_id, sync_status, created_at
+ """,
+ (
+ f"Udlejning opstart: {hardware_label}",
+ payload.customer_id,
+ json.dumps(startup_lines, ensure_ascii=False),
+ (
+ f"Quick rent startup order for {hardware_label}\n"
+ f"Sag: #{payload.sag_id}\n"
+ f"Subscription: #{subscription_id}\n"
+ f"Asset: #{hardware_id}"
+ ),
+ payload.start_date,
+ coverage_end,
+ "forward",
+ [subscription_id],
+ f"quick-rent-{subscription_id}-{hardware_id}-{payload.start_date.isoformat()}",
+ 1,
+ payload.created_by_user_id,
+ "pending",
+ json.dumps({"source": "quick_rent", "subscription_id": subscription_id}, ensure_ascii=False),
+ ),
+ )
+ order_draft = (order_result or [None])[0]
+
+ return {
+ "status": "ok",
+ "hardware_id": hardware_id,
+ "subscription_id": subscription_id,
+ "created_new_subscription": created_new_subscription,
+ "asset_binding_id": binding.get("id") if isinstance(binding, dict) else None,
+ "ordre_draft_id": order_draft.get("id") if order_draft else None,
+ "message": "Rental flow completed: subscription, binding and startup order draft created",
+ }
+
+
@router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any])
async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput):
body = payload.model_dump(exclude_none=True)
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html
index 1e93107..443ed59 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -26,7 +26,6 @@
.time-v1-calendar-grid {
display: flex;
position: relative;
- overflow-x: auto;
}
.time-v1-time-axis {
width: 60px;
@@ -67,7 +66,6 @@
justify-content: center;
gap: 6px;
position: sticky;
- top: 0;
z-index: 50;
color: var(--text-color);
}
@@ -1518,12 +1516,59 @@
gap: 0.75rem;
}
+ .module-priority-low {
+ --module-accent: #64748b;
+ }
+
+ .module-priority-normal {
+ --module-accent: #0f4c75;
+ }
+
+ .module-priority-high {
+ --module-accent: #d97706;
+ }
+
+ .module-priority-critical {
+ --module-accent: #dc2626;
+ }
+
+ .right-module-card {
+ border: 1px solid rgba(15, 76, 117, 0.14);
+ border-left: 4px solid var(--module-accent, var(--accent));
+ border-radius: 12px;
+ overflow: hidden;
+ background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, var(--accent)) 6%, var(--bg-card)) 0%, var(--bg-card) 100%);
+ box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08);
+ }
+
+ .right-module-card .card-header {
+ border-bottom: 1px solid color-mix(in srgb, var(--module-accent, var(--accent)) 22%, #d1d5db);
+ background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card));
+ }
+
+ .module-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin: 0;
+ font-size: 0.82rem;
+ font-weight: 700;
+ line-height: 1.2;
+ color: var(--text-primary);
+ }
+
+ .module-icon {
+ color: var(--module-accent, var(--accent));
+ font-size: 0.9rem;
+ flex-shrink: 0;
+ }
+
.right-modules-grid .card-header {
padding: 0.35rem 0.6rem;
}
.right-modules-grid .card-header h6 {
- font-size: 0.8rem;
+ font-size: 0.82rem;
}
.right-modules-grid .card-body {
@@ -1689,7 +1734,7 @@
}
.case-tabs-topbar.topbar-primary {
- grid-template-columns: 105px minmax(170px, 1.1fr) minmax(170px, 1.1fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr);
+ grid-template-columns: 105px minmax(260px, 1.45fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr);
background: linear-gradient(140deg, rgba(15,76,117,0.11), rgba(15,76,117,0.03));
border: 1px solid rgba(15,76,117,0.22);
box-shadow: 0 3px 12px rgba(15,76,117,0.1);
@@ -1735,6 +1780,21 @@
white-space: nowrap;
}
+ .topbar-company-contact {
+ margin-top: 0.22rem;
+ font-size: 0.72rem;
+ line-height: 1.2;
+ color: color-mix(in srgb, var(--accent) 58%, #475569);
+ opacity: 0.95;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .topbar-company-contact i {
+ margin-right: 0.25rem;
+ }
+
.topbar-company-edit-btn {
display: inline-flex;
align-items: center;
@@ -1841,6 +1901,25 @@
box-shadow: 0 1px 6px rgba(75,145,255,0.35);
}
+ [data-bs-theme="dark"] .topbar-company-contact {
+ color: #b8d9f1;
+ }
+
+ [data-bs-theme="dark"] .right-module-card {
+ border-color: rgba(140, 182, 219, 0.25);
+ box-shadow: 0 4px 16px rgba(5, 22, 40, 0.45);
+ background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%);
+ }
+
+ [data-bs-theme="dark"] .right-module-card .card-header {
+ border-bottom-color: color-mix(in srgb, var(--module-accent, #69a6d5) 45%, #4b5563);
+ background: color-mix(in srgb, var(--module-accent, #69a6d5) 18%, rgba(18, 28, 40, 0.98));
+ }
+
+ [data-bs-theme="dark"] .module-title {
+ color: #e5edf5;
+ }
+
.case-tabs-topbar.topbar-secondary {
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
grid-template-columns:
@@ -2261,17 +2340,18 @@
Firma
-
-
{{ customer.name if customer else 'Ingen kunde' }}
-
+
+
+ {{ customer.name if customer else 'Ingen kunde' }}
+
+
+
+ Hoved kontakt: {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}
+
-
-
Kontakt
-
{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}
-
Status