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:
parent
8e8616c835
commit
4a52bdb5d6
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -285,6 +285,30 @@ js{% extends "shared/frontend/base.html" %}
|
||||
</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 -->
|
||||
<div class="form-section">
|
||||
<h3 class="form-section-title">📝 Noter</h3>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -407,6 +407,12 @@
|
||||
<span class="small fw-bold">Opret Sag</span>
|
||||
</a>
|
||||
</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="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
|
||||
<i class="bi bi-geo-alt text-primary"></i>
|
||||
@ -728,6 +734,99 @@
|
||||
</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 -->
|
||||
<div class="modal fade" id="ownerModal" tabindex="-1">
|
||||
<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
|
||||
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');
|
||||
|
||||
@ -285,6 +285,58 @@
|
||||
</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 -->
|
||||
<div class="form-section">
|
||||
<h3 class="form-section-title">📝 Noter</h3>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 @@
|
||||
</div>
|
||||
<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-value topbar-company-row">
|
||||
<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">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="case-tabs-topbar-value topbar-company-row">
|
||||
<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">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</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 class="case-tabs-topbar-item">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-circle-half"></i>Status</div>
|
||||
<select id="topbarStatusSelect" class="case-inline-select" onchange="saveCaseStatusFromTopbar()">
|
||||
@ -2708,19 +2788,6 @@
|
||||
<div class="row g-4">
|
||||
<!-- TREDELT-1: Relations, History, etc. -->
|
||||
<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>
|
||||
<!-- TREDELT-2: Hero, Info -->
|
||||
<div class="col-12" id="inner-center-col">
|
||||
@ -2952,10 +3019,10 @@
|
||||
<!-- Relationer (center) -->
|
||||
<div class="row 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="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"
|
||||
style="font-size:0.9rem; cursor:help;"
|
||||
data-bs-toggle="tooltip"
|
||||
@ -3072,9 +3139,9 @@
|
||||
<div class="row mb-3">
|
||||
<!-- Files -->
|
||||
<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">
|
||||
<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)">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('fileInput').click()">
|
||||
<i class="bi bi-cloud-upload"></i> Upload
|
||||
@ -5289,10 +5356,10 @@
|
||||
</script>
|
||||
|
||||
<!-- 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="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>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
|
||||
<i class="bi bi-fullscreen me-1"></i>Fuld Formular
|
||||
@ -5407,9 +5474,23 @@
|
||||
</div></div><!-- slut inner cols -->
|
||||
<div class="col-xl-4 col-lg-4" id="case-right-column">
|
||||
<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">
|
||||
<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"
|
||||
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
|
||||
title="Tilføj tag">
|
||||
@ -5429,9 +5510,9 @@
|
||||
</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">
|
||||
<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')">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
@ -5462,9 +5543,9 @@
|
||||
</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">
|
||||
<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')">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
@ -5511,9 +5592,9 @@
|
||||
</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">
|
||||
<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">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="sendCaseHardwareLabelsToPrinter()" title="Print labels for alt hardware på sagen">
|
||||
<i class="bi bi-printer"></i>
|
||||
@ -5530,14 +5611,19 @@
|
||||
</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">
|
||||
<h6 class="mb-0" style="color: var(--accent);">📈 Salgspipeline</h6>
|
||||
<button id="pipelineEditToggle" class="btn btn-sm btn-outline-primary" onclick="togglePipelineEdit()">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<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()">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pipelineCardBody" class="card-body d-none">
|
||||
<div id="pipelineViewMode">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
@ -5617,9 +5703,9 @@
|
||||
</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">
|
||||
<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">
|
||||
<i class="bi bi-telephone"></i>
|
||||
</a>
|
||||
@ -5675,9 +5761,9 @@
|
||||
</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">
|
||||
<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">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
@ -5697,9 +5783,9 @@
|
||||
</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">
|
||||
<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 class="card-body flex-grow-1 p-0" style="max-height: 220px; overflow: auto;">
|
||||
<div class="p-2 border-bottom">
|
||||
@ -10788,10 +10874,20 @@
|
||||
}
|
||||
|
||||
function togglePipelineEdit(forceEdit = null) {
|
||||
const moduleBody = document.getElementById('pipelineCardBody');
|
||||
const collapseIcon = document.getElementById('pipelineCollapseIcon');
|
||||
const view = document.getElementById('pipelineViewMode');
|
||||
const edit = document.getElementById('pipelineEditMode');
|
||||
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) {
|
||||
view.classList.add('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() {
|
||||
const select = document.getElementById('pipelineStageSelect');
|
||||
if (!select) return;
|
||||
|
||||
@ -5,13 +5,36 @@
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.search-bar {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 380px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
@ -270,11 +293,12 @@
|
||||
.stats-bar {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
margin-bottom: 0;
|
||||
padding: 0.25rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
@ -325,6 +349,16 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.top-controls-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.top-controls-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
@ -360,12 +394,31 @@
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
||||
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h1 style="margin: 0; color: var(--accent);">
|
||||
<div class="top-controls-row">
|
||||
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
|
||||
<i class="bi bi-list-check"></i>
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ sager|length }}</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
||||
<div class="stat-label">Åbne</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="searchInput"
|
||||
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
|
||||
autocomplete="off">
|
||||
</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>
|
||||
@ -375,27 +428,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ sager|length }}</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
||||
<div class="stat-label">Åbne</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="search-bar">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="searchInput"
|
||||
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
|
||||
<div class="filter-pills">
|
||||
<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 '-' }}
|
||||
</td>
|
||||
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
<div class="sag-titel">{{ sag.titel }}</div>
|
||||
{% if sag.beskrivelse %}
|
||||
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
|
||||
{% endif %}
|
||||
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
|
||||
</td>
|
||||
<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>
|
||||
@ -559,10 +588,7 @@
|
||||
{% for rt in all_rel_types %}
|
||||
<span class="relation-badge">{{ rt }}</span>
|
||||
{% endfor %}
|
||||
<div class="sag-titel" style="display: inline;">{{ related_sag.titel }}</div>
|
||||
{% if related_sag.beskrivelse %}
|
||||
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
|
||||
{% endif %}
|
||||
<div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
|
||||
</td>
|
||||
<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>
|
||||
|
||||
@ -47,6 +47,40 @@ def _extract_create_table_block(sql: str, start_pos: int) -> str:
|
||||
|
||||
depth = 0
|
||||
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]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
@ -226,16 +260,21 @@ async def migrations_page(request: Request):
|
||||
"""Render database migrations page"""
|
||||
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
|
||||
migrations = []
|
||||
migration_file_names = []
|
||||
|
||||
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()
|
||||
migration_file_names.append(migration_file.name)
|
||||
migrations.append({
|
||||
"name": migration_file.name,
|
||||
"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", {
|
||||
"request": request,
|
||||
"title": "Database Migrationer",
|
||||
@ -243,7 +282,8 @@ async def migrations_page(request: Request):
|
||||
"db_user": settings.POSTGRES_USER,
|
||||
"db_name": settings.POSTGRES_DB,
|
||||
"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():
|
||||
"""Check migration files against current schema and return per-file color status."""
|
||||
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()
|
||||
try:
|
||||
|
||||
@ -46,6 +46,20 @@
|
||||
</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="col-lg-8">
|
||||
<div class="card shadow-sm border-0">
|
||||
|
||||
17
migrations/170_hardware_default_rental_prices.sql
Normal file
17
migrations/170_hardware_default_rental_prices.sql
Normal 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.';
|
||||
Loading…
Reference in New Issue
Block a user