feat: add conversational RollCalc assistant
This commit is contained in:
+221
-29
@@ -43,6 +43,9 @@
|
||||
.btn-calc:hover { background: #1F5438; }
|
||||
.btn-reset { background: #e0e0e0; color: #444; border: none; border-radius: 6px; padding: 11px 20px; font-size: 14px; cursor: pointer; margin-left: 10px; }
|
||||
.btn-reset:hover { background: #d0d0d0; }
|
||||
.btn-pdf { background: #1F5438; color: #fff; border: none; border-radius: 5px; padding: 9px 16px; font-size: 12px; font-weight: 700; cursor: pointer; margin-top: 13px; }
|
||||
.btn-pdf:hover:not(:disabled) { background: #153c28; }
|
||||
.btn-pdf:disabled { cursor: not-allowed; opacity: 0.48; }
|
||||
.result-box { background: linear-gradient(135deg, #D6EBE2 0%, #8CC9AB 100%); border: 2px solid #2BAC70; border-radius: 8px; padding: 16px 20px; margin-bottom: 18px; display: none; }
|
||||
.result-box.show { display: block; }
|
||||
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
|
||||
@@ -469,7 +472,7 @@
|
||||
|
||||
<!-- DIRECT CALCULATION TAB -->
|
||||
<div class="tab-pane active" id="tab-direct">
|
||||
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Product Category</label>
|
||||
@@ -509,10 +512,9 @@
|
||||
<span class="unit-label">mm</span>
|
||||
</div>
|
||||
<div class="core-presets">
|
||||
<button class="core-preset-btn" data-core="133">133mm Stahl</button>
|
||||
<button class="core-preset-btn" data-core="150">150mm PVC</button>
|
||||
<button class="core-preset-btn" data-core="168">168mm PVC</button>
|
||||
<button class="core-preset-btn" data-core="194">194mm Stahl</button>
|
||||
{% for preset in core_presets %}
|
||||
<button class="core-preset-btn" data-core="{{ preset.ui_diameter }}" data-core-material="{{ preset.material }}">{{ preset.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -613,6 +615,7 @@
|
||||
<div class="result-label" id="d-res-label">Result</div>
|
||||
<div class="result-value" id="d-res-val">–</div>
|
||||
<div class="result-range" id="d-res-range"></div>
|
||||
<button class="btn-pdf" id="d-pdf" type="button" disabled>Download PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="formula-box">
|
||||
@@ -880,6 +883,8 @@
|
||||
// =====================================================
|
||||
window.ARTICLE_DATA = [];
|
||||
window.APP_CONFIG = {"forklift_rules":{"applicable_categories":["bentofix"],"rules":[{"weight_threshold_kg":1700,"max_weight_kg":2750,"requirements":{"min_core_outer_diameter_mm":170},"messages":{"en":{"warning_title":"Heavy Roll – Special Equipment Required","core_ok":"Core diameter OK (≥ 170 mm)","core_nok":"Core diameter too small"}}}],"hard_limit":{"max_weight_kg":2750,"messages":{"en":{"block_title":"Weight Limit Exceeded","block_text":"Roll weight exceeds 2750 kg maximum"}}}}};
|
||||
window.CURRENT_ROLL_REQUEST = null;
|
||||
window.CURRENT_ROLL_RESULT = null;
|
||||
|
||||
// =====================================================
|
||||
// LOAD ARTICLE DATA
|
||||
@@ -936,6 +941,7 @@ function populateArticleDropdowns() {
|
||||
inp.value = val;
|
||||
pane.querySelectorAll('.core-preset-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (pane.id === 'tab-direct') invalidatePdfReport();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -982,7 +988,102 @@ document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
// =====================================================
|
||||
// DIRECT CALCULATION (WITH 2σ)
|
||||
// =====================================================
|
||||
document.getElementById('d-calc').addEventListener('click', () => {
|
||||
function currentArticle() {
|
||||
const rawValue = document.getElementById('d-art').value.trim();
|
||||
if (!rawValue) return null;
|
||||
const match = (window.ARTICLE_DATA || []).find(article =>
|
||||
rawValue === article.nr || rawValue.startsWith(article.nr + ' –')
|
||||
);
|
||||
if (match) return { number: String(match.nr), name: match.name || '' };
|
||||
const separator = rawValue.indexOf(' – ');
|
||||
if (separator >= 0) {
|
||||
return { number: rawValue.slice(0, separator), name: rawValue.slice(separator + 3) };
|
||||
}
|
||||
return { number: rawValue, name: '' };
|
||||
}
|
||||
|
||||
function currentCoreType(coreDiameter) {
|
||||
const activePreset = document.querySelector('#tab-direct .core-preset-btn.active');
|
||||
if (activePreset && Number(activePreset.dataset.core) === coreDiameter) return activePreset.textContent.trim();
|
||||
return 'Custom / not specified';
|
||||
}
|
||||
|
||||
function invalidatePdfReport() {
|
||||
window.CURRENT_ROLL_REQUEST = null;
|
||||
window.CURRENT_ROLL_RESULT = null;
|
||||
const button = document.getElementById('d-pdf');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Download PDF';
|
||||
}
|
||||
|
||||
function numericInput(id) {
|
||||
const raw = document.getElementById(id).value.trim();
|
||||
return raw === '' ? null : Number(raw);
|
||||
}
|
||||
|
||||
function buildCalculationRequest() {
|
||||
const article = currentArticle();
|
||||
const width = numericInput('d-wi');
|
||||
const areaWeight = numericInput('d-aw');
|
||||
return {
|
||||
intent: 'calculate_roll_diameter',
|
||||
article_number: article?.number || null,
|
||||
article_name_hint: article?.name || null,
|
||||
roll_length_m: numericInput('d-le'),
|
||||
width_m: width,
|
||||
thickness_mm: numericInput('d-th'),
|
||||
thickness_stddev_mm: numericInput('d-tol'),
|
||||
area_weight_g_m2: areaWeight,
|
||||
core_diameter_mm: numericInput('d-co'),
|
||||
core_type: currentCoreType(numericInput('d-co')),
|
||||
category: document.getElementById('d-cat').value || null,
|
||||
production_site: document.getElementById('d-site').value || null,
|
||||
include_roll_weight: width !== null && areaWeight !== null
|
||||
};
|
||||
}
|
||||
|
||||
function calculationIssueMessage(result) {
|
||||
if (result.status === 'article_not_found') return 'Article not found.';
|
||||
if (result.status === 'article_conflict') return 'Article number and name conflict.';
|
||||
if (result.status === 'needs_clarification') {
|
||||
const fields = [...(result.missing || []), ...(result.ambiguous || [])];
|
||||
return 'Please clarify: ' + fields.join(', ');
|
||||
}
|
||||
const issue = result.invalid?.[0]?.message;
|
||||
return issue ? 'Invalid calculation request: ' + issue : 'Calculation failed.';
|
||||
}
|
||||
|
||||
function renderAuthoritativeForkliftResult(result) {
|
||||
const box = document.getElementById('forkliftCheck');
|
||||
const titleEl = document.getElementById('forkliftCheckTitle');
|
||||
const bodyEl = document.getElementById('forkliftCheckBody');
|
||||
const warning = result.warnings?.[0];
|
||||
const note = result.notes?.[0];
|
||||
if (warning) {
|
||||
box.className = 'forklift-check ' + (warning.severity === 'critical' ? 'fc-block' : 'fc-warn') + ' show';
|
||||
titleEl.textContent = '⚠️ ' + warning.title;
|
||||
bodyEl.textContent = warning.message;
|
||||
} else if (note) {
|
||||
box.className = 'forklift-check fc-ok show';
|
||||
titleEl.textContent = '✓ Forklift Check OK';
|
||||
bodyEl.textContent = note;
|
||||
} else {
|
||||
box.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRollCalculation(calculationRequest) {
|
||||
const response = await fetch('/api/calculations/roll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(calculationRequest)
|
||||
});
|
||||
if (!response.ok) throw new Error('Calculation service failed');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
document.getElementById('d-calc').addEventListener('click', async () => {
|
||||
invalidatePdfReport();
|
||||
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
|
||||
const d = parseFloat(document.getElementById('d-co').value) || 0;
|
||||
const t = parseFloat(document.getElementById('d-th').value) || 0;
|
||||
@@ -995,20 +1096,45 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
||||
const resLabel = document.getElementById('d-res-label');
|
||||
const resVal = document.getElementById('d-res-val');
|
||||
const resRange = document.getElementById('d-res-range');
|
||||
let diameterResult = null;
|
||||
let authoritativeResult = null;
|
||||
let calculationRequest = null;
|
||||
|
||||
if (mode === 'diameter') {
|
||||
if (!d || !t || !L) { alert('Please enter d, t, and L.'); return; }
|
||||
const D_nom = Math.sqrt(d*d + (4*L*1000*t)/Math.PI);
|
||||
calculationRequest = buildCalculationRequest();
|
||||
try {
|
||||
authoritativeResult = await requestRollCalculation(calculationRequest);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
if (authoritativeResult.status !== 'success') {
|
||||
alert(calculationIssueMessage(authoritativeResult));
|
||||
return;
|
||||
}
|
||||
const calculation = authoritativeResult.calculation;
|
||||
diameterResult = {
|
||||
minimum: calculation.minimum_diameter_mm,
|
||||
average: calculation.average_diameter_mm,
|
||||
maximum: calculation.maximum_diameter_mm,
|
||||
minus2Sigma: calculation.minimum_diameter_mm,
|
||||
plus2Sigma: calculation.maximum_diameter_mm,
|
||||
rollLength: calculation.effective_roll_length_m
|
||||
};
|
||||
resLabel.textContent = 'Roll Diameter (D)';
|
||||
resVal.textContent = D_nom.toFixed(1) + ' mm';
|
||||
resVal.textContent = diameterResult.average.toFixed(1) + ' mm';
|
||||
if (tol) {
|
||||
const D_minus2sigma = Math.sqrt(d*d + (4*L*1000*(t-2*tol))/Math.PI);
|
||||
const D_plus2sigma = Math.sqrt(d*d + (4*L*1000*(t+2*tol))/Math.PI);
|
||||
resRange.textContent = '−2σ: ' + D_minus2sigma.toFixed(1) + ' mm | +2σ: ' + D_plus2sigma.toFixed(1) + ' mm';
|
||||
resRange.textContent = '−2σ: ' + diameterResult.minus2Sigma.toFixed(1) + ' mm | +2σ: ' + diameterResult.plus2Sigma.toFixed(1) + ' mm';
|
||||
} else {
|
||||
resRange.textContent = '';
|
||||
}
|
||||
document.getElementById('d-di').value = D_nom.toFixed(1);
|
||||
document.getElementById('d-di').value = diameterResult.average.toFixed(1);
|
||||
if (authoritativeResult.effective_inputs.width_m?.source === 'article_master_data') {
|
||||
document.getElementById('d-wi').value = authoritativeResult.effective_inputs.width_m.value;
|
||||
}
|
||||
const authoritativeWeight = calculation.roll_weight_kg;
|
||||
document.getElementById('d-wo').value = authoritativeWeight === null ? '' : authoritativeWeight.toFixed(1);
|
||||
renderAuthoritativeForkliftResult(authoritativeResult);
|
||||
} else if (mode === 'length') {
|
||||
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; }
|
||||
const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000;
|
||||
@@ -1074,17 +1200,24 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
||||
document.getElementById('d-wo').value = W_result.toFixed(1);
|
||||
}
|
||||
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||
const weight = aw * L_final * wi / 1000;
|
||||
document.getElementById('d-wo').value = weight.toFixed(1);
|
||||
checkForklift(weight, d, category);
|
||||
} else {
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
if (mode !== 'diameter') {
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||
const rollWeight = aw * L_final * wi / 1000;
|
||||
document.getElementById('d-wo').value = rollWeight.toFixed(1);
|
||||
checkForklift(rollWeight, d, category);
|
||||
} else {
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'diameter' && authoritativeResult) {
|
||||
window.CURRENT_ROLL_REQUEST = calculationRequest;
|
||||
window.CURRENT_ROLL_RESULT = authoritativeResult;
|
||||
document.getElementById('d-pdf').disabled = false;
|
||||
}
|
||||
resultBox.style.display = 'block';
|
||||
});
|
||||
|
||||
@@ -1096,6 +1229,7 @@ document.getElementById('d-reset').addEventListener('click', () => {
|
||||
document.getElementById('d-art').value = '';
|
||||
document.getElementById('d-result').style.display = 'none';
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
invalidatePdfReport();
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
@@ -1141,33 +1275,91 @@ function checkForklift(weightKg, coreMm, category) {
|
||||
const cfg = window.APP_CONFIG?.forklift_rules;
|
||||
if (!cfg || !cfg.applicable_categories.includes(category.toLowerCase())) {
|
||||
box.classList.remove('show');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = cfg.rules[0];
|
||||
const limit = cfg.hard_limit;
|
||||
|
||||
if (weightKg > limit.max_weight_kg) {
|
||||
const message = limit.messages.en.block_text.replace('{weight}', weightKg);
|
||||
box.className = 'forklift-check fc-block show';
|
||||
titleEl.textContent = '⚠️ ' + limit.messages.en.block_title;
|
||||
bodyEl.innerHTML = '<p>' + limit.messages.en.block_text.replace('{weight}', weightKg) + '</p>';
|
||||
return;
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return {
|
||||
kind: 'warning',
|
||||
severity: 'critical',
|
||||
title: limit.messages.en.block_title,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
if (weightKg >= rule.weight_threshold_kg) {
|
||||
const coreOk = coreMm >= rule.requirements.min_core_outer_diameter_mm;
|
||||
const coreMessage = coreOk
|
||||
? 'Core diameter OK'
|
||||
: 'Core diameter ' + coreMm + ' mm (min 170 mm required)';
|
||||
const message = 'Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg. ' + coreMessage + '.';
|
||||
box.className = 'forklift-check fc-warn show';
|
||||
titleEl.textContent = '⚠️ ' + rule.messages.en.warning_title;
|
||||
const statusMsg = coreOk ? '<li class="fc-req-ok">✓ Core diameter OK</li>' : '<li class="fc-req-nok">✗ Core diameter ' + coreMm + ' mm (min 170 mm required)</li>';
|
||||
bodyEl.innerHTML = '<p>Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg:</p><ul>' + statusMsg + '</ul>';
|
||||
return;
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return {
|
||||
kind: 'warning',
|
||||
severity: 'warning',
|
||||
title: rule.messages.en.warning_title,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
const message = 'Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.';
|
||||
box.className = 'forklift-check fc-ok show';
|
||||
titleEl.textContent = '✓ Forklift Check OK';
|
||||
bodyEl.innerHTML = '<p>Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.</p>';
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return { kind: 'note', message };
|
||||
}
|
||||
|
||||
document.getElementById('tab-direct').addEventListener('input', event => {
|
||||
if (event.target.id !== 'd-wo') invalidatePdfReport();
|
||||
});
|
||||
document.getElementById('tab-direct').addEventListener('change', invalidatePdfReport);
|
||||
document.querySelectorAll('.mode-btn').forEach(button => button.addEventListener('click', invalidatePdfReport));
|
||||
|
||||
document.getElementById('d-pdf').addEventListener('click', async () => {
|
||||
const button = document.getElementById('d-pdf');
|
||||
if (!window.CURRENT_ROLL_REQUEST || button.disabled) return;
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Creating PDF…';
|
||||
try {
|
||||
const response = await fetch('/api/reports/roll-calculation.pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(window.CURRENT_ROLL_REQUEST)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || 'PDF generation failed');
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i);
|
||||
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1].replace(/"$/, '')) : 'roll-calculation.pdf';
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
} finally {
|
||||
button.disabled = !window.CURRENT_ROLL_REQUEST;
|
||||
button.textContent = 'Download PDF';
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// LOAD OPTIMIZER CORE
|
||||
// =====================================================
|
||||
|
||||
Reference in New Issue
Block a user