198 lines
14 KiB
JavaScript
198 lines
14 KiB
JavaScript
/* All intake sources prefill the same reviewed form; no automatic hardware writes. */
|
|
function newHardwareIntakeRequestId() {
|
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, token => {
|
|
const value = Math.floor(Math.random() * 16);
|
|
return (token === 'x' ? value : (value & 3) | 8).toString(16);
|
|
});
|
|
}
|
|
const hardwareIntake = {mode: 'manual', requestId: newHardwareIntakeRequestId(), preview: null, line: null, code: null, productId: null, products: [], generation: 0};
|
|
const intakeEl = id => document.getElementById(id);
|
|
const intakeEscape = value => String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
const intakeMessage = (message, error = false) => {
|
|
intakeEl('intake-message').textContent = message;
|
|
intakeEl('intake-message').className = error ? 'small text-danger' : 'small text-muted';
|
|
};
|
|
async function intakeFetch(url, options = {}) {
|
|
const response = await fetch(url, options);
|
|
if (!response.ok) {
|
|
let message = 'Kunne ikke hente oplysninger';
|
|
try { const error = await response.json(); message = typeof error.detail === 'string' ? error.detail : message; } catch {}
|
|
throw new Error(message);
|
|
}
|
|
return response.json();
|
|
}
|
|
function setHardwareIntakeMode(mode) {
|
|
hardwareIntake.inventory = null;
|
|
intakeEl('intake-inventory-panel').hidden = true;
|
|
hardwareIntake.mode = mode;
|
|
hardwareIntake.preview = null;
|
|
hardwareIntake.line = null;
|
|
hardwareIntake.code = null;
|
|
hardwareIntake.productId = null;
|
|
hardwareIntake.generation++;
|
|
for (const name of ['manual', 'invoice', 'barcode']) {
|
|
intakeEl(`intake-mode-${name}`).className = name === mode ? 'btn btn-primary' : 'btn btn-outline-primary';
|
|
}
|
|
intakeEl('intake-invoice-panel').hidden = mode !== 'invoice';
|
|
intakeEl('intake-barcode-panel').hidden = mode !== 'barcode';
|
|
intakeEl('intake-invoice-lines').innerHTML = '';
|
|
intakeMessage(mode === 'manual' ? 'Udfyld formularen manuelt. Intet oprettes, før du gemmer.' : 'Vælg et grundlag. Kontrollér derefter hardwaretype, ejer og asset-type i formularen.');
|
|
if (mode === 'invoice') findHardwareInvoiceSources();
|
|
if (mode === 'barcode') intakeEl('intake-code').focus();
|
|
}
|
|
async function findHardwareInvoiceSources() {
|
|
const version = ++hardwareIntake.generation;
|
|
intakeMessage('Henter leverandørfakturaer…');
|
|
try {
|
|
const data = await intakeFetch(`/api/v1/hardware-intake/sources?q=${encodeURIComponent(intakeEl('intake-invoice-search').value)}`);
|
|
if (version !== hardwareIntake.generation) return;
|
|
intakeEl('intake-invoice-results').innerHTML = data.sources.length ? data.sources.map(source => `<button type="button" class="list-group-item list-group-item-action" onclick="previewHardwareInvoice('${source.kind === 'invoice' ? 'invoice' : 'file'}',${Number(source.id)})"><span class="badge bg-light text-dark me-2">${source.kind === 'invoice' ? 'Faktura' : 'Fil'}</span>${intakeEscape(source.label)}</button>`).join('') : '<div class="text-muted p-2">Ingen fakturaer fundet. Upload en faktura ovenfor.</div>';
|
|
intakeMessage('Vælg en faktura eller upload en PDF/billedfil.');
|
|
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message, true); }
|
|
}
|
|
async function previewHardwareInvoice(kind, id) {
|
|
const version = ++hardwareIntake.generation;
|
|
hardwareIntake.preview = null; hardwareIntake.line = null;
|
|
intakeMessage('Læser fakturalinjer. Tekstgenkendelse kan tage lidt tid…');
|
|
try {
|
|
const data = await intakeFetch('/api/v1/hardware-intake/preview', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind,id})});
|
|
if (version === hardwareIntake.generation) showHardwareInvoicePreview(data);
|
|
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message, true); }
|
|
}
|
|
async function uploadHardwareInvoice() {
|
|
const file = intakeEl('intake-upload').files[0];
|
|
if (!file) return;
|
|
const version = ++hardwareIntake.generation;
|
|
hardwareIntake.preview = null; hardwareIntake.line = null;
|
|
const body = new FormData(); body.append('file', file);
|
|
intakeMessage('Uploader og læser fakturaen. Vent på linjerne, før du vælger hardware…');
|
|
try {
|
|
const data = await intakeFetch('/api/v1/hardware-intake/upload', {method:'POST', body});
|
|
if (version === hardwareIntake.generation) showHardwareInvoicePreview(data);
|
|
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message, true); }
|
|
}
|
|
function showHardwareInvoicePreview(data) {
|
|
hardwareIntake.preview = data;
|
|
const label = [data.header.vendor_name, data.header.invoice_number || data.header.filename].filter(Boolean).join(' · ');
|
|
intakeEl('intake-invoice-lines').innerHTML = `<h3 class="h6">${intakeEscape(label || 'Fakturalinjer')}</h3><div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Vare</th><th>Antal</th><th>Enhedspris ekskl. moms</th><th></th></tr></thead><tbody>${data.lines.map((line,index) => `<tr><td>${intakeEscape(line.description)}<div class="small text-muted">${intakeEscape(line.sku || '')}</div></td><td>${intakeEscape(line.quantity ?? 'Ukendt')}${data.created_units?.[line.key]?.length ? `<div class="small text-muted">${data.created_units[line.key].map(id => `<a href="/hardware/${Number(id)}" target="_blank" rel="noopener">#${Number(id)}</a>`).join(', ')} allerede oprettet</div>` : ''}</td><td>${intakeEscape(line.unit_price ?? 'Ukendt')} ${intakeEscape(data.header.currency || '')}</td><td><button type="button" class="btn btn-sm btn-outline-primary" onclick="chooseHardwareInvoiceLine(${index})">Brug linje</button></td></tr>`).join('')}</tbody></table></div>`;
|
|
intakeMessage(data.lines.length ? 'Vælg kun linjer med fysisk hardware. Kontrollér udlæste oplysninger før oprettelse.' : 'Ingen varelinjer kunne udlæses. Vælg en anden faktura eller opret manuelt.', !data.lines.length);
|
|
}
|
|
function chooseHardwareInvoiceLine(index) {
|
|
const line = hardwareIntake.preview?.lines[index];
|
|
const count = Number(line?.quantity);
|
|
const remaining = count - (hardwareIntake.preview?.created_units?.[line?.key]?.length || 0);
|
|
if (!line || !Number.isInteger(count) || count < 1) { intakeMessage('Linjen mangler et positivt helt antal. Afklar fakturagrundlaget før oprettelse.', true); return; }
|
|
if (remaining < 1) { intakeMessage('Alle enheder på linjen er allerede oprettet. Se de tilknyttede hardwareposter.', true); return; }
|
|
hardwareIntake.line = line;
|
|
intakeEl('model').value = line.description;
|
|
intakeEl('brand').value = '';
|
|
intakeEl('intake-count').value = Math.min(remaining,100);
|
|
intakeEl('intake-unit-cost').value = line.unit_price ?? '';
|
|
intakeEl('intake-currency').value = hardwareIntake.preview.header.currency || 'DKK';
|
|
updateHardwareIntakeQuantity();
|
|
intakeMessage(`Valgt: ${line.description}. Kontrollér antal, serienumre og ejer i formularen nedenfor. Fakturaen bogføres ikke af hardwareoprettelsen.`);
|
|
intakeEl('asset_type').focus();
|
|
}
|
|
async function lookupHardwareCode() {
|
|
const code = intakeEl('intake-code').value.trim();
|
|
if (!code) { intakeMessage('Scan eller indtast en kode først.', true); return; }
|
|
const version = ++hardwareIntake.generation;
|
|
hardwareIntake.code = null; hardwareIntake.productId = null;
|
|
intakeMessage('Slår koden op i varekartoteket…');
|
|
try {
|
|
const local = await intakeFetch(`/api/v1/products?q=${encodeURIComponent(code)}`);
|
|
let products = (Array.isArray(local) ? local : local.products || []).map(product => ({...product, intakeLocal:true}));
|
|
if (!products.length) {
|
|
intakeMessage('Ingen lokal vare fundet. Søger via API Gateway…');
|
|
const external = await intakeFetch(`/api/v1/products/apigateway/search?q=${encodeURIComponent(code)}&per_page=10`);
|
|
products = external.products || external.items || [];
|
|
}
|
|
if (version !== hardwareIntake.generation) return;
|
|
hardwareIntake.products = products.slice(0,15);
|
|
hardwareIntake.lookupCode = code;
|
|
intakeEl('intake-product-results').innerHTML = hardwareIntake.products.length ? hardwareIntake.products.map((product,index) => `<button type="button" class="list-group-item list-group-item-action" onclick="chooseHardwareProduct(${index})"><strong>${intakeEscape(product.name || product.product_name || product.model || '(Vare uden navn)')}</strong><div class="small text-muted">${intakeEscape(product.ean || '')} · ${intakeEscape(product.sku_internal || product.sku || '')}</div></button>`).join('') : '<div class="text-muted p-2">Ingen vare fundet. Prøv et varenummer, eller vælg 100 % manuelt.</div>';
|
|
intakeMessage('Vælg den rigtige vare. Et EAN identificerer varetypen; hver maskine får sit eget serienummer.');
|
|
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message + ' Du kan stadig vælge 100 % manuelt.', true); }
|
|
}
|
|
function chooseHardwareProduct(index) {
|
|
const product = hardwareIntake.products[index];
|
|
if (!product) return;
|
|
hardwareIntake.code = hardwareIntake.lookupCode;
|
|
hardwareIntake.productId = product.intakeLocal ? product.id : null;
|
|
intakeEl('model').value = product.name || product.product_name || product.model || '';
|
|
const brand = product.manufacturer || product.brand || '';
|
|
intakeEl('brand').value = typeof brand === 'string' ? brand : brand.name || '';
|
|
intakeEl('intake-unit-cost').value = product.supplier_price ?? product.cost_price ?? product.price ?? '';
|
|
intakeEl('intake-currency').value = product.cost_currency || product.supplier_currency || product.currency || 'DKK';
|
|
intakeMessage(`Valgt vare: ${intakeEl('model').value}. Kontrollér oplysningerne og vælg hardwaretype og ejer før gemning.`);
|
|
intakeEl('asset_type').focus();
|
|
}
|
|
function updateHardwareIntakeQuantity() {
|
|
const count = Number(intakeEl('intake-count').value);
|
|
intakeEl('serial_number').closest('.form-group').hidden = count > 1;
|
|
intakeEl('intake-serials-group').hidden = count <= 1;
|
|
intakeEl('intake-save').textContent = count > 1 ? `Gem ${count} hardwareenheder` : 'Gem hardware';
|
|
}
|
|
function hardwareIntakePayload(hardware) {
|
|
if (hardwareIntake.mode === 'invoice' && !hardwareIntake.line) throw new Error('Vælg en fakturalinje først');
|
|
if (hardwareIntake.mode === 'barcode' && !hardwareIntake.code) throw new Error('Slå en kode op, og vælg en vare først');
|
|
const count = Number(intakeEl('intake-count').value);
|
|
const serialText = intakeEl('intake-serials').value.trim();
|
|
if (hardwareIntake.inventory) {
|
|
if (count !== 1) throw new Error('En inventory-maskine kan kun oprette ét asset');
|
|
hardware.hardware_specs = {...hardware.hardware_specs, ...hardwareIntake.inventory.hardware_specs};
|
|
}
|
|
return {request_id:hardwareIntake.requestId, mode:hardwareIntake.mode, hardware, count,
|
|
serial_numbers:count > 1 && serialText ? serialText.split(/\r?\n/) : [],
|
|
preview_id:hardwareIntake.preview?.preview_id || null, line_key:hardwareIntake.line?.key || null,
|
|
code:hardwareIntake.code, product_id:hardwareIntake.productId,
|
|
approved_unit_cost:intakeEl('intake-unit-cost').value || null, currency:intakeEl('intake-currency').value};
|
|
}
|
|
|
|
function openHardwareInventory() {
|
|
setHardwareIntakeMode('manual');
|
|
intakeEl('intake-inventory-panel').hidden = false;
|
|
findHardwareInventory();
|
|
}
|
|
async function findHardwareInventory(page = 1) {
|
|
const version = ++hardwareIntake.generation;
|
|
intakeMessage('Henter PC / Mac inventory…');
|
|
try {
|
|
const data = await intakeFetch(`/api/v1/hardware-intake/inventory?q=${encodeURIComponent(intakeEl('intake-inventory-search').value)}&by=${encodeURIComponent(intakeEl('intake-inventory-by').value || 'name')}&page=${page}`);
|
|
if (version !== hardwareIntake.generation) return;
|
|
hardwareIntake.inventoryItems = data.items;
|
|
intakeEl('intake-inventory-results').innerHTML = data.items.map((item,index) => `<button type="button" class="list-group-item list-group-item-action" onclick="chooseHardwareInventory(${index})"><strong>${intakeEscape(item.name)}</strong><div>${intakeEscape([item.brand,item.model,item.serial_number].filter(Boolean).join(' · '))}</div><div class="small text-muted">${intakeEscape([item.os,item.cpu,item.ram_gb != null ? item.ram_gb + ' GB RAM' : '',item.last_seen].filter(Boolean).join(' · '))}</div></button>`).join('') || '<div class="text-muted">Ingen maskiner fundet.</div>';
|
|
if (page > 1) intakeEl('intake-inventory-results').innerHTML += `<button type="button" class="btn btn-secondary" onclick="findHardwareInventory(${page-1})">Forrige</button>`;
|
|
if (data.pagination?.total_pages > page) intakeEl('intake-inventory-results').innerHTML += `<button type="button" class="btn btn-secondary" onclick="findHardwareInventory(${page+1})">Næste</button>`;
|
|
intakeMessage(hardwareIntake.pairHardwareId ? 'Klik på en maskine for at parre den med dette asset. Eksisterende felter bevares.' : 'Vælg en maskine. Kontrollér oplysninger, hardwaretype og ejer før gemning.');
|
|
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message,true); }
|
|
}
|
|
async function chooseHardwareInventory(index) {
|
|
const item = hardwareIntake.inventoryItems[index];
|
|
if (!item) return;
|
|
if (hardwareIntake.pairHardwareId) {
|
|
if (hardwareIntake.pairing) return;
|
|
hardwareIntake.pairing = true;
|
|
try {
|
|
await intakeFetch(`/api/v1/hardware-intake/inventory/${hardwareIntake.pairHardwareId}/pair`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({inventory_id:item.id})});
|
|
window.location.reload();
|
|
} catch (error) { intakeMessage(error.message, true); }
|
|
finally { hardwareIntake.pairing = false; }
|
|
return;
|
|
}
|
|
hardwareIntake.inventory = item;
|
|
for (const key of ['brand','model','serial_number']) intakeEl(key).value = item[key] || '';
|
|
intakeEl('asset_type').value = 'pc';
|
|
intakeEl('intake-count').value = 1;
|
|
updateHardwareIntakeQuantity();
|
|
intakeMessage(`Valgt: ${item.name}. CPU, RAM, OS og øvrige inventory-oplysninger gemmes med asset. Kontrollér ejer og hardwaretype før gemning.`);
|
|
}
|
|
|
|
async function unpairHardwareInventory() {
|
|
try {
|
|
await intakeFetch(`/api/v1/hardware-intake/inventory/${hardwareIntake.pairHardwareId}/pair`, {method:'DELETE'});
|
|
window.location.reload();
|
|
} catch (error) { intakeMessage(error.message, true); }
|
|
}
|