feat: Implement quick-rent functionality for hardware assets

- Added QuickRentCreateInput model to handle quick-rent requests.
- Introduced quick_rent_preview endpoint to check existing subscriptions.
- Created quick_rent_hardware endpoint to manage rental subscriptions, asset bindings, and startup order drafts.
- Updated SQL queries to ensure proper data retrieval and handling.
- Added default rental price columns to hardware_assets table via migration.
- Enhanced UI in sag templates for better user experience and accessibility.
- Refactored existing code for improved readability and maintainability.
This commit is contained in:
Christian 2026-04-21 01:34:40 +02:00
parent 8e8616c835
commit 4a52bdb5d6
12 changed files with 1019 additions and 101 deletions

View File

@ -324,6 +324,16 @@ async def sync_eset_hardware() -> None:
update_fields.append("brand = %s") update_fields.append("brand = %s")
update_params.append(brand) update_params.append(brand)
# Auto-created ESET devices are customer devices by default unless explicitly reassigned.
if customer_id:
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_fields.append("current_owner_customer_id = %s")
update_params.append(customer_id)
elif existing[0].get("notes") == "Auto-created from ESET" and existing[0].get("current_owner_type") != "customer":
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_params.append(hardware_id) update_params.append(hardware_id)
update_query = f""" update_query = f"""
UPDATE hardware_assets UPDATE hardware_assets
@ -332,7 +342,8 @@ async def sync_eset_hardware() -> None:
""" """
execute_query(update_query, tuple(update_params)) execute_query(update_query, tuple(update_params))
else: 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_query = """
INSERT INTO hardware_assets ( INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, asset_type, brand, model, serial_number,

View File

@ -374,9 +374,11 @@ async def create_hardware(data: dict):
internal_asset_id, notes, current_owner_type, current_owner_customer_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life, status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link, anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group eset_uuid, hardware_specs, eset_group,
rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
@ -402,7 +404,11 @@ async def create_hardware(data: dict):
data.get("anydesk_link"), data.get("anydesk_link"),
data.get("eset_uuid"), data.get("eset_uuid"),
specs, specs,
data.get("eset_group") data.get("eset_group"),
data.get("rental_default_start_price"),
data.get("rental_default_freight_price"),
data.get("rental_default_preparation_price"),
data.get("rental_default_operations_monthly_price"),
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result: if not result:
@ -503,7 +509,9 @@ async def update_hardware(hardware_id: int, data: dict):
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id", "internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life", "status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link", "follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group" "eset_uuid", "hardware_specs", "eset_group",
"rental_default_start_price", "rental_default_freight_price",
"rental_default_preparation_price", "rental_default_operations_monthly_price"
] ]
for field in allowed_fields: for field in allowed_fields:

View File

@ -285,6 +285,30 @@ js{% extends "shared/frontend/base.html" %}
</div> </div>
</div> </div>
<!-- Rental Defaults -->
<div class="form-section">
<h3 class="form-section-title">💶 Standardpriser for Udlejning</h3>
<div class="form-grid">
<div class="form-group">
<label for="rental_default_start_price">Standard startpris</label>
<input type="number" id="rental_default_start_price" name="rental_default_start_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_freight_price">Standard fragt</label>
<input type="number" id="rental_default_freight_price" name="rental_default_freight_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_preparation_price">Standard klargoring</label>
<input type="number" id="rental_default_preparation_price" name="rental_default_preparation_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_operations_monthly_price">Standard drift pr. maned</label>
<input type="number" id="rental_default_operations_monthly_price" name="rental_default_operations_monthly_price" min="0" step="0.01" value="0">
</div>
</div>
<div class="form-text mt-2">Bruges til at autoudfylde Udlej-modal pa asseten.</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="form-section"> <div class="form-section">
<h3 class="form-section-title">📝 Noter</h3> <h3 class="form-section-title">📝 Noter</h3>
@ -356,6 +380,13 @@ js{% extends "shared/frontend/base.html" %}
// Convert customer_id to integer // Convert customer_id to integer
if (key === 'current_owner_customer_id') { if (key === 'current_owner_customer_id') {
data[key] = parseInt(value); data[key] = parseInt(value);
} else if (
key === 'rental_default_start_price' ||
key === 'rental_default_freight_price' ||
key === 'rental_default_preparation_price' ||
key === 'rental_default_operations_monthly_price'
) {
data[key] = Number(value);
} else { } else {
data[key] = value; data[key] = value;
} }

View File

@ -407,6 +407,12 @@
<span class="small fw-bold">Opret Sag</span> <span class="small fw-bold">Opret Sag</span>
</a> </a>
</div> </div>
<div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#quickRentModal">
<i class="bi bi-box-seam text-success"></i>
<span class="small fw-bold">Udlej</span>
</div>
</div>
<div class="col-6"> <div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal"> <div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt text-primary"></i> <i class="bi bi-geo-alt text-primary"></i>
@ -728,6 +734,99 @@
</div> </div>
</div> </div>
<!-- Quick Rent Modal -->
<div class="modal fade" id="quickRentModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Udlej Hardware #{{ hardware.id }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
</div>
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3">
Vaelg kunde og sag for at se hvad der bliver oprettet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<select class="form-select" id="quickRentCustomerId" required>
<option value="">-- Vaelg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}">{{ customer.navn }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Sag ID</label>
<input type="number" class="form-control" id="quickRentSagId" placeholder="fx 123" min="1" required>
</div>
<div class="col-md-4">
<label class="form-label">Start dato</label>
<input type="date" class="form-control" id="quickRentStartDate" required>
</div>
<div class="col-md-4">
<label class="form-label">Startpris</label>
<input
type="number"
class="form-control"
id="quickRentStartPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Fragt</label>
<input
type="number"
class="form-control"
id="quickRentFreightPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Klargoring</label>
<input
type="number"
class="form-control"
id="quickRentPreparationPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Drift pr. maned</label>
<input
type="number"
class="form-control"
id="quickRentOperationsMonthlyPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 }}"
required
>
</div>
<div class="col-md-4">
<label class="form-label">Drift i ordre (maneder)</label>
<input type="number" class="form-control" id="quickRentInitialMonths" min="1" max="12" value="2">
</div>
</div>
<div class="form-text mt-2">Standarden er 2 mdr. drift i opstartsordren (1+1).</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" id="quickRentSubmitBtn" onclick="submitQuickRent()">Opret udlejning</button>
</div>
</div>
</div>
</div>
<!-- Modal for Owner --> <!-- Modal for Owner -->
<div class="modal fade" id="ownerModal" tabindex="-1"> <div class="modal fade" id="ownerModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@ -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 // Tree Toggle Function
function toggleLocationChildren(event, nodeId) { function toggleLocationChildren(event, nodeId) {
event.preventDefault(); event.preventDefault();
@ -1040,6 +1252,24 @@
// Initialize Tags // Initialize Tags
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const quickRentStartDate = document.getElementById('quickRentStartDate');
const quickRentCustomerId = document.getElementById('quickRentCustomerId');
const quickRentSagId = document.getElementById('quickRentSagId');
const quickRentModal = document.getElementById('quickRentModal');
if (quickRentStartDate && !quickRentStartDate.value) {
quickRentStartDate.value = new Date().toISOString().slice(0, 10);
}
if (quickRentCustomerId) {
quickRentCustomerId.addEventListener('change', refreshQuickRentPlan);
}
if (quickRentSagId) {
quickRentSagId.addEventListener('input', refreshQuickRentPlan);
}
if (quickRentModal) {
quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan);
}
refreshQuickRentPlan();
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch'); const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect'); const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSearch = document.getElementById('ownerContactSearch'); const ownerContactSearch = document.getElementById('ownerContactSearch');

View File

@ -285,6 +285,58 @@
</div> </div>
</div> </div>
<!-- Rental Defaults -->
<div class="form-section">
<h3 class="form-section-title">💶 Standardpriser for Udlejning</h3>
<div class="form-grid">
<div class="form-group">
<label for="rental_default_start_price">Standard startpris</label>
<input
type="number"
id="rental_default_start_price"
name="rental_default_start_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_freight_price">Standard fragt</label>
<input
type="number"
id="rental_default_freight_price"
name="rental_default_freight_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_preparation_price">Standard klargoring</label>
<input
type="number"
id="rental_default_preparation_price"
name="rental_default_preparation_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_operations_monthly_price">Standard drift pr. maned</label>
<input
type="number"
id="rental_default_operations_monthly_price"
name="rental_default_operations_monthly_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 }}"
>
</div>
</div>
<div class="form-text mt-2">Bruges til at autoudfylde Udlej-modal pa asseten.</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="form-section"> <div class="form-section">
<h3 class="form-section-title">📝 Noter</h3> <h3 class="form-section-title">📝 Noter</h3>
@ -329,6 +381,13 @@
// Convert customer_id to integer // Convert customer_id to integer
if (key === 'current_owner_customer_id') { if (key === 'current_owner_customer_id') {
data[key] = parseInt(value); data[key] = parseInt(value);
} else if (
key === 'rental_default_start_price' ||
key === 'rental_default_freight_price' ||
key === 'rental_default_preparation_price' ||
key === 'rental_default_operations_monthly_price'
) {
data[key] = Number(value);
} else { } else {
data[key] = value; data[key] = value;
} }

View File

@ -260,7 +260,7 @@ async def list_ordre_drafts(
"""List all ordre drafts (no user filtering).""" """List all ordre drafts (no user filtering)."""
try: try:
query = """ query = """
SELECT 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, coverage_start, coverage_end, billing_direction, source_subscription_ids,
invoice_aggregate_key, sync_status, export_idempotency_key, invoice_aggregate_key, sync_status, export_idempotency_key,
economic_order_number, economic_invoice_number, economic_order_number, economic_invoice_number,
@ -271,13 +271,13 @@ async def list_ordre_drafts(
FROM ordre_draft_sync_events ev FROM ordre_draft_sync_events ev
WHERE ev.draft_id = ordre_drafts.id WHERE ev.draft_id = ordre_drafts.id
) AS sync_event_count, ) AS sync_event_count,
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 FROM ordre_drafts
LEFT JOIN LATERAL ( 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 FROM ordre_draft_sync_events
WHERE draft_id = ordre_drafts.id 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 LIMIT 1
) ev_latest ON TRUE ) ev_latest ON TRUE
ORDER BY updated_at DESC, id DESC ORDER BY updated_at DESC, id DESC

View File

@ -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.core.config import settings
from app.jobs.process_subscriptions import process_subscriptions from app.jobs.process_subscriptions import process_subscriptions
from app.subscriptions.backend.router import create_subscription as create_sag_subscription from app.subscriptions.backend.router import create_subscription as create_sag_subscription
from app.subscriptions.backend.router import create_subscription_asset_binding as create_sag_asset_binding
from app.subscriptions.backend.router import update_subscription as update_sag_subscription from app.subscriptions.backend.router import update_subscription as update_sag_subscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -538,6 +539,102 @@ class AssetBindingCreateInput(BaseModel):
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
class QuickRentCreateInput(BaseModel):
customer_id: int
sag_id: int
start_date: date = Field(default_factory=date.today)
start_price: float = Field(default=0, ge=0)
freight_price: float = Field(default=0, ge=0)
preparation_price: float = Field(default=0, ge=0)
operations_monthly_price: float = Field(gt=0)
initial_operations_months: int = Field(default=2, ge=1, le=12)
notice_period_days: int = Field(default=30, ge=0)
created_by_user_id: Optional[int] = None
@router.get("/hardware/{hardware_id}/quick-rent/preview", response_model=Dict[str, Any])
async def quick_rent_preview(hardware_id: int, customer_id: int = Query(...), sag_id: int = Query(...)):
"""Preview whether quick-rent will reuse an existing subscription or create a new one."""
hardware = execute_query_single(
"""
SELECT id, current_owner_type
FROM hardware_assets
WHERE id = %s
AND deleted_at IS NULL
""",
(hardware_id,),
)
if not hardware:
return {
"can_submit": False,
"action": "blocked",
"message": "Hardware blev ikke fundet.",
}
if (hardware.get("current_owner_type") or "").strip().lower() != "bmc":
return {
"can_submit": False,
"action": "blocked",
"message": "Kun BMC-ejede assets kan udlejes fra denne modal.",
}
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL",
(customer_id,),
)
if not customer:
return {
"can_submit": False,
"action": "blocked",
"message": "Kunde blev ikke fundet.",
}
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not sag:
return {
"can_submit": False,
"action": "blocked",
"message": "Sag blev ikke fundet.",
}
if int(sag.get("customer_id") or 0) != int(customer_id):
return {
"can_submit": False,
"action": "blocked",
"message": "Sag og kunde matcher ikke.",
}
existing_subscription = execute_query_single(
"""
SELECT id, status
FROM sag_subscriptions
WHERE sag_id = %s
AND status IN ('draft', 'active', 'paused')
ORDER BY id DESC
LIMIT 1
""",
(sag_id,),
)
if existing_subscription:
return {
"can_submit": True,
"action": "reuse",
"subscription_id": int(existing_subscription.get("id")),
"message": f"Genbruger abonnement #{int(existing_subscription.get('id'))} paa sagen.",
}
return {
"can_submit": True,
"action": "create",
"subscription_id": None,
"message": "Der findes intet aktivt abonnement paa sagen. Der oprettes et nyt.",
}
class InvoiceGenerateInput(BaseModel): class InvoiceGenerateInput(BaseModel):
preview: bool = False preview: bool = False
customer_id: Optional[int] = None customer_id: Optional[int] = None
@ -706,6 +803,273 @@ async def create_subscription_alias(payload: SubscriptionCreateInput):
return await create_sag_subscription(body) return await create_sag_subscription(body)
@router.post("/hardware/{hardware_id}/quick-rent", response_model=Dict[str, Any])
async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
"""Create a rental subscription + asset binding + startup order draft in one step."""
hardware = execute_query_single(
"""
SELECT id, brand, model, serial_number, current_owner_type
FROM hardware_assets
WHERE id = %s
AND deleted_at IS NULL
""",
(hardware_id,),
)
if not hardware:
raise HTTPException(status_code=404, detail="Hardware not found")
if (hardware.get("current_owner_type") or "").strip().lower() != "bmc":
raise HTTPException(status_code=409, detail="Only BMC-owned assets can be rented from this flow")
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL",
(payload.customer_id,),
)
if not customer:
raise HTTPException(status_code=400, detail="Customer not found")
sag = execute_query_single(
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(payload.sag_id,),
)
if not sag:
raise HTTPException(status_code=400, detail="Sag not found")
if int(sag.get("customer_id") or 0) != int(payload.customer_id):
raise HTTPException(status_code=400, detail="Sag customer mismatch")
_assert_asset_booking_available(
asset_id=hardware_id,
start_date=payload.start_date,
end_date=None,
)
hardware_label = f"{hardware.get('brand') or ''} {hardware.get('model') or ''}".strip() or f"Asset {hardware_id}"
existing_subscription = execute_query_single(
"""
SELECT id, customer_id
FROM sag_subscriptions
WHERE sag_id = %s
AND status IN ('draft', 'active', 'paused')
ORDER BY id DESC
LIMIT 1
""",
(payload.sag_id,),
)
created_new_subscription = False
if existing_subscription:
subscription_id = int(existing_subscription.get("id") or 0)
if int(existing_subscription.get("customer_id") or 0) != int(payload.customer_id):
raise HTTPException(status_code=400, detail="Existing subscription customer mismatch for sag")
duplicate_line = execute_query_single(
"""
SELECT id
FROM sag_subscription_items
WHERE subscription_id = %s
AND asset_id = %s
LIMIT 1
""",
(subscription_id, hardware_id),
)
if duplicate_line:
raise HTTPException(status_code=409, detail="Asset already exists on subscription line items for this sag")
next_line_row = execute_query_single(
"SELECT COALESCE(MAX(line_no), 0) + 1 AS next_line_no FROM sag_subscription_items WHERE subscription_id = %s",
(subscription_id,),
)
next_line_no = int((next_line_row or {}).get("next_line_no") or 1)
execute_query(
"""
INSERT INTO sag_subscription_items (
subscription_id,
line_no,
product_id,
asset_id,
description,
quantity,
unit_price,
line_total,
period_from,
period_to,
price_type,
custom_price_override,
requires_serial_number,
serial_number,
billing_blocked,
billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
subscription_id,
next_line_no,
None,
hardware_id,
f"Drift - {hardware_label}",
1,
float(payload.operations_monthly_price),
float(payload.operations_monthly_price),
payload.start_date,
None,
"manual",
True,
False,
hardware.get("serial_number"),
False,
None,
),
)
execute_query(
"""
UPDATE sag_subscriptions
SET price = COALESCE(price, 0) + %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(float(payload.operations_monthly_price), subscription_id),
)
else:
subscription_payload = {
"customer_id": payload.customer_id,
"sag_id": payload.sag_id,
"product_name": f"Udlejning - {hardware_label}",
"price": float(payload.operations_monthly_price),
"billing_interval": "monthly",
"billing_day": min(max(payload.start_date.day, 1), 28),
"start_date": payload.start_date.isoformat(),
"end_date": None,
"binding_months": 0,
"billing_direction": "forward",
"price_type": "manual",
"custom_price_override": True,
"first_invoice_policy": "next_cycle",
"invoice_merge_key": f"rental-{payload.customer_id}-{payload.sag_id}",
"notes": f"Quick rent created from hardware #{hardware_id}",
"line_items": [
{
"description": f"Drift - {hardware_label}",
"quantity": 1,
"unit_price": float(payload.operations_monthly_price),
"asset_id": hardware_id,
"serial_number": hardware.get("serial_number"),
"price_type": "manual",
"custom_price_override": True,
}
],
}
subscription = await create_sag_subscription(subscription_payload)
subscription_id = int(subscription.get("id") or 0)
if subscription_id <= 0:
raise HTTPException(status_code=500, detail="Subscription creation did not return an id")
created_new_subscription = True
binding = await create_sag_asset_binding(
subscription_id,
{
"asset_id": hardware_id,
"start_date": payload.start_date.isoformat(),
"end_date": None,
"binding_months": 0,
"shared_binding_key": f"rental-{payload.customer_id}-{payload.sag_id}",
"notice_period_days": payload.notice_period_days,
"sag_id": payload.sag_id,
"created_by_user_id": payload.created_by_user_id,
},
)
startup_lines: List[Dict[str, Any]] = []
def _append_line(key: str, description: str, quantity: float, unit_price: float) -> None:
if quantity <= 0 or unit_price < 0:
return
startup_lines.append(
{
"line_key": key,
"source_type": "manual",
"source_id": hardware_id,
"description": description,
"quantity": float(quantity),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
}
)
_append_line("rental-start", f"Start - {hardware_label}", 1, float(payload.start_price))
_append_line("rental-freight", "Fragt", 1, float(payload.freight_price))
_append_line("rental-preparation", "Klargoring", 1, float(payload.preparation_price))
_append_line(
"rental-operations-initial",
f"Drift (forste {payload.initial_operations_months} maned(er))",
float(payload.initial_operations_months),
float(payload.operations_monthly_price),
)
if not startup_lines:
raise HTTPException(status_code=400, detail="At least one startup/order line must have a price")
coverage_end = payload.start_date + relativedelta(months=payload.initial_operations_months)
order_result = execute_query(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
coverage_start,
coverage_end,
billing_direction,
source_subscription_ids,
invoice_aggregate_key,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING id, title, customer_id, sync_status, created_at
""",
(
f"Udlejning opstart: {hardware_label}",
payload.customer_id,
json.dumps(startup_lines, ensure_ascii=False),
(
f"Quick rent startup order for {hardware_label}\n"
f"Sag: #{payload.sag_id}\n"
f"Subscription: #{subscription_id}\n"
f"Asset: #{hardware_id}"
),
payload.start_date,
coverage_end,
"forward",
[subscription_id],
f"quick-rent-{subscription_id}-{hardware_id}-{payload.start_date.isoformat()}",
1,
payload.created_by_user_id,
"pending",
json.dumps({"source": "quick_rent", "subscription_id": subscription_id}, ensure_ascii=False),
),
)
order_draft = (order_result or [None])[0]
return {
"status": "ok",
"hardware_id": hardware_id,
"subscription_id": subscription_id,
"created_new_subscription": created_new_subscription,
"asset_binding_id": binding.get("id") if isinstance(binding, dict) else None,
"ordre_draft_id": order_draft.get("id") if order_draft else None,
"message": "Rental flow completed: subscription, binding and startup order draft created",
}
@router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any]) @router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any])
async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput): async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput):
body = payload.model_dump(exclude_none=True) body = payload.model_dump(exclude_none=True)

View File

@ -26,7 +26,6 @@
.time-v1-calendar-grid { .time-v1-calendar-grid {
display: flex; display: flex;
position: relative; position: relative;
overflow-x: auto;
} }
.time-v1-time-axis { .time-v1-time-axis {
width: 60px; width: 60px;
@ -67,7 +66,6 @@
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
position: sticky; position: sticky;
top: 0;
z-index: 50; z-index: 50;
color: var(--text-color); color: var(--text-color);
} }
@ -1518,12 +1516,59 @@
gap: 0.75rem; 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 { .right-modules-grid .card-header {
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
} }
.right-modules-grid .card-header h6 { .right-modules-grid .card-header h6 {
font-size: 0.8rem; font-size: 0.82rem;
} }
.right-modules-grid .card-body { .right-modules-grid .card-body {
@ -1689,7 +1734,7 @@
} }
.case-tabs-topbar.topbar-primary { .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)); 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); border: 1px solid rgba(15,76,117,0.22);
box-shadow: 0 3px 12px rgba(15,76,117,0.1); box-shadow: 0 3px 12px rgba(15,76,117,0.1);
@ -1735,6 +1780,21 @@
white-space: nowrap; 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 { .topbar-company-edit-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1841,6 +1901,25 @@
box-shadow: 0 1px 6px rgba(75,145,255,0.35); 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 { .case-tabs-topbar.topbar-secondary {
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */ /* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
grid-template-columns: grid-template-columns:
@ -2261,16 +2340,17 @@
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item">
<div class="case-tabs-topbar-label"><i class="bi bi-building"></i>Firma</div> <div class="case-tabs-topbar-label"><i class="bi bi-building"></i>Firma</div>
<div>
<div class="case-tabs-topbar-value topbar-company-row"> <div class="case-tabs-topbar-value topbar-company-row">
<span class="topbar-company-name">{{ customer.name if customer else 'Ingen kunde' }}</span> <span class="topbar-company-name">{{ customer.name if customer else 'Ingen kunde' }}</span>
<button type="button" class="topbar-company-edit-btn" onclick="showCustomerSearch('replace')" title="Skift kunde/firma"> <button type="button" class="topbar-company-edit-btn" onclick="showCustomerSearch('replace')" title="Skift kunde/firma">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
</div> </div>
<div class="topbar-company-contact">
<i class="bi bi-person"></i>Hoved kontakt: {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}
</div>
</div> </div>
<div class="case-tabs-topbar-item">
<div class="case-tabs-topbar-label"><i class="bi bi-person"></i>Kontakt</div>
<div class="case-tabs-topbar-value">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}</div>
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item">
<div class="case-tabs-topbar-label"><i class="bi bi-circle-half"></i>Status</div> <div class="case-tabs-topbar-label"><i class="bi bi-circle-half"></i>Status</div>
@ -2708,19 +2788,6 @@
<div class="row g-4"> <div class="row g-4">
<!-- TREDELT-1: Relations, History, etc. --> <!-- TREDELT-1: Relations, History, etc. -->
<div class="col-12" id="inner-left-col"> <div class="col-12" id="inner-left-col">
<div class="mb-3"><div class="card h-100 d-flex flex-column right-module-card" data-module="locations" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📍 Lokationer</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('location')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto p-0" style="max-height: 180px;">
<div class="list-group list-group-flush" id="locations-list">
<div class="p-3 text-center text-muted">Henter lokationer...</div>
</div>
</div>
</div></div>
</div> </div>
<!-- TREDELT-2: Hero, Info --> <!-- TREDELT-2: Hero, Info -->
<div class="col-12" id="inner-center-col"> <div class="col-12" id="inner-center-col">
@ -2952,10 +3019,10 @@
<!-- Relationer (center) --> <!-- Relationer (center) -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="card h-100 d-flex flex-column" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<h6 class="mb-0" style="color: var(--accent);">🔗 Relationer</h6> <h6 class="module-title"><i class="bi bi-diagram-3-fill module-icon"></i>Relationer</h6>
<i class="bi bi-info-circle text-muted" <i class="bi bi-info-circle text-muted"
style="font-size:0.9rem; cursor:help;" style="font-size:0.9rem; cursor:help;"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
@ -3072,9 +3139,9 @@
<div class="row mb-3"> <div class="row mb-3">
<!-- Files --> <!-- Files -->
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="card h-100" data-module="files" data-has-content="unknown"> <div class="card h-100 right-module-card module-priority-low" data-module="files" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📁 Filer & Dokumenter</h6> <h6 class="module-title"><i class="bi bi-folder2-open module-icon"></i>Filer & Dokumenter</h6>
<input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileUpload(this.files)"> <input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileUpload(this.files)">
<button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('fileInput').click()"> <button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('fileInput').click()">
<i class="bi bi-cloud-upload"></i> Upload <i class="bi bi-cloud-upload"></i> Upload
@ -5289,10 +5356,10 @@
</script> </script>
<!-- Tid & Fakturering Section (Moved from Right Column) --> <!-- Tid & Fakturering Section (Moved from Right Column) -->
<div class="card mt-3" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}"> <div class="card mt-3 right-module-card module-priority-high" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h5> <h5 class="module-title"><i class="bi bi-clock-history module-icon"></i>Tid & Fakturering</h5>
</div> </div>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()"> <button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
<i class="bi bi-fullscreen me-1"></i>Fuld Formular <i class="bi bi-fullscreen me-1"></i>Fuld Formular
@ -5407,9 +5474,23 @@
</div></div><!-- slut inner cols --> </div></div><!-- slut inner cols -->
<div class="col-xl-4 col-lg-4" id="case-right-column"> <div class="col-xl-4 col-lg-4" id="case-right-column">
<div class="right-modules-grid"> <div class="right-modules-grid">
<div class="card h-100 d-flex flex-column right-module-card" data-module="tags" data-has-content="unknown"> <div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="locations" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏷️ TAGS</h6> <h6 class="module-title"><i class="bi bi-geo-alt-fill module-icon"></i>Lokationer</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('location')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto p-0" style="max-height: 180px;">
<div class="list-group list-group-flush" id="locations-list">
<div class="p-3 text-center text-muted">Henter lokationer...</div>
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())" onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
title="Tilføj tag"> title="Tilføj tag">
@ -5429,9 +5510,9 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="customers" data-has-content="{{ 'true' if customers else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="customers" data-has-content="{{ 'true' if customers else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏢 Kunder</h6> <h6 class="module-title"><i class="bi bi-building-fill module-icon"></i>Kunder</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('customer')"> <button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('customer')">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</button> </button>
@ -5462,9 +5543,9 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="contacts" data-has-content="{{ 'true' if contacts else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="contacts" data-has-content="{{ 'true' if contacts else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">👥 Kontakter</h6> <h6 class="module-title"><i class="bi bi-people-fill module-icon"></i>Kontakter</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('contact')"> <button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('contact')">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</button> </button>
@ -5511,9 +5592,9 @@
</div> </div>
</div> </div>
<div class="card d-flex flex-column h-100 right-module-card" data-module="hardware" data-has-content="unknown"> <div class="card d-flex flex-column h-100 right-module-card module-priority-normal" data-module="hardware" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6> <h6 class="module-title"><i class="bi bi-pc-display-horizontal module-icon"></i>Hardware</h6>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-secondary" onclick="sendCaseHardwareLabelsToPrinter()" title="Print labels for alt hardware på sagen"> <button class="btn btn-sm btn-outline-secondary" onclick="sendCaseHardwareLabelsToPrinter()" title="Print labels for alt hardware på sagen">
<i class="bi bi-printer"></i> <i class="bi bi-printer"></i>
@ -5530,14 +5611,19 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="pipeline" data-has-content="{{ 'true' if case.pipeline_stage_id or case.pipeline_amount or case.pipeline_probability else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card module-priority-high" data-module="pipeline" data-has-content="{{ 'true' if case.pipeline_stage_id or case.pipeline_amount or case.pipeline_probability else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📈 Salgspipeline</h6> <h6 class="module-title"><i class="bi bi-graph-up-arrow module-icon"></i>Salgspipeline</h6>
<div class="d-flex gap-2">
<button id="pipelineCollapseToggle" class="btn btn-sm btn-outline-secondary" onclick="togglePipelineModule()" title="Vis/skjul pipeline">
<i id="pipelineCollapseIcon" class="bi bi-chevron-down"></i>
</button>
<button id="pipelineEditToggle" class="btn btn-sm btn-outline-primary" onclick="togglePipelineEdit()"> <button id="pipelineEditToggle" class="btn btn-sm btn-outline-primary" onclick="togglePipelineEdit()">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
</div> </div>
<div class="card-body"> </div>
<div id="pipelineCardBody" class="card-body d-none">
<div id="pipelineViewMode"> <div id="pipelineViewMode">
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
@ -5617,9 +5703,9 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="call-history" data-has-content="{{ 'true' if call_history else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card module-priority-critical" data-module="call-history" data-has-content="{{ 'true' if call_history else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📞 Opkaldshistorik</h6> <h6 class="module-title"><i class="bi bi-telephone-inbound-fill module-icon"></i>Opkaldshistorik</h6>
<a href="/telefoni" class="btn btn-sm btn-outline-primary"> <a href="/telefoni" class="btn btn-sm btn-outline-primary">
<i class="bi bi-telephone"></i> <i class="bi bi-telephone"></i>
</a> </a>
@ -5675,9 +5761,9 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="todo-steps" data-has-content="unknown"> <div class="card h-100 d-flex flex-column right-module-card module-priority-high" data-module="todo-steps" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">Todo-opgaver</h6> <h6 class="module-title"><i class="bi bi-check2-square module-icon"></i>Todo-opgaver</h6>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleTodoStepForm()" title="Tilføj opgave"> <button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleTodoStepForm()" title="Tilføj opgave">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</button> </button>
@ -5697,9 +5783,9 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="wiki" data-has-content="unknown"> <div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="wiki" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">Kunde-wiki</h6> <h6 class="module-title"><i class="bi bi-journal-richtext module-icon"></i>Kunde-wiki</h6>
</div> </div>
<div class="card-body flex-grow-1 p-0" style="max-height: 220px; overflow: auto;"> <div class="card-body flex-grow-1 p-0" style="max-height: 220px; overflow: auto;">
<div class="p-2 border-bottom"> <div class="p-2 border-bottom">
@ -10788,10 +10874,20 @@
} }
function togglePipelineEdit(forceEdit = null) { function togglePipelineEdit(forceEdit = null) {
const moduleBody = document.getElementById('pipelineCardBody');
const collapseIcon = document.getElementById('pipelineCollapseIcon');
const view = document.getElementById('pipelineViewMode'); const view = document.getElementById('pipelineViewMode');
const edit = document.getElementById('pipelineEditMode'); const edit = document.getElementById('pipelineEditMode');
const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit; const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit;
if (moduleBody && moduleBody.classList.contains('d-none')) {
moduleBody.classList.remove('d-none');
}
if (collapseIcon) {
collapseIcon.classList.remove('bi-chevron-down');
collapseIcon.classList.add('bi-chevron-up');
}
if (shouldEdit) { if (shouldEdit) {
view.classList.add('d-none'); view.classList.add('d-none');
edit.classList.remove('d-none'); edit.classList.remove('d-none');
@ -10805,6 +10901,28 @@
} }
} }
function togglePipelineModule(forceOpen = null) {
const moduleBody = document.getElementById('pipelineCardBody');
const collapseIcon = document.getElementById('pipelineCollapseIcon');
if (!moduleBody) return;
const shouldOpen = forceOpen === null ? moduleBody.classList.contains('d-none') : Boolean(forceOpen);
if (shouldOpen) {
moduleBody.classList.remove('d-none');
if (collapseIcon) {
collapseIcon.classList.remove('bi-chevron-down');
collapseIcon.classList.add('bi-chevron-up');
}
return;
}
moduleBody.classList.add('d-none');
if (collapseIcon) {
collapseIcon.classList.remove('bi-chevron-up');
collapseIcon.classList.add('bi-chevron-down');
}
}
async function ensurePipelineStagesLoaded() { async function ensurePipelineStagesLoaded() {
const select = document.getElementById('pipelineStageSelect'); const select = document.getElementById('pipelineStageSelect');
if (!select) return; if (!select) return;

View File

@ -5,13 +5,36 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.search-bar { .search-bar {
margin-bottom: 1.5rem; margin-bottom: 0;
flex: 1 1 380px;
min-width: 240px;
} }
.search-bar input { .search-bar input {
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
padding: 0.6rem 1rem; padding: 0.45rem 0.85rem;
}
.top-controls-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.9rem;
}
.top-controls-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-left: auto;
flex-shrink: 0;
}
.top-controls-actions .btn {
white-space: nowrap;
padding-top: 0.4rem;
padding-bottom: 0.4rem;
} }
.table-wrapper { .table-wrapper {
@ -270,11 +293,12 @@
.stats-bar { .stats-bar {
display: inline-flex; display: inline-flex;
gap: 0.4rem; gap: 0.4rem;
margin-bottom: 0.8rem; margin-bottom: 0;
padding: 0.25rem; padding: 0.25rem;
background: var(--bg-card); background: var(--bg-card);
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(0,0,0,0.08); border: 1px solid rgba(0,0,0,0.08);
flex-shrink: 0;
} }
.stat-item { .stat-item {
@ -325,6 +349,16 @@
word-break: break-word; word-break: break-word;
} }
@media (max-width: 1100px) {
.top-controls-row {
flex-wrap: wrap;
}
.top-controls-actions {
margin-left: 0;
}
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
@ -360,22 +394,11 @@
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;"> <div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div> <div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<!-- Header --> <div class="top-controls-row">
<div class="d-flex justify-content-between align-items-center mb-2"> <h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
<h1 style="margin: 0; color: var(--accent);">
<i class="bi bi-list-check"></i> <i class="bi bi-list-check"></i>
</h1> </h1>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
<i class="bi bi-plus-lg me-2"></i>Ny Sag
</button>
</div>
</div>
<!-- Stats Bar -->
<div class="stats-bar"> <div class="stats-bar">
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ sager|length }}</div> <div class="stat-value">{{ sager|length }}</div>
@ -387,7 +410,6 @@
</div> </div>
</div> </div>
<!-- Search & Filters -->
<div class="search-bar"> <div class="search-bar">
<input type="text" <input type="text"
class="form-control" class="form-control"
@ -396,6 +418,16 @@
autocomplete="off"> autocomplete="off">
</div> </div>
<div class="top-controls-actions">
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
<i class="bi bi-plus-lg me-2"></i>Ny Sag
</button>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-3 mb-3"> <div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="filter-pills"> <div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div> <div class="filter-pill active" data-filter="all">Alle</div>
@ -481,10 +513,7 @@
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }} {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td> </td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'"> <td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div> <div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
{% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
@ -559,10 +588,7 @@
{% for rt in all_rel_types %} {% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span> <span class="relation-badge">{{ rt }}</span>
{% endfor %} {% endfor %}
<div class="sag-titel" style="display: inline;">{{ related_sag.titel }}</div> <div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
{% if related_sag.beskrivelse %}
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
{% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>

View File

@ -47,6 +47,40 @@ def _extract_create_table_block(sql: str, start_pos: int) -> str:
depth = 0 depth = 0
for idx in range(open_paren, len(sql)): for idx in range(open_paren, len(sql)):
def _migration_numeric_prefix(file_name: str) -> int | None:
match = re.match(r"^(\d+)_", file_name)
if not match:
return None
try:
return int(match.group(1))
except Exception:
return None
def _migration_sort_key(migration_path: Path) -> tuple[int, int, str]:
number = _migration_numeric_prefix(migration_path.name)
if number is None:
# Non-numbered files go last, alphabetically.
return (1, 0, migration_path.name.lower())
return (0, number, migration_path.name.lower())
def _find_duplicate_migration_numbers(file_names: list[str]) -> list[dict]:
grouped: dict[int, list[str]] = {}
for name in file_names:
number = _migration_numeric_prefix(name)
if number is None:
continue
grouped.setdefault(number, []).append(name)
duplicates = []
for number in sorted(grouped.keys()):
files = sorted(grouped[number], key=lambda n: n.lower())
if len(files) > 1:
duplicates.append({"number": number, "files": files})
return duplicates
ch = sql[idx] ch = sql[idx]
if ch == "(": if ch == "(":
depth += 1 depth += 1
@ -226,16 +260,21 @@ async def migrations_page(request: Request):
"""Render database migrations page""" """Render database migrations page"""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations" migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
migrations = [] migrations = []
migration_file_names = []
if migrations_dir.exists(): if migrations_dir.exists():
for migration_file in sorted(migrations_dir.glob("*.sql")): for migration_file in sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key):
stat = migration_file.stat() stat = migration_file.stat()
migration_file_names.append(migration_file.name)
migrations.append({ migrations.append({
"name": migration_file.name, "name": migration_file.name,
"size_kb": round(stat.st_size / 1024, 1), "size_kb": round(stat.st_size / 1024, 1),
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
"number": _migration_numeric_prefix(migration_file.name),
}) })
duplicate_numbers = _find_duplicate_migration_numbers(migration_file_names)
return templates.TemplateResponse("settings/frontend/migrations.html", { return templates.TemplateResponse("settings/frontend/migrations.html", {
"request": request, "request": request,
"title": "Database Migrationer", "title": "Database Migrationer",
@ -243,7 +282,8 @@ async def migrations_page(request: Request):
"db_user": settings.POSTGRES_USER, "db_user": settings.POSTGRES_USER,
"db_name": settings.POSTGRES_DB, "db_name": settings.POSTGRES_DB,
"db_container": "bmc-hub-postgres", "db_container": "bmc-hub-postgres",
"is_production": request.url.hostname not in ['localhost', '127.0.0.1', '0.0.0.0'] "is_production": request.url.hostname not in ['localhost', '127.0.0.1', '0.0.0.0'],
"duplicate_numbers": duplicate_numbers,
}) })
@ -255,7 +295,7 @@ class MigrationExecution(BaseModel):
def migration_statuses(): def migration_statuses():
"""Check migration files against current schema and return per-file color status.""" """Check migration files against current schema and return per-file color status."""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations" migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
files = sorted(migrations_dir.glob("*.sql")) if migrations_dir.exists() else [] files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
conn = get_db_connection() conn = get_db_connection()
try: try:

View File

@ -46,6 +46,20 @@
</div> </div>
</div> </div>
{% if duplicate_numbers and duplicate_numbers|length > 0 %}
<div class="alert alert-danger d-flex align-items-start" role="alert">
<i class="bi bi-exclamation-octagon me-2 mt-1"></i>
<div>
<strong>Advarsel:</strong> Flere migrationer deler samme nummer.
<div class="small mt-1">
{% for duplicate in duplicate_numbers %}
#{{ duplicate.number }}: {{ duplicate.files | join(', ') }}{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">

View File

@ -0,0 +1,17 @@
-- Migration 170: Default rental prices on hardware assets
-- Stores asset-level default prices used to prefill quick-rent flow.
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS rental_default_start_price DECIMAL(10,2)
CHECK (rental_default_start_price IS NULL OR rental_default_start_price >= 0),
ADD COLUMN IF NOT EXISTS rental_default_freight_price DECIMAL(10,2)
CHECK (rental_default_freight_price IS NULL OR rental_default_freight_price >= 0),
ADD COLUMN IF NOT EXISTS rental_default_preparation_price DECIMAL(10,2)
CHECK (rental_default_preparation_price IS NULL OR rental_default_preparation_price >= 0),
ADD COLUMN IF NOT EXISTS rental_default_operations_monthly_price DECIMAL(10,2)
CHECK (rental_default_operations_monthly_price IS NULL OR rental_default_operations_monthly_price >= 0);
COMMENT ON COLUMN hardware_assets.rental_default_start_price IS 'Default startup price for quick-rent orders.';
COMMENT ON COLUMN hardware_assets.rental_default_freight_price IS 'Default freight price for quick-rent orders.';
COMMENT ON COLUMN hardware_assets.rental_default_preparation_price IS 'Default preparation price for quick-rent orders.';
COMMENT ON COLUMN hardware_assets.rental_default_operations_monthly_price IS 'Default monthly operations price for quick-rent orders.';