bmc_hub/app/modules/sag/templates/create.html
Christian 56d6d45aa2 feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00

547 lines
23 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Ny Sag - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Gradient Header for the Card */
.card-header-custom {
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
color: white;
padding: 1.5rem;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.card-custom {
border: none;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
overflow: visible; /* Changed from hidden to visible for shadows/tooltips */
}
.form-label {
font-weight: 600;
color: var(--text-primary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.form-control:focus, .form-select:focus {
border-color: #3282b8;
box-shadow: 0 0 0 0.25rem rgba(50, 130, 184, 0.25);
}
/* Search Results Dropdown */
.search-position-relative {
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, rgba(0,0,0,0.1));
border-radius: 8px;
max-height: 250px;
overflow-y: auto;
z-index: 1050;
margin-top: 5px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.search-result-item {
padding: 10px 15px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.05));
cursor: pointer;
transition: background-color 0.15s ease;
}
.search-result-item:hover {
background-color: var(--bg-hover, #f8f9fa);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
}
.search-result-meta {
font-size: 0.8rem;
color: var(--text-secondary, #6c757d);
}
/* Selected Items (Tags) */
.selected-item {
display: inline-flex;
align-items: center;
background-color: #e3f2fd;
color: #0f4c75;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
margin-right: 8px;
margin-bottom: 8px;
border: 1px solid #bbdefb;
}
.selected-item button {
background: none;
border: none;
color: #0f4c75;
margin-left: 8px;
padding: 0;
line-height: 1;
font-size: 1.1rem;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.selected-item button:hover {
opacity: 1;
}
/* Dark Mode Adjustments */
[data-bs-theme="dark"] .card-custom {
background-color: var(--bg-surface);
}
[data-bs-theme="dark"] .selected-item {
background-color: rgba(50, 130, 184, 0.2);
color: #a6d5fa;
border-color: rgba(50, 130, 184, 0.4);
}
[data-bs-theme="dark"] .selected-item button {
color: #a6d5fa;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
</div>
<div class="card-body p-4">
<!-- Notifications -->
<div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
</div>
<div id="success" class="alert alert-success d-none shadow-sm" role="alert">
<i class="bi bi-check-circle-fill me-2"></i><span id="success-text"></span>
</div>
<form id="createForm" novalidate>
<!-- Section: Basic Info -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
</div>
</div>
<div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
<!-- Contact Search -->
<div class="col-md-6">
<label class="form-label">Kontaktpersoner</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-plus"></i></span>
<input type="text" id="contactSearch" class="form-control border-start-0 ps-0" placeholder="Søg kontakt...">
</div>
<div id="contactResults" class="search-results shadow-sm d-none"></div>
</div>
<div id="selectedContacts" class="mt-2 text-wrap"></div>
</div>
<!-- Customer Search -->
<div class="col-md-6">
<label class="form-label">Kunde</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span>
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg kunde (min. 2 tegn)...">
</div>
<div id="customerResults" class="search-results shadow-sm d-none"></div>
</div>
<div id="selectedCustomer" class="mt-2 min-vh-20"></div>
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4">
<div class="col-md-4">
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="type" required>
<option value="ticket" selected>🎫 Ticket</option>
<option value="opgave">🧩 Opgave</option>
<option value="ordre">🧾 Ordre</option>
<option value="projekt">📁 Projekt</option>
<option value="service">🛠️ Service</option>
</select>
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option>
<option value="afventer">🟡 Afventer</option>
<option value="lukket">🔴 Lukket</option>
</select>
</div>
<div class="col-md-4">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig (ID)</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-badge"></i></span>
<input type="number" class="form-control border-start-0 ps-0" id="ansvarlig_bruger_id" placeholder="Bruger ID">
</div>
</div>
<div class="col-md-4">
<label for="deadline" class="form-label">Deadline</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-calendar-event"></i></span>
<input type="datetime-local" class="form-control border-start-0 ps-0" id="deadline">
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex justify-content-end gap-3 mt-5">
<a href="/sag" class="btn btn-light border px-4 fw-bold">Annuller</a>
<button type="submit" class="btn btn-primary px-5 fw-bold shadow-sm" id="submitBtn">
<span class="d-flex align-items-center">
<i class="bi bi-check-lg me-2"></i>Opret Sag
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
let selectedCustomer = null;
let selectedContacts = {};
let customerSearchTimeout;
let contactSearchTimeout;
// --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) {
beskrInput.addEventListener('input', function(e) {
document.getElementById('charCount').textContent = e.target.value.length + " tegn";
});
}
// --- Search Logic ---
function initializeSearch() {
// Customer Search
const customerInput = document.getElementById('customerSearch');
if (customerInput) {
customerInput.addEventListener('input', (e) => handleSearch(e, 'customer'));
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-position-relative')) {
const cr = document.getElementById('customerResults');
if(cr) cr.classList.add('d-none');
}
});
}
// Contact Search
const contactInput = document.getElementById('contactSearch');
if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-position-relative')) {
const cr = document.getElementById('contactResults');
if(cr) cr.classList.add('d-none');
}
});
}
}
function handleSearch(event, type) {
const query = event.target.value.trim();
const resultsId = type === 'customer' ? 'customerResults' : 'contactResults';
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
const resultsDiv = document.getElementById(resultsId);
clearTimeout(timeoutVar);
if (query.length < 2) {
resultsDiv.classList.add('d-none');
return;
}
const timeout = setTimeout(async () => {
try {
// Show loading state
resultsDiv.classList.remove('d-none');
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
const endpoint = type === 'customer' ? '/api/v1/search/customers' : '/api/v1/search/contacts';
const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`);
if (!response.ok) {
const errorText = await response.text();
resultsDiv.innerHTML = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
return;
}
const data = await response.json();
if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return;
}
if (data.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted small">Ingen fundet</div>';
} else {
resultsDiv.innerHTML = data.map(item => {
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
const meta = item.email || (type === 'customer' ? 'CVR: ' + (item.cvr_nummer || '-') : '-');
// Handle escaping for JS function call
const safeName = name.replace(/'/g, "\\'");
const fn = type === 'customer' ? `selectCustomer(${item.id}, '${safeName}')` : `selectContact(${item.id}, '${safeName}')`;
return `
<div class="search-result-item" onclick="${fn}">
<div class="search-result-name">${name}</div>
<div class="search-result-meta">${meta}</div>
</div>
`;
}).join('');
}
} catch (err) {
console.error('Search error:', err);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
}
}, 300);
if (type === 'customer') customerSearchTimeout = timeout;
else contactSearchTimeout = timeout;
}
// --- Selection Logic ---
function selectCustomer(id, name) {
selectedCustomer = { id, name };
document.getElementById('customer_id').value = id;
document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none');
renderSelections();
}
function removeCustomer() {
selectedCustomer = null;
document.getElementById('customer_id').value = '';
renderSelections();
}
async function selectContact(id, name) {
if (!selectedContacts[id]) {
selectedContacts[id] = { id, name };
}
document.getElementById('contactSearch').value = '';
document.getElementById('contactResults').classList.add('d-none');
renderSelections();
// Check for associated company (auto-select if single match)
try {
const response = await fetch(`/api/v1/contacts/${id}`);
if (response.ok) {
const data = await response.json();
if (data.companies && data.companies.length === 1) {
const company = data.companies[0];
if (!selectedCustomer) {
selectCustomer(company.id, company.name);
// Show brief notification
const successDiv = document.getElementById('success');
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = `Valgte automatisk kunde: ${company.name}`;
setTimeout(() => successDiv.classList.add('d-none'), 3000);
}
}
}
} catch (e) {
console.error("Auto-select company failed", e);
}
}
function removeContact(id) {
delete selectedContacts[id];
renderSelections();
}
function renderSelections() {
// Customer
const custDiv = document.getElementById('selectedCustomer');
if (selectedCustomer) {
custDiv.innerHTML = `
<div class="selected-item border-primary bg-primary bg-opacity-10 text-primary">
<i class="bi bi-building me-2"></i>${selectedCustomer.name}
<button type="button" onclick="removeCustomer()" class="text-primary hover-opacity"><i class="bi bi-x"></i></button>
</div>`;
} else {
custDiv.innerHTML = '';
}
// Contacts
const contDiv = document.getElementById('selectedContacts');
contDiv.innerHTML = Object.values(selectedContacts).map(c => `
<div class="selected-item">
<i class="bi bi-person me-2"></i>${c.name}
<button type="button" onclick="removeContact(${c.id})"><i class="bi bi-x"></i></button>
</div>
`).join('');
}
async function loadCaseTypesSelect() {
const select = document.getElementById('type');
if (!select) return;
try {
const res = await fetch('/api/v1/settings/case_types');
if (!res.ok) return;
const setting = await res.json();
const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return;
select.innerHTML = types
.map((type) => `<option value="${type}">${type}</option>`)
.join('');
} catch (err) {
console.error('Failed to load case types', err);
}
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initializeSearch();
loadCaseTypesSelect();
});
// --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
// UI Reset
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const btn = document.getElementById('submitBtn');
errorDiv.classList.add('d-none');
successDiv.classList.add('d-none');
// Basic Validation
const titelInput = document.getElementById('titel');
const titel = titelInput.value;
const status = document.getElementById('status').value;
if (!titel.trim()) {
titelInput.classList.add('is-invalid');
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = "Titel er påkrævet";
return;
} else {
titelInput.classList.remove('is-invalid');
}
// Loading State
btn.disabled = true;
const originalBtnText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
const data = {
titel: titel,
beskrivelse: document.getElementById('beskrivelse').value || '',
type: document.getElementById('type').value,
status: status,
customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
created_by_user_id: 1, // HARDCODED for now, should come from auth
deadline: document.getElementById('deadline').value || null
};
try {
const response = await fetch('/api/v1/sag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
const result = await response.json();
// Add contacts if any
const contactPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'})
})
);
await Promise.all(contactPromises);
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
setTimeout(() => {
window.location.href = `/sag/${result.id}`;
}, 1000);
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
try {
const json = JSON.parse(errorText);
errMsg = json.detail || errMsg;
} catch(e) {}
throw new Error(errMsg);
}
} catch (err) {
console.error('Submit error:', err);
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
btn.disabled = false;
btn.innerHTML = originalBtnText;
}
});
</script>
{% endblock %}