formlab-mcp 0.6.49 → 0.6.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/theoretical.js +81 -0
- package/tools/formulations.js +21 -1
- package/tools/ingredients.js +5 -1
- package/tools/lab.js +3 -1
- package/vendor/README.md +18 -0
- package/vendor/cost.js +2619 -0
- package/vendor/nesting.js +323 -0
- package/vendor/theoretical-params.js +1559 -0
package/vendor/cost.js
ADDED
|
@@ -0,0 +1,2619 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// COST — per-ingredient pricing, currency conversion, formulation
|
|
3
|
+
// cost rollup. The shape of ing.cost is:
|
|
4
|
+
// { value: number, currency: 'USD', unit: 'kg', source: 'manual'
|
|
5
|
+
// | 'csv' | 'erp', updatedAt: ISO8601 }
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
const COST_CURRENCIES = [
|
|
9
|
+
{ code: 'USD', symbol: '$', label: 'US Dollar' },
|
|
10
|
+
{ code: 'EUR', symbol: '€', label: 'Euro' },
|
|
11
|
+
{ code: 'GBP', symbol: '£', label: 'British Pound' },
|
|
12
|
+
{ code: 'JPY', symbol: '¥', label: 'Japanese Yen' },
|
|
13
|
+
{ code: 'CNY', symbol: '¥', label: 'Chinese Yuan' },
|
|
14
|
+
{ code: 'CAD', symbol: 'C$', label: 'Canadian Dollar' },
|
|
15
|
+
{ code: 'AUD', symbol: 'A$', label: 'Australian Dollar' },
|
|
16
|
+
{ code: 'CHF', symbol: 'Fr', label: 'Swiss Franc' },
|
|
17
|
+
{ code: 'INR', symbol: '₹', label: 'Indian Rupee' },
|
|
18
|
+
{ code: 'BRL', symbol: 'R$', label: 'Brazilian Real' },
|
|
19
|
+
// Asia-Pacific + Mexico (2026-09-09): all four are ECB reference currencies,
|
|
20
|
+
// so Frankfurter serves live rates for them like the rest.
|
|
21
|
+
{ code: 'SGD', symbol: 'S$', label: 'Singapore Dollar' },
|
|
22
|
+
{ code: 'HKD', symbol: 'HK$', label: 'Hong Kong Dollar' },
|
|
23
|
+
{ code: 'KRW', symbol: '₩', label: 'South Korean Won' },
|
|
24
|
+
{ code: 'MXN', symbol: 'MX$', label: 'Mexican Peso' },
|
|
25
|
+
// PKR is NOT an ECB reference currency, so Frankfurter (our primary FX
|
|
26
|
+
// source) doesn't price it. It's live-rated via the secondary source in
|
|
27
|
+
// _backfillMissingFxRates; see the note there. Added 2026-10-02 for a
|
|
28
|
+
// Pakistan-based compounder.
|
|
29
|
+
{ code: 'PKR', symbol: '₨', label: 'Pakistani Rupee' },
|
|
30
|
+
];
|
|
31
|
+
// Picker order: USD / EUR / GBP pinned on top, the rest alphabetical by code —
|
|
32
|
+
// so a new currency slots into place instead of landing at the end of the list.
|
|
33
|
+
(function () {
|
|
34
|
+
const pinned = ['USD', 'EUR', 'GBP'];
|
|
35
|
+
COST_CURRENCIES.sort((a, b) => {
|
|
36
|
+
const pa = pinned.indexOf(a.code), pb = pinned.indexOf(b.code);
|
|
37
|
+
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
|
|
38
|
+
return a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
|
|
39
|
+
});
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
// Built-in mass units → kg
|
|
43
|
+
const MASS_TO_KG = {
|
|
44
|
+
µg: 1e-9, ug: 1e-9, mcg: 1e-9,
|
|
45
|
+
mg: 1e-6, cg: 1e-5, dg: 1e-4, g: 0.001,
|
|
46
|
+
hg: 0.1, kg: 1,
|
|
47
|
+
t: 1000, ton: 1000, tonne: 1000,
|
|
48
|
+
lb: 0.45359237, oz: 0.0283495, grain: 6.479891e-5, stone: 6.35029,
|
|
49
|
+
};
|
|
50
|
+
// Built-in volume units → L
|
|
51
|
+
const VOL_TO_L = {
|
|
52
|
+
µL: 1e-6, uL: 1e-6,
|
|
53
|
+
mL: 0.001, cL: 0.01, dL: 0.1, L: 1,
|
|
54
|
+
m3: 1000, 'm³': 1000,
|
|
55
|
+
cm3: 0.001, 'cm³': 0.001,
|
|
56
|
+
dm3: 1, 'dm³': 1,
|
|
57
|
+
gal: 3.78541, 'gal_uk': 4.54609, pt: 0.473176, qt: 0.946353,
|
|
58
|
+
tsp: 0.00492892, tbsp: 0.0147868, cup: 0.24,
|
|
59
|
+
fl_oz: 0.0295735, 'fl oz': 0.0295735,
|
|
60
|
+
drum: 208.198, bbl: 158.987,
|
|
61
|
+
};
|
|
62
|
+
// Built-in length units → m
|
|
63
|
+
const LENGTH_TO_M = {
|
|
64
|
+
µm: 1e-6, um: 1e-6,
|
|
65
|
+
mm: 0.001, cm: 0.01, m: 1, km: 1000,
|
|
66
|
+
in: 0.0254, ft: 0.3048, yd: 0.9144, mi: 1609.34,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// === Custom-unit overlay ===
|
|
70
|
+
// Reads db.customUnits and merges with built-ins. Format:
|
|
71
|
+
// db.customUnits = {
|
|
72
|
+
// 'bag-50kg': { dimension: 'mass', factorToBase: 50 },
|
|
73
|
+
// 'pail-5gal':{ dimension: 'volume', factorToBase: 18.927 },
|
|
74
|
+
// 'each': { dimension: 'count' } // no conversion
|
|
75
|
+
// }
|
|
76
|
+
function _customMassUnits() {
|
|
77
|
+
const out = {};
|
|
78
|
+
const cu = (typeof db !== 'undefined' && db?.customUnits) || {};
|
|
79
|
+
for (const [k, v] of Object.entries(cu)) {
|
|
80
|
+
if (v?.dimension === 'mass' && isFinite(v.factorToBase) && v.factorToBase > 0) out[k] = v.factorToBase;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function _customVolUnits() {
|
|
85
|
+
const out = {};
|
|
86
|
+
const cu = (typeof db !== 'undefined' && db?.customUnits) || {};
|
|
87
|
+
for (const [k, v] of Object.entries(cu)) {
|
|
88
|
+
if (v?.dimension === 'volume' && isFinite(v.factorToBase) && v.factorToBase > 0) out[k] = v.factorToBase;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function _customLengthUnits() {
|
|
93
|
+
const out = {};
|
|
94
|
+
const cu = (typeof db !== 'undefined' && db?.customUnits) || {};
|
|
95
|
+
for (const [k, v] of Object.entries(cu)) {
|
|
96
|
+
if (v?.dimension === 'length' && isFinite(v.factorToBase) && v.factorToBase > 0) out[k] = v.factorToBase;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function getMassUnits() { return { ...MASS_TO_KG, ..._customMassUnits() }; }
|
|
101
|
+
function getVolUnits() { return { ...VOL_TO_L, ..._customVolUnits() }; }
|
|
102
|
+
function getLengthUnits() { return { ...LENGTH_TO_M, ..._customLengthUnits() }; }
|
|
103
|
+
function isMassUnit(u) { return u != null && (u in MASS_TO_KG || u in _customMassUnits()); }
|
|
104
|
+
function isVolUnit(u) { return u != null && (u in VOL_TO_L || u in _customVolUnits()); }
|
|
105
|
+
function isLengthUnit(u) { return u != null && (u in LENGTH_TO_M || u in _customLengthUnits()); }
|
|
106
|
+
function massToKg(u) { return MASS_TO_KG[u] ?? _customMassUnits()[u] ?? null; }
|
|
107
|
+
function volToL(u) { return VOL_TO_L[u] ?? _customVolUnits()[u] ?? null; }
|
|
108
|
+
function lengthToM(u) { return LENGTH_TO_M[u] ?? _customLengthUnits()[u] ?? null; }
|
|
109
|
+
|
|
110
|
+
function getCostUnits() {
|
|
111
|
+
const customCount = Object.entries((typeof db !== 'undefined' && db?.customUnits) || {})
|
|
112
|
+
.filter(([_, v]) => v?.dimension === 'count').map(([k]) => k);
|
|
113
|
+
return [
|
|
114
|
+
...Object.keys(getMassUnits()),
|
|
115
|
+
...Object.keys(getVolUnits()),
|
|
116
|
+
...Object.keys(getLengthUnits()),
|
|
117
|
+
'each',
|
|
118
|
+
...customCount,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================
|
|
123
|
+
// SHARED UNIT DATALISTS — make every unit field type-to-filter
|
|
124
|
+
// ------------------------------------------------------------
|
|
125
|
+
// A native <select> of ~30 units means scrolling to find one. Instead,
|
|
126
|
+
// unit inputs are <input list="fl-units-…"> referencing one of these
|
|
127
|
+
// shared <datalist>s: the user can just type ("ml", "lb") to filter,
|
|
128
|
+
// or open it to browse. Common units are listed first. Rebuilt on
|
|
129
|
+
// load and whenever a custom unit is added.
|
|
130
|
+
function getAllUnits() {
|
|
131
|
+
const builtin = [
|
|
132
|
+
'wt%', 'vol%',
|
|
133
|
+
...Object.keys(getMassUnits()),
|
|
134
|
+
...Object.keys(getVolUnits()),
|
|
135
|
+
...Object.keys(getLengthUnits()),
|
|
136
|
+
'ppm', 'ppb', 'eq', 'mol', 'each',
|
|
137
|
+
];
|
|
138
|
+
const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
|
|
139
|
+
return [...new Set([...builtin, ...custom])];
|
|
140
|
+
}
|
|
141
|
+
function ensureUnitDatalists() {
|
|
142
|
+
if (typeof document === 'undefined' || !document.body) return;
|
|
143
|
+
const q = (s) => String(s).replace(/"/g, '"');
|
|
144
|
+
const make = (id, units) => {
|
|
145
|
+
let dl = document.getElementById(id);
|
|
146
|
+
if (!dl) { dl = document.createElement('datalist'); dl.id = id; document.body.appendChild(dl); }
|
|
147
|
+
dl.innerHTML = units.map((u) => `<option value="${q(u)}"></option>`).join('');
|
|
148
|
+
};
|
|
149
|
+
make('fl-units-all', getAllUnits()); // composition / batch quantity
|
|
150
|
+
make('fl-units-cost', getCostUnits()); // ingredient set-price (mass/vol/length/each)
|
|
151
|
+
make('fl-units-qty', getQuantityUnits()); // physical quantity made (mass/vol/count, no fractions)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Units for a physical quantity made (sample / batch yield): mass, volume,
|
|
155
|
+
// count — NOT fractions (wt%/ppm), which only make sense in a recipe ratio.
|
|
156
|
+
// Mirrors the curated set the old <select> used (`_quantityUnitOptions`).
|
|
157
|
+
function getQuantityUnits() {
|
|
158
|
+
const mass = ['mg', 'g', 'kg', 'ton', 'lb', 'oz'];
|
|
159
|
+
const vol = ['µL', 'mL', 'L', 'tsp', 'tbsp', 'cup', 'fl_oz', 'pt', 'qt', 'gal', 'drum'];
|
|
160
|
+
const count = ['units', 'each'];
|
|
161
|
+
const custom = Object.keys((typeof db !== 'undefined' && db?.customUnits) || {});
|
|
162
|
+
return [...new Set([...mass, ...vol, ...count, ...custom])];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================
|
|
166
|
+
// Unit combobox — replaces the native <datalist> popup.
|
|
167
|
+
// ------------------------------------------------------------
|
|
168
|
+
// The native datalist dropdown mis-anchors to the top-left of the window when
|
|
169
|
+
// its <input> lives inside a position:fixed modal (a long-standing Chromium
|
|
170
|
+
// bug) — it showed up as a stray "floating fill box". So unit inputs now carry
|
|
171
|
+
// `class="fl-unit-combo" data-unit-list="<datalist id>"` (NOT `list=`), and we
|
|
172
|
+
// render our own correctly-positioned, type-to-filter suggestion list off the
|
|
173
|
+
// same datalist options. Free typing still works (custom units are allowed);
|
|
174
|
+
// the list is just suggestions. Delegated listeners cover inputs created later
|
|
175
|
+
// (modals, dynamic ingredient rows) with no per-field wiring.
|
|
176
|
+
(function initUnitCombo() {
|
|
177
|
+
if (typeof document === 'undefined') return;
|
|
178
|
+
var POP_ID = 'fl-unit-combo-pop';
|
|
179
|
+
var activeInput = null, activeIdx = -1;
|
|
180
|
+
// Show the in-popup search box once a list has more than this many units.
|
|
181
|
+
var SEARCH_THRESHOLD = 8;
|
|
182
|
+
|
|
183
|
+
function escA(s) { return String(s).replace(/"/g, '"'); }
|
|
184
|
+
function escH(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
185
|
+
function isCombo(el) { return !!(el && el.classList && el.classList.contains('fl-unit-combo')); }
|
|
186
|
+
function popEl() {
|
|
187
|
+
var p = document.getElementById(POP_ID);
|
|
188
|
+
if (!p) { p = document.createElement('div'); p.id = POP_ID; p.className = 'fl-unit-combo-pop'; p.style.display = 'none'; document.body.appendChild(p); }
|
|
189
|
+
return p;
|
|
190
|
+
}
|
|
191
|
+
function isOpen() { var p = document.getElementById(POP_ID); return !!(p && p.style.display !== 'none'); }
|
|
192
|
+
function optionsFor(input) {
|
|
193
|
+
var dl = document.getElementById(input.getAttribute('data-unit-list') || '');
|
|
194
|
+
return dl ? Array.prototype.map.call(dl.children, function (o) { return o.value; }) : [];
|
|
195
|
+
}
|
|
196
|
+
function listEl() { var p = document.getElementById(POP_ID); return p ? p.querySelector('.fl-unit-combo-list') : null; }
|
|
197
|
+
function items() { var l = listEl(); return l ? Array.prototype.slice.call(l.querySelectorAll('.fl-unit-combo-item')) : []; }
|
|
198
|
+
|
|
199
|
+
// Classify a unit into a display group. Mirrors the formula picker's grouping
|
|
200
|
+
// (Mass / Volume / Length …) and uses the same isMassUnit/isVolUnit helpers
|
|
201
|
+
// so custom units land in the right bucket too.
|
|
202
|
+
function classify(u) {
|
|
203
|
+
if (['wt%', 'vol%', '%', 'ppm', 'ppb'].indexOf(u) !== -1) return 'frac';
|
|
204
|
+
if (typeof isMassUnit === 'function' && isMassUnit(u)) return 'mass';
|
|
205
|
+
if (typeof isVolUnit === 'function' && isVolUnit(u)) return 'vol';
|
|
206
|
+
if (typeof isLengthUnit === 'function' && isLengthUnit(u)) return 'len';
|
|
207
|
+
if (['mol', 'eq', 'each', 'pcs', 'unit', 'units'].indexOf(u) !== -1) return 'amt';
|
|
208
|
+
return 'other';
|
|
209
|
+
}
|
|
210
|
+
var GROUP_ORDER = [['frac', 'Fraction'], ['mass', 'Mass'], ['vol', 'Volume'], ['len', 'Length'], ['amt', 'Amount / count'], ['other', 'Other']];
|
|
211
|
+
function groupUnits(units) {
|
|
212
|
+
var by = {};
|
|
213
|
+
units.forEach(function (u) { var k = classify(u); (by[k] = by[k] || []).push(u); });
|
|
214
|
+
return GROUP_ORDER.map(function (p) { return { label: p[1], units: by[p[0]] || [] }; }).filter(function (g) { return g.units.length; });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function position(input) {
|
|
218
|
+
var p = popEl(), r = input.getBoundingClientRect();
|
|
219
|
+
p.style.position = 'fixed';
|
|
220
|
+
p.style.minWidth = r.width + 'px';
|
|
221
|
+
p.style.top = (r.bottom + 2) + 'px';
|
|
222
|
+
p.style.left = r.left + 'px';
|
|
223
|
+
var h = p.offsetHeight;
|
|
224
|
+
if (r.bottom + 2 + h > window.innerHeight - 8) p.style.top = Math.max(8, r.top - h - 2) + 'px';
|
|
225
|
+
var w = p.offsetWidth, left = r.left;
|
|
226
|
+
if (left + w > window.innerWidth - 8) left = Math.max(8, window.innerWidth - w - 8);
|
|
227
|
+
p.style.left = left + 'px';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Re-render ONLY the grouped list for a filter query (keeps the search box —
|
|
231
|
+
// and its focus/caret — intact across keystrokes).
|
|
232
|
+
function renderList(input, query) {
|
|
233
|
+
var l = listEl(); if (!l) return;
|
|
234
|
+
var q = String(query || '').trim();
|
|
235
|
+
var ql = q.toLowerCase();
|
|
236
|
+
var all = optionsFor(input);
|
|
237
|
+
var cur = String(input.value || '').trim().toLowerCase();
|
|
238
|
+
var matches = all.filter(function (u) { return !ql || u.toLowerCase().indexOf(ql) !== -1; });
|
|
239
|
+
var html = groupUnits(matches).map(function (g) {
|
|
240
|
+
return '<div class="fl-unit-combo-group">' + escH(g.label) + '</div>' +
|
|
241
|
+
g.units.map(function (u) {
|
|
242
|
+
return '<div class="fl-unit-combo-item' + (u.toLowerCase() === cur ? ' current' : '') + '" data-val="' + escA(u) + '">' + escH(u) + '</div>';
|
|
243
|
+
}).join('');
|
|
244
|
+
}).join('');
|
|
245
|
+
// Free-typed custom unit: offer to use it verbatim when it isn't an option.
|
|
246
|
+
var exact = all.some(function (u) { return u.toLowerCase() === ql; });
|
|
247
|
+
if (q && !exact) html += '<div class="fl-unit-combo-item fl-unit-combo-custom" data-val="' + escA(q) + '">Use “' + escH(q) + '”</div>';
|
|
248
|
+
if (!html) html = '<div class="fl-unit-combo-empty">No matching units</div>';
|
|
249
|
+
l.innerHTML = html;
|
|
250
|
+
activeIdx = -1;
|
|
251
|
+
position(input);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function open(input) {
|
|
255
|
+
activeInput = input;
|
|
256
|
+
var p = popEl();
|
|
257
|
+
var showSearch = optionsFor(input).length > SEARCH_THRESHOLD;
|
|
258
|
+
p.innerHTML =
|
|
259
|
+
(showSearch ? '<div class="fl-unit-combo-searchwrap"><input type="text" class="fl-unit-combo-search" placeholder="Search units…" autocomplete="off" spellcheck="false"></div>' : '') +
|
|
260
|
+
'<div class="fl-unit-combo-list"></div>';
|
|
261
|
+
p.style.display = 'block';
|
|
262
|
+
if (showSearch) {
|
|
263
|
+
var sb = p.querySelector('.fl-unit-combo-search');
|
|
264
|
+
sb.addEventListener('input', function () { renderList(input, sb.value); });
|
|
265
|
+
sb.addEventListener('keydown', navKeydown);
|
|
266
|
+
sb.addEventListener('blur', function () {
|
|
267
|
+
setTimeout(function () {
|
|
268
|
+
var pp = document.getElementById(POP_ID);
|
|
269
|
+
if (pp && !pp.contains(document.activeElement) && document.activeElement !== input) hide();
|
|
270
|
+
}, 120);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
renderList(input, '');
|
|
274
|
+
position(input);
|
|
275
|
+
// Move focus into the search box so the user can type to filter (matches
|
|
276
|
+
// the ingredient picker). The field keeps the chosen value.
|
|
277
|
+
if (showSearch) setTimeout(function () { var sb2 = p.querySelector('.fl-unit-combo-search'); if (sb2) sb2.focus(); }, 0);
|
|
278
|
+
}
|
|
279
|
+
function hide() { var p = document.getElementById(POP_ID); if (p) p.style.display = 'none'; activeIdx = -1; }
|
|
280
|
+
function choose(val) {
|
|
281
|
+
var inp = activeInput; if (!inp) return;
|
|
282
|
+
inp.value = val;
|
|
283
|
+
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
|
284
|
+
inp.dispatchEvent(new Event('change', { bubbles: true }));
|
|
285
|
+
hide();
|
|
286
|
+
try { inp.focus(); } catch (_) {}
|
|
287
|
+
}
|
|
288
|
+
function highlight(i) {
|
|
289
|
+
var its = items(); if (!its.length) { activeIdx = -1; return; }
|
|
290
|
+
activeIdx = (i + its.length) % its.length;
|
|
291
|
+
its.forEach(function (el, idx) { el.classList.toggle('active', idx === activeIdx); });
|
|
292
|
+
var c = its[activeIdx]; if (c && c.scrollIntoView) c.scrollIntoView({ block: 'nearest' });
|
|
293
|
+
}
|
|
294
|
+
// Shared arrow/Enter/Esc handling for both the field and the search box.
|
|
295
|
+
function navKeydown(e) {
|
|
296
|
+
if (!isOpen()) { if (e.key === 'ArrowDown' && activeInput) { open(activeInput); e.preventDefault(); } return; }
|
|
297
|
+
if (e.key === 'ArrowDown') { highlight(activeIdx + 1); e.preventDefault(); }
|
|
298
|
+
else if (e.key === 'ArrowUp') { highlight(activeIdx - 1); e.preventDefault(); }
|
|
299
|
+
else if (e.key === 'Enter') {
|
|
300
|
+
var its = items();
|
|
301
|
+
var pick = activeIdx >= 0 ? its[activeIdx] : (its.length === 1 ? its[0] : null);
|
|
302
|
+
if (pick) { choose(pick.getAttribute('data-val')); e.preventDefault(); }
|
|
303
|
+
} else if (e.key === 'Escape') { hide(); if (activeInput) try { activeInput.focus(); } catch (_) {} }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
document.addEventListener('focusin', function (e) { if (isCombo(e.target)) open(e.target); });
|
|
307
|
+
// Typing directly in the field filters too (only matters when there's no
|
|
308
|
+
// search box — i.e. short lists; otherwise focus is in the search box).
|
|
309
|
+
document.addEventListener('input', function (e) {
|
|
310
|
+
if (isCombo(e.target) && isOpen() && !(document.getElementById(POP_ID) || {}).querySelector('.fl-unit-combo-search')) {
|
|
311
|
+
renderList(e.target, e.target.value);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
document.addEventListener('keydown', function (e) { if (isCombo(e.target)) navKeydown(e); });
|
|
315
|
+
// mousedown (capture) so a click on a suggestion wins over the input's blur.
|
|
316
|
+
document.addEventListener('mousedown', function (e) {
|
|
317
|
+
if (!isOpen()) return;
|
|
318
|
+
var item = e.target.closest && e.target.closest('.fl-unit-combo-item');
|
|
319
|
+
if (item) { e.preventDefault(); choose(item.getAttribute('data-val')); return; }
|
|
320
|
+
var p = document.getElementById(POP_ID);
|
|
321
|
+
if (e.target !== activeInput && !(p && p.contains(e.target))) hide();
|
|
322
|
+
}, true);
|
|
323
|
+
document.addEventListener('focusout', function (e) {
|
|
324
|
+
if (!isCombo(e.target)) return;
|
|
325
|
+
setTimeout(function () {
|
|
326
|
+
var p = document.getElementById(POP_ID);
|
|
327
|
+
if (p && p.contains(document.activeElement)) return; // focus moved into the popup (search box)
|
|
328
|
+
if (isCombo(document.activeElement)) return;
|
|
329
|
+
hide();
|
|
330
|
+
}, 120);
|
|
331
|
+
});
|
|
332
|
+
window.addEventListener('scroll', function () { if (activeInput && isOpen()) position(activeInput); }, true);
|
|
333
|
+
window.addEventListener('resize', hide);
|
|
334
|
+
})();
|
|
335
|
+
|
|
336
|
+
// Backwards-compat alias — older code paths reference COST_UNITS directly.
|
|
337
|
+
// We can't make it a live getter without breaking destructuring, so anyone
|
|
338
|
+
// hitting it gets the snapshot taken when cost.js loaded. New code should
|
|
339
|
+
// call getCostUnits() instead.
|
|
340
|
+
const COST_UNITS = [
|
|
341
|
+
...Object.keys(MASS_TO_KG),
|
|
342
|
+
...Object.keys(VOL_TO_L),
|
|
343
|
+
'each',
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
// ============================================================
|
|
347
|
+
// COST ESTIMATES — rough market prices for common ingredients,
|
|
348
|
+
// used by the "⚲ Lookup" button in the Ingredient Edit modal as
|
|
349
|
+
// a best-effort default. Values are approximate spot/bulk prices
|
|
350
|
+
// in USD and meant as a starting point — the user should verify
|
|
351
|
+
// against their actual vendor.
|
|
352
|
+
// ============================================================
|
|
353
|
+
const COST_ESTIMATES = {
|
|
354
|
+
// Food / commodity
|
|
355
|
+
'water': { value: 0.002, unit: 'L' },
|
|
356
|
+
'wheat flour': { value: 1.20, unit: 'kg' },
|
|
357
|
+
'flour': { value: 1.20, unit: 'kg' },
|
|
358
|
+
'rye flour': { value: 1.80, unit: 'kg' },
|
|
359
|
+
'rice flour': { value: 2.40, unit: 'kg' },
|
|
360
|
+
'salt': { value: 0.85, unit: 'kg' },
|
|
361
|
+
'sodium chloride': { value: 0.85, unit: 'kg' },
|
|
362
|
+
'sugar': { value: 0.90, unit: 'kg' },
|
|
363
|
+
'sucrose': { value: 0.90, unit: 'kg' },
|
|
364
|
+
'yeast': { value: 18.50, unit: 'kg' },
|
|
365
|
+
'active dry yeast': { value: 18.50, unit: 'kg' },
|
|
366
|
+
'olive oil': { value: 7.50, unit: 'L' },
|
|
367
|
+
'sunflower oil': { value: 2.20, unit: 'L' },
|
|
368
|
+
'butter': { value: 8.50, unit: 'kg' },
|
|
369
|
+
// Solvents / chemicals
|
|
370
|
+
'ethanol': { value: 0.85, unit: 'L' },
|
|
371
|
+
'methanol': { value: 0.55, unit: 'L' },
|
|
372
|
+
'isopropanol': { value: 1.50, unit: 'L' },
|
|
373
|
+
'glycerin': { value: 1.50, unit: 'kg' },
|
|
374
|
+
'glycerol': { value: 1.50, unit: 'kg' },
|
|
375
|
+
'propylene glycol': { value: 1.80, unit: 'kg' },
|
|
376
|
+
'ethylene glycol': { value: 1.20, unit: 'kg' },
|
|
377
|
+
'acetone': { value: 1.10, unit: 'L' },
|
|
378
|
+
// Industrial / formulation
|
|
379
|
+
'titanium dioxide': { value: 3.40, unit: 'kg' },
|
|
380
|
+
'calcium carbonate': { value: 0.30, unit: 'kg' },
|
|
381
|
+
'silica': { value: 1.20, unit: 'kg' },
|
|
382
|
+
'sodium hydroxide': { value: 0.80, unit: 'kg' },
|
|
383
|
+
'citric acid': { value: 1.40, unit: 'kg' },
|
|
384
|
+
'stearic acid': { value: 2.00, unit: 'kg' },
|
|
385
|
+
'lauric acid': { value: 2.50, unit: 'kg' },
|
|
386
|
+
'methylcellulose': { value: 7.50, unit: 'kg' },
|
|
387
|
+
'methyl cellulose': { value: 7.50, unit: 'kg' },
|
|
388
|
+
'acrylic latex': { value: 2.10, unit: 'kg' },
|
|
389
|
+
'acrylic latex emulsion': { value: 2.10, unit: 'kg' },
|
|
390
|
+
// Personal-care
|
|
391
|
+
'cetyl alcohol': { value: 3.50, unit: 'kg' },
|
|
392
|
+
'beeswax': { value: 25.00, unit: 'kg' },
|
|
393
|
+
'lanolin': { value: 18.00, unit: 'kg' },
|
|
394
|
+
'shea butter': { value: 12.00, unit: 'kg' },
|
|
395
|
+
// Surfactants / detergents
|
|
396
|
+
'sodium lauryl sulfate': { value: 2.00, unit: 'kg' },
|
|
397
|
+
'sls': { value: 2.00, unit: 'kg' },
|
|
398
|
+
'sodium laureth sulfate': { value: 1.80, unit: 'kg' },
|
|
399
|
+
'sles': { value: 1.80, unit: 'kg' },
|
|
400
|
+
'cocamidopropyl betaine': { value: 2.60, unit: 'kg' },
|
|
401
|
+
'linear alkylbenzene sulfonate': { value: 1.60, unit: 'kg' },
|
|
402
|
+
'alcohol ethoxylate': { value: 2.20, unit: 'kg' },
|
|
403
|
+
'nonionic surfactant': { value: 2.20, unit: 'kg' },
|
|
404
|
+
'decyl glucoside': { value: 4.00, unit: 'kg' },
|
|
405
|
+
// Thickeners / rheology / fillers
|
|
406
|
+
'hydroxyethyl cellulose': { value: 7.50, unit: 'kg' },
|
|
407
|
+
'sodium polyacrylate': { value: 2.50, unit: 'kg' },
|
|
408
|
+
'carbomer': { value: 16.00, unit: 'kg' },
|
|
409
|
+
'xanthan gum': { value: 6.00, unit: 'kg' },
|
|
410
|
+
'guar gum': { value: 3.50, unit: 'kg' },
|
|
411
|
+
'carrageenan': { value: 12.00, unit: 'kg' },
|
|
412
|
+
'bentonite': { value: 0.60, unit: 'kg' },
|
|
413
|
+
'kaolin': { value: 0.50, unit: 'kg' },
|
|
414
|
+
'talc': { value: 0.55, unit: 'kg' },
|
|
415
|
+
// Preservatives / chelants
|
|
416
|
+
'phenoxyethanol': { value: 4.00, unit: 'kg' },
|
|
417
|
+
'benzyl alcohol': { value: 2.50, unit: 'kg' },
|
|
418
|
+
'potassium sorbate': { value: 4.00, unit: 'kg' },
|
|
419
|
+
'sodium benzoate': { value: 1.80, unit: 'kg' },
|
|
420
|
+
'benzisothiazolinone': { value: 8.00, unit: 'kg' },
|
|
421
|
+
'in-can preservative': { value: 5.00, unit: 'kg' },
|
|
422
|
+
'methylparaben': { value: 5.50, unit: 'kg' },
|
|
423
|
+
'disodium edta': { value: 3.00, unit: 'kg' },
|
|
424
|
+
'edta': { value: 3.00, unit: 'kg' },
|
|
425
|
+
// Humectants / emollients / silicones
|
|
426
|
+
'pentylene glycol': { value: 8.00, unit: 'kg' },
|
|
427
|
+
'butylene glycol': { value: 4.00, unit: 'kg' },
|
|
428
|
+
'dipropylene glycol': { value: 2.50, unit: 'kg' },
|
|
429
|
+
'sorbitol': { value: 1.50, unit: 'kg' },
|
|
430
|
+
'caprylic/capric triglyceride': { value: 4.00, unit: 'kg' },
|
|
431
|
+
'dimethicone': { value: 6.00, unit: 'kg' },
|
|
432
|
+
'isopropyl myristate': { value: 3.50, unit: 'kg' },
|
|
433
|
+
'mineral oil': { value: 1.50, unit: 'L' },
|
|
434
|
+
'petrolatum': { value: 2.00, unit: 'kg' },
|
|
435
|
+
// Cosmetic actives
|
|
436
|
+
'niacinamide': { value: 15.00, unit: 'kg' },
|
|
437
|
+
'sodium hyaluronate': { value: 250.00, unit: 'kg' },
|
|
438
|
+
'hyaluronic acid': { value: 250.00, unit: 'kg' },
|
|
439
|
+
'salicylic acid': { value: 6.00, unit: 'kg' },
|
|
440
|
+
'ascorbic acid': { value: 8.00, unit: 'kg' },
|
|
441
|
+
'panthenol': { value: 15.00, unit: 'kg' },
|
|
442
|
+
'allantoin': { value: 12.00, unit: 'kg' },
|
|
443
|
+
'caffeine': { value: 15.00, unit: 'kg' },
|
|
444
|
+
'zinc oxide': { value: 3.00, unit: 'kg' },
|
|
445
|
+
// Paints / coatings
|
|
446
|
+
'styrene acrylic': { value: 2.00, unit: 'kg' },
|
|
447
|
+
'coalescent': { value: 3.50, unit: 'kg' },
|
|
448
|
+
'texanol': { value: 3.50, unit: 'kg' },
|
|
449
|
+
'defoamer': { value: 4.00, unit: 'kg' },
|
|
450
|
+
'iron oxide': { value: 2.50, unit: 'kg' },
|
|
451
|
+
'carbon black': { value: 2.00, unit: 'kg' },
|
|
452
|
+
// Solvents (industrial)
|
|
453
|
+
'toluene': { value: 1.20, unit: 'L' },
|
|
454
|
+
'xylene': { value: 1.30, unit: 'L' },
|
|
455
|
+
'mineral spirits': { value: 1.50, unit: 'L' },
|
|
456
|
+
'butyl acetate': { value: 2.00, unit: 'L' },
|
|
457
|
+
'ethyl acetate': { value: 1.50, unit: 'L' },
|
|
458
|
+
'methyl ethyl ketone': { value: 2.00, unit: 'L' },
|
|
459
|
+
'butanol': { value: 1.60, unit: 'L' },
|
|
460
|
+
// Acids / bases / salts
|
|
461
|
+
'potassium hydroxide': { value: 1.20, unit: 'kg' },
|
|
462
|
+
'hydrochloric acid': { value: 0.40, unit: 'L' },
|
|
463
|
+
'sulfuric acid': { value: 0.30, unit: 'kg' },
|
|
464
|
+
'acetic acid': { value: 0.90, unit: 'L' },
|
|
465
|
+
'lactic acid': { value: 2.00, unit: 'kg' },
|
|
466
|
+
'phosphoric acid': { value: 1.20, unit: 'kg' },
|
|
467
|
+
'ammonia': { value: 0.50, unit: 'L' },
|
|
468
|
+
'sodium carbonate': { value: 0.40, unit: 'kg' },
|
|
469
|
+
'soda ash': { value: 0.40, unit: 'kg' },
|
|
470
|
+
'sodium bicarbonate': { value: 0.50, unit: 'kg' },
|
|
471
|
+
'sodium citrate': { value: 2.00, unit: 'kg' },
|
|
472
|
+
'potassium chloride': { value: 1.00, unit: 'kg' },
|
|
473
|
+
'magnesium sulfate': { value: 0.50, unit: 'kg' },
|
|
474
|
+
// Food / beverage
|
|
475
|
+
'fructose': { value: 1.20, unit: 'kg' },
|
|
476
|
+
'dextrose': { value: 1.00, unit: 'kg' },
|
|
477
|
+
'glucose': { value: 1.00, unit: 'kg' },
|
|
478
|
+
'corn syrup': { value: 0.80, unit: 'kg' },
|
|
479
|
+
'malic acid': { value: 2.50, unit: 'kg' },
|
|
480
|
+
'monosodium glutamate': { value: 1.50, unit: 'kg' },
|
|
481
|
+
'aqua': { value: 0.002, unit: 'L' },
|
|
482
|
+
// Waxes / fats / fatty alcohols
|
|
483
|
+
'carnauba wax': { value: 10.00, unit: 'kg' },
|
|
484
|
+
'paraffin wax': { value: 1.50, unit: 'kg' },
|
|
485
|
+
'microcrystalline wax': { value: 2.50, unit: 'kg' },
|
|
486
|
+
'coconut oil': { value: 2.50, unit: 'L' },
|
|
487
|
+
'castor oil': { value: 2.50, unit: 'kg' },
|
|
488
|
+
'palm oil': { value: 1.20, unit: 'kg' },
|
|
489
|
+
'cocoa butter': { value: 9.00, unit: 'kg' },
|
|
490
|
+
'stearyl alcohol': { value: 3.50, unit: 'kg' },
|
|
491
|
+
'cetearyl alcohol': { value: 3.50, unit: 'kg' },
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// Returns { value, currency, unit, source, matchedKey } or null.
|
|
495
|
+
// Currency is always USD for now. Match is case-insensitive and tries
|
|
496
|
+
// the full name first, then any whole-word substring match.
|
|
497
|
+
function lookupIngredientCost(name) {
|
|
498
|
+
if (!name) return null;
|
|
499
|
+
const key = String(name).toLowerCase().trim();
|
|
500
|
+
if (key in COST_ESTIMATES) {
|
|
501
|
+
return { ...COST_ESTIMATES[key], currency: 'USD', source: 'estimate', matchedKey: key };
|
|
502
|
+
}
|
|
503
|
+
// Substring fallback — longest match wins so "wheat flour" beats "flour"
|
|
504
|
+
let best = null;
|
|
505
|
+
for (const [k, v] of Object.entries(COST_ESTIMATES)) {
|
|
506
|
+
if (key.includes(k) && (!best || k.length > best.key.length)) {
|
|
507
|
+
best = { key: k, ...v };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (best) return { value: best.value, unit: best.unit, currency: 'USD', source: 'estimate', matchedKey: best.key };
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Add a user-defined unit, immediately available to the cost engine.
|
|
515
|
+
function addCustomUnit(name, dimension, factorToBase) {
|
|
516
|
+
// sync-exempt: custom units aren't a per-record entity — they sync via the
|
|
517
|
+
// workspace_settings blob through window.__pro.onSettingsChanged() below.
|
|
518
|
+
if (typeof db === 'undefined') return false;
|
|
519
|
+
if (!name || !dimension) return false;
|
|
520
|
+
db.customUnits = db.customUnits || {};
|
|
521
|
+
if (dimension === 'count' || dimension === 'other') {
|
|
522
|
+
db.customUnits[name] = { dimension };
|
|
523
|
+
} else if (
|
|
524
|
+
(dimension === 'mass' || dimension === 'volume' || dimension === 'length') &&
|
|
525
|
+
isFinite(parseFloat(factorToBase)) && parseFloat(factorToBase) > 0
|
|
526
|
+
) {
|
|
527
|
+
db.customUnits[name] = { dimension, factorToBase: parseFloat(factorToBase) };
|
|
528
|
+
} else {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
if (typeof saveDB === 'function') saveDB();
|
|
532
|
+
registerCustomUnits();
|
|
533
|
+
ensureUnitDatalists(); // refresh the shared unit pickers with the new unit
|
|
534
|
+
try { if (typeof window !== 'undefined' && window.__pro && typeof window.__pro.onSettingsChanged === 'function') window.__pro.onSettingsChanged(); } catch (_) {}
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Remove a user-defined unit. Also strips it from the live conversion maps that
|
|
539
|
+
// registerCustomUnits() populates (that fn only ADDS), so the unit vanishes from
|
|
540
|
+
// pickers/conversions without a reload. Records still carrying the label keep it
|
|
541
|
+
// (they just won't convert until it's re-added). Syncs via the workspace_settings
|
|
542
|
+
// blob through onSettingsChanged() — so a removal now propagates across devices.
|
|
543
|
+
function deleteCustomUnit(name) {
|
|
544
|
+
// sync-exempt: custom units aren't a per-record entity — see addCustomUnit.
|
|
545
|
+
if (typeof db === 'undefined' || !db || !db.customUnits || !(name in db.customUnits)) return false;
|
|
546
|
+
const def = db.customUnits[name];
|
|
547
|
+
delete db.customUnits[name];
|
|
548
|
+
if (def) {
|
|
549
|
+
if (def.dimension === 'mass') delete MASS_TO_KG[name];
|
|
550
|
+
if (def.dimension === 'volume') delete VOL_TO_L[name];
|
|
551
|
+
if (def.dimension === 'length') delete LENGTH_TO_M[name];
|
|
552
|
+
}
|
|
553
|
+
if (typeof saveDB === 'function') saveDB();
|
|
554
|
+
registerCustomUnits();
|
|
555
|
+
if (typeof ensureUnitDatalists === 'function') ensureUnitDatalists();
|
|
556
|
+
try { if (typeof window !== 'undefined' && window.__pro && typeof window.__pro.onSettingsChanged === 'function') window.__pro.onSettingsChanged(); } catch (_) {}
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// How many records still reference a unit string (for the delete warning).
|
|
561
|
+
// Scans the common unit-bearing fields across the library.
|
|
562
|
+
function customUnitUsageCount(name) {
|
|
563
|
+
if (typeof db === 'undefined' || !db || !name) return 0;
|
|
564
|
+
let n = 0;
|
|
565
|
+
const eq = (u) => u === name;
|
|
566
|
+
(db.ingredients || []).forEach(i => { if (i && eq(i.cost && i.cost.unit)) n++; if (i && eq(i.densityUnit)) n++; });
|
|
567
|
+
(db.formulations || []).forEach(f => (f && f.composition || []).forEach(c => { if (eq(c && c.unit)) n++; }));
|
|
568
|
+
(db.batches || []).forEach(b => { if (b && eq(b.unit)) n++; (b && b.actualComposition || []).forEach(c => { if (eq(c && c.unit)) n++; }); });
|
|
569
|
+
(db.samples || []).forEach(s => { if (s && eq(s.unit)) n++; });
|
|
570
|
+
(db.lots || []).forEach(l => { if (l && eq(l.unit)) n++; });
|
|
571
|
+
return n;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Mutate MASS_TO_KG / VOL_TO_L to include user-defined units, so existing
|
|
575
|
+
// `unit in MASS_TO_KG` checks throughout the cost engine pick them up with
|
|
576
|
+
// no extra refactor. Idempotent — safe to call multiple times.
|
|
577
|
+
function registerCustomUnits() {
|
|
578
|
+
if (typeof db === 'undefined' || !db?.customUnits) return;
|
|
579
|
+
for (const [name, def] of Object.entries(db.customUnits)) {
|
|
580
|
+
if (!def) continue;
|
|
581
|
+
const factor = parseFloat(def.factorToBase);
|
|
582
|
+
if (def.dimension === 'mass' && isFinite(factor) && factor > 0) MASS_TO_KG[name] = factor;
|
|
583
|
+
if (def.dimension === 'volume' && isFinite(factor) && factor > 0) VOL_TO_L[name] = factor;
|
|
584
|
+
if (def.dimension === 'length' && isFinite(factor) && factor > 0) LENGTH_TO_M[name] = factor;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Returns the unit category by looking at both built-ins and custom units.
|
|
589
|
+
// 'mass-frac' | 'vol-frac' | 'mass-abs' | 'vol-abs' | 'count' | 'unknown'
|
|
590
|
+
function detectUnitDimension(unit) {
|
|
591
|
+
if (unit == null) return 'unknown';
|
|
592
|
+
const s = String(unit).trim();
|
|
593
|
+
const lower = s.toLowerCase();
|
|
594
|
+
if (lower.startsWith('wt') || s === '%') return 'mass-frac';
|
|
595
|
+
if (lower.startsWith('vol')) return 'vol-frac';
|
|
596
|
+
if (s in MASS_TO_KG || s in _customMassUnits()) return 'mass-abs';
|
|
597
|
+
if (s in VOL_TO_L || s in _customVolUnits()) return 'vol-abs';
|
|
598
|
+
if (s in LENGTH_TO_M || s in _customLengthUnits()) return 'length-abs';
|
|
599
|
+
// Case-insensitive fallback for typo-tolerance
|
|
600
|
+
for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return 'mass-abs';
|
|
601
|
+
for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return 'vol-abs';
|
|
602
|
+
for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return 'length-abs';
|
|
603
|
+
const custom = (typeof db !== 'undefined' && db?.customUnits) || {};
|
|
604
|
+
if (custom[s]?.dimension === 'count' || custom[s]?.dimension === 'other') return 'count';
|
|
605
|
+
return 'unknown';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function costUnitDimension(u) {
|
|
609
|
+
if (u in MASS_TO_KG) return 'mass';
|
|
610
|
+
if (u in VOL_TO_L) return 'volume';
|
|
611
|
+
return 'other';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---- Amount "view in other units" conversion (composition table) ----
|
|
615
|
+
// Curated common units shown in the click-to-convert popover. We don't
|
|
616
|
+
// list every mass/volume unit the engine knows — just the ones a
|
|
617
|
+
// formulator typically reaches for, so the popover stays scannable.
|
|
618
|
+
const _AMT_VIEW_MASS = ['mg', 'g', 'kg', 'oz', 'lb', 'ton'];
|
|
619
|
+
const _AMT_VIEW_VOL = ['mL', 'L', 'tsp', 'tbsp', 'fl_oz', 'cup', 'gal'];
|
|
620
|
+
|
|
621
|
+
function _massFactorCI(unit) {
|
|
622
|
+
if (unit in MASS_TO_KG) return MASS_TO_KG[unit];
|
|
623
|
+
const l = String(unit).toLowerCase();
|
|
624
|
+
for (const k in MASS_TO_KG) if (k.toLowerCase() === l) return MASS_TO_KG[k];
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
function _volFactorCI(unit) {
|
|
628
|
+
if (unit in VOL_TO_L) return VOL_TO_L[unit];
|
|
629
|
+
const l = String(unit).toLowerCase();
|
|
630
|
+
for (const k in VOL_TO_L) if (k.toLowerCase() === l) return VOL_TO_L[k];
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Convert `amount` (in `unit`) into the other common units of the same
|
|
635
|
+
// dimension, plus a cross-dimension block (mass↔volume) when the
|
|
636
|
+
// ingredient's density is known. For relative-fraction units (wt% /
|
|
637
|
+
// vol%) or count/unknown units, returns a `note` instead of rows —
|
|
638
|
+
// those have no absolute conversion without a batch basis.
|
|
639
|
+
//
|
|
640
|
+
// Returns: { dim, source:{value,unit}, same:[{unit,value}],
|
|
641
|
+
// cross:{label, rows:[{unit,value}]}|null, note:'' } | null
|
|
642
|
+
function amountConversions(amount, unit, ingredient, opts) {
|
|
643
|
+
const amt = parseFloat(amount);
|
|
644
|
+
if (!isFinite(amt)) return null;
|
|
645
|
+
const dim = (typeof detectUnitDimension === 'function') ? detectUnitDimension(unit) : 'unknown';
|
|
646
|
+
const res = { dim, source: { value: amt, unit: unit || '' }, same: [], cross: null, note: '' };
|
|
647
|
+
|
|
648
|
+
if (dim === 'mass-abs') {
|
|
649
|
+
const f = _massFactorCI(unit);
|
|
650
|
+
if (f == null) { res.note = 'Unrecognized mass unit.'; return res; }
|
|
651
|
+
const kg = amt * f;
|
|
652
|
+
res.same = _AMT_VIEW_MASS.filter(u => u !== unit).map(u => ({ unit: u, value: kg / MASS_TO_KG[u] }));
|
|
653
|
+
const d = ingredient ? _densityToKgPerL(ingredient.density, ingredient.densityUnit) : null; // kg/L
|
|
654
|
+
if (d) {
|
|
655
|
+
const L = kg / d;
|
|
656
|
+
res.cross = { label: `via density (${(+d.toFixed(3))} g/mL)`, rows: [
|
|
657
|
+
{ unit: 'mL', value: L / VOL_TO_L.mL },
|
|
658
|
+
{ unit: 'L', value: L },
|
|
659
|
+
] };
|
|
660
|
+
}
|
|
661
|
+
} else if (dim === 'vol-abs') {
|
|
662
|
+
const f = _volFactorCI(unit);
|
|
663
|
+
if (f == null) { res.note = 'Unrecognized volume unit.'; return res; }
|
|
664
|
+
const L = amt * f;
|
|
665
|
+
res.same = _AMT_VIEW_VOL.filter(u => u !== unit).map(u => ({ unit: u, value: L / VOL_TO_L[u] }));
|
|
666
|
+
const d = ingredient ? _densityToKgPerL(ingredient.density, ingredient.densityUnit) : null;
|
|
667
|
+
if (d) {
|
|
668
|
+
const kg = L * d;
|
|
669
|
+
res.cross = { label: `via density (${(+d.toFixed(3))} g/mL)`, rows: [
|
|
670
|
+
{ unit: 'g', value: kg / MASS_TO_KG.g },
|
|
671
|
+
{ unit: 'kg', value: kg },
|
|
672
|
+
] };
|
|
673
|
+
}
|
|
674
|
+
} else if (dim === 'mass-frac' || dim === 'vol-frac') {
|
|
675
|
+
// When a batch size is provided (opts.batchAmount + opts.batchUnit),
|
|
676
|
+
// materialize the fraction into an absolute amount so the click
|
|
677
|
+
// popover stops being a dead end on wt%/vol% rows. The caller passes
|
|
678
|
+
// the live Scale-to-batch state; without it we fall back to the
|
|
679
|
+
// legacy "depends on batch size" note.
|
|
680
|
+
const batchAmt = opts && parseFloat(opts.batchAmount);
|
|
681
|
+
const batchUnit = opts && opts.batchUnit;
|
|
682
|
+
const batchKg = (isFinite(batchAmt) && batchUnit in MASS_TO_KG) ? batchAmt * MASS_TO_KG[batchUnit] : null;
|
|
683
|
+
const batchL = (isFinite(batchAmt) && batchUnit in VOL_TO_L ) ? batchAmt * VOL_TO_L[batchUnit] : null;
|
|
684
|
+
const dens = ingredient ? _densityToKgPerL(ingredient.density, ingredient.densityUnit) : null;
|
|
685
|
+
// Match the fraction dimension to the batch dimension when possible.
|
|
686
|
+
// Cross-dimension (mass-frac × volume-batch, or vol-frac × mass-batch)
|
|
687
|
+
// requires the row's density — handled below when available.
|
|
688
|
+
let absKg = null, absL = null;
|
|
689
|
+
if (dim === 'mass-frac') {
|
|
690
|
+
if (batchKg != null) absKg = (amt / 100) * batchKg;
|
|
691
|
+
else if (batchL != null && dens != null) absKg = (amt / 100) * (batchL * dens);
|
|
692
|
+
} else { // vol-frac
|
|
693
|
+
if (batchL != null) absL = (amt / 100) * batchL;
|
|
694
|
+
else if (batchKg != null && dens != null) absL = (amt / 100) * (batchKg / dens);
|
|
695
|
+
}
|
|
696
|
+
if (absKg != null) {
|
|
697
|
+
res.same = [{ unit: 'kg', value: absKg }].concat(
|
|
698
|
+
_AMT_VIEW_MASS.filter(u => u !== 'kg').map(u => ({ unit: u, value: absKg / MASS_TO_KG[u] }))
|
|
699
|
+
);
|
|
700
|
+
if (dens != null) {
|
|
701
|
+
const L = absKg / dens;
|
|
702
|
+
res.cross = { label: `via density (${(+dens.toFixed(3))} g/mL)`, rows: [
|
|
703
|
+
{ unit: 'mL', value: L / VOL_TO_L.mL },
|
|
704
|
+
{ unit: 'L', value: L },
|
|
705
|
+
] };
|
|
706
|
+
}
|
|
707
|
+
res.note = `Computed at ${batchAmt} ${batchUnit} batch.`;
|
|
708
|
+
} else if (absL != null) {
|
|
709
|
+
res.same = [{ unit: 'L', value: absL }].concat(
|
|
710
|
+
_AMT_VIEW_VOL.filter(u => u !== 'L').map(u => ({ unit: u, value: absL / VOL_TO_L[u] }))
|
|
711
|
+
);
|
|
712
|
+
if (dens != null) {
|
|
713
|
+
const kg = absL * dens;
|
|
714
|
+
res.cross = { label: `via density (${(+dens.toFixed(3))} g/mL)`, rows: [
|
|
715
|
+
{ unit: 'g', value: kg / MASS_TO_KG.g },
|
|
716
|
+
{ unit: 'kg', value: kg },
|
|
717
|
+
] };
|
|
718
|
+
}
|
|
719
|
+
res.note = `Computed at ${batchAmt} ${batchUnit} batch.`;
|
|
720
|
+
} else {
|
|
721
|
+
res.note = `"${unit}" is a relative fraction — its absolute mass/volume depends on the batch size. Set a "Scale to batch" amount above to materialize.`;
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
res.note = 'No unit conversions available for this unit.';
|
|
725
|
+
}
|
|
726
|
+
return res;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Normalize whatever density unit the user picked into kg/L (= g/mL).
|
|
730
|
+
function _densityToKgPerL(value, unit) {
|
|
731
|
+
const v = parseFloat(value);
|
|
732
|
+
if (!isFinite(v) || v <= 0) return null;
|
|
733
|
+
switch (unit) {
|
|
734
|
+
case 'g/mL': case 'kg/L': case 'g/cm³': case 'kg/dm³': return v;
|
|
735
|
+
case 'g/L': case 'mg/mL': return v / 1000;
|
|
736
|
+
case 'kg/m³': case 't/m³': return v / 1000;
|
|
737
|
+
case 'mg/L': case 'µg/mL': return v / 1e6;
|
|
738
|
+
default: return v; // assume g/mL
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Convert an ing.cost to $/kg in the cost's own currency.
|
|
743
|
+
// Returns null if conversion isn't possible (no density for vol→mass).
|
|
744
|
+
function costPerKgInOwnCurrency(cost, density, densityUnit) {
|
|
745
|
+
if (!cost || cost.value == null || cost.value === '') return null;
|
|
746
|
+
const v = parseFloat(cost.value);
|
|
747
|
+
if (!isFinite(v)) return null;
|
|
748
|
+
const u = cost.unit || 'kg';
|
|
749
|
+
|
|
750
|
+
if (u in MASS_TO_KG) return v / MASS_TO_KG[u];
|
|
751
|
+
|
|
752
|
+
if (u in VOL_TO_L) {
|
|
753
|
+
const d = _densityToKgPerL(density, densityUnit);
|
|
754
|
+
if (!d) return null;
|
|
755
|
+
// $/u → $/L → $/kg
|
|
756
|
+
return (v / VOL_TO_L[u]) / d;
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ============================================================
|
|
762
|
+
// CURRENCY RATES — live via Frankfurter (free, no key), cached
|
|
763
|
+
// daily in localStorage. Falls back to static rates if offline.
|
|
764
|
+
// ============================================================
|
|
765
|
+
const FX_LS_KEY = 'formlab_fx_v1';
|
|
766
|
+
const FX_FALLBACK = {
|
|
767
|
+
// Approximate rates per 1 USD as of mid-2026. Used only when the
|
|
768
|
+
// live fetch fails (offline). Don't rely on these for finance.
|
|
769
|
+
base: 'USD',
|
|
770
|
+
rates: {
|
|
771
|
+
USD: 1, EUR: 0.92, GBP: 0.79, JPY: 156, CNY: 7.23,
|
|
772
|
+
CAD: 1.37, AUD: 1.52, CHF: 0.89, INR: 83.5, BRL: 5.1,
|
|
773
|
+
SGD: 1.26, HKD: 7.84, KRW: 1336, MXN: 16.9, // Frankfurter 2026-09-09
|
|
774
|
+
PKR: 278, // not on Frankfurter; see _backfillMissingFxRates
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
let _fxCache = null;
|
|
778
|
+
|
|
779
|
+
async function ensureFxRates() {
|
|
780
|
+
if (_fxCache) return _fxCache;
|
|
781
|
+
try {
|
|
782
|
+
const raw = localStorage.getItem(FX_LS_KEY);
|
|
783
|
+
if (raw) {
|
|
784
|
+
const parsed = JSON.parse(raw);
|
|
785
|
+
const today = flTodayIso();
|
|
786
|
+
if (parsed && parsed.date === today && parsed.base === 'USD') {
|
|
787
|
+
_fxCache = parsed;
|
|
788
|
+
return _fxCache;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
} catch {}
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
// Frankfurter migrated api.frankfurter.app → api.frankfurter.dev (the old
|
|
795
|
+
// host now 301-redirects, and a browser fetch can't follow that cross-origin
|
|
796
|
+
// redirect under CORS — the request failed with "No Access-Control-Allow-
|
|
797
|
+
// Origin"). Hit the new /v1 endpoint directly; it sends `access-control-
|
|
798
|
+
// allow-origin: *` and returns the same { base, date, rates } shape.
|
|
799
|
+
const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD');
|
|
800
|
+
if (!res.ok) throw new Error('Frankfurter returned ' + res.status);
|
|
801
|
+
const data = await res.json();
|
|
802
|
+
const rates = { USD: 1, ...data.rates };
|
|
803
|
+
// Frankfurter is ECB-only (~30 currencies). Any currency we list that it
|
|
804
|
+
// doesn't price (PKR today) gets a live rate from a broader secondary
|
|
805
|
+
// source, so the picker never offers a currency whose conversions are null.
|
|
806
|
+
await _backfillMissingFxRates(rates);
|
|
807
|
+
const date = flTodayIso();
|
|
808
|
+
_fxCache = { date, base: 'USD', rates };
|
|
809
|
+
try { localStorage.setItem(FX_LS_KEY, JSON.stringify(_fxCache)); } catch {}
|
|
810
|
+
return _fxCache;
|
|
811
|
+
} catch {
|
|
812
|
+
_fxCache = FX_FALLBACK;
|
|
813
|
+
return _fxCache;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Backfill live rates for listed currencies the primary (ECB/Frankfurter)
|
|
818
|
+
// source doesn't cover — e.g. PKR, which isn't an ECB reference currency.
|
|
819
|
+
// Mutates `rates` in place. Best-effort: on any failure the currency simply
|
|
820
|
+
// stays missing from the live cache and convertCurrencySync falls back to the
|
|
821
|
+
// FX_FALLBACK approximate for it. Source: exchangerate-api's free open endpoint
|
|
822
|
+
// (no key, CORS '*', ~160 currencies, daily refresh).
|
|
823
|
+
async function _backfillMissingFxRates(rates) {
|
|
824
|
+
const missing = COST_CURRENCIES.map(c => c.code).filter(code => rates[code] == null);
|
|
825
|
+
if (!missing.length) return;
|
|
826
|
+
try {
|
|
827
|
+
const res = await fetch('https://open.er-api.com/v6/latest/USD');
|
|
828
|
+
if (!res.ok) return;
|
|
829
|
+
const data = await res.json();
|
|
830
|
+
const supp = data && data.rates;
|
|
831
|
+
if (!supp) return;
|
|
832
|
+
for (const code of missing) {
|
|
833
|
+
if (typeof supp[code] === 'number' && isFinite(supp[code])) rates[code] = supp[code];
|
|
834
|
+
}
|
|
835
|
+
} catch { /* leave missing; the per-code FX_FALLBACK in convertCurrencySync covers it */ }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function convertCurrencySync(amount, from, to) {
|
|
839
|
+
if (amount == null || !isFinite(amount)) return null;
|
|
840
|
+
if (from === to) return amount;
|
|
841
|
+
const cache = _fxCache || FX_FALLBACK;
|
|
842
|
+
const r = cache.rates;
|
|
843
|
+
// Per-code fallback: if the live cache is missing a rate (e.g. the PKR
|
|
844
|
+
// backfill fetch failed this session), use the approximate FX_FALLBACK for
|
|
845
|
+
// that code rather than returning null and blanking the whole cost panel.
|
|
846
|
+
const rateOf = code => (r[code] != null ? r[code] : FX_FALLBACK.rates[code]);
|
|
847
|
+
const rFrom = rateOf(from), rTo = rateOf(to);
|
|
848
|
+
if (rFrom == null || rTo == null) return null;
|
|
849
|
+
// Cache is USD-based; convert from→USD→to
|
|
850
|
+
const usd = amount / rFrom;
|
|
851
|
+
return usd * rTo;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ============================================================
|
|
855
|
+
// DISPLAY CURRENCY — user's preferred currency for totals/UI
|
|
856
|
+
// ============================================================
|
|
857
|
+
const DISPLAY_CCY_LS_KEY = 'formlab_display_currency';
|
|
858
|
+
|
|
859
|
+
function getDisplayCurrency() {
|
|
860
|
+
return localStorage.getItem(DISPLAY_CCY_LS_KEY) || 'USD';
|
|
861
|
+
}
|
|
862
|
+
function setDisplayCurrency(code) {
|
|
863
|
+
if (!COST_CURRENCIES.some(c => c.code === code)) return;
|
|
864
|
+
localStorage.setItem(DISPLAY_CCY_LS_KEY, code);
|
|
865
|
+
// Per-user preference: write-through to the cloud (Pro). localStorage stays the
|
|
866
|
+
// offline/Free store; the hook persists it on the user's profile. Best-effort.
|
|
867
|
+
try { (typeof window !== 'undefined' ? window.__pro : null)?.onDisplayCurrency?.(code); } catch (_) {}
|
|
868
|
+
}
|
|
869
|
+
// Apply a cloud-loaded currency without echoing back to the cloud (used by the
|
|
870
|
+
// Pro layer after it reads the signed-in user's profile on boot).
|
|
871
|
+
function applyDisplayCurrencyFromCloud(code) {
|
|
872
|
+
if (!code || !COST_CURRENCIES.some(c => c.code === code)) return;
|
|
873
|
+
if (localStorage.getItem(DISPLAY_CCY_LS_KEY) === code) return;
|
|
874
|
+
localStorage.setItem(DISPLAY_CCY_LS_KEY, code);
|
|
875
|
+
if (typeof reRender === 'function') { try { reRender(); } catch (_) {} }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Live-FX provenance for cost panels: the date the cached rates were fetched,
|
|
879
|
+
// or a clear "offline / approximate" note when the live fetch hasn't landed.
|
|
880
|
+
function fxAsOfHint() {
|
|
881
|
+
if (_fxCache && _fxCache !== FX_FALLBACK && _fxCache.date) return 'FX ' + _fxCache.date;
|
|
882
|
+
return 'approx FX';
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Reusable global display-currency switcher for any cost panel. All cost figures
|
|
886
|
+
// share the one global pref (getDisplayCurrency), so changing it here re-renders
|
|
887
|
+
// the whole app in that currency. `reRenderJs` is a JS expression run right after
|
|
888
|
+
// the change to repaint the current view (e.g. "renderBatchDetail('b1')").
|
|
889
|
+
// `betweenHtml` (optional) is slotted between the select and the FX hint — the
|
|
890
|
+
// ingredient Cost tab puts its display-unit switcher there so currency, unit
|
|
891
|
+
// and FX date read as one row.
|
|
892
|
+
function displayCurrencyControlHtml(reRenderJs, betweenHtml) {
|
|
893
|
+
const cur = getDisplayCurrency();
|
|
894
|
+
const opts = COST_CURRENCIES
|
|
895
|
+
.map(c => `<option value="${c.code}"${c.code === cur ? ' selected' : ''}>${c.code} ${c.symbol}</option>`)
|
|
896
|
+
.join('');
|
|
897
|
+
const after = String(reRenderJs || '');
|
|
898
|
+
return `<select class="cost-ccy-select" onclick="event.stopPropagation()" `
|
|
899
|
+
+ `title="Display currency for all cost figures, live FX" `
|
|
900
|
+
+ `onchange="setDisplayCurrency(this.value);${after}" `
|
|
901
|
+
+ `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:#fff">${opts}</select>`
|
|
902
|
+
+ (betweenHtml || '')
|
|
903
|
+
+ `<span class="cost-ccy-fxhint" style="font-size:10.5px;color:var(--text-muted);margin-left:6px;white-space:nowrap">${fxAsOfHint()}</span>`;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ============================================================
|
|
907
|
+
// DISPLAY COST UNIT — per-user "show prices per …" for the ingredient Cost
|
|
908
|
+
// tab. Mirrors the display currency: the ENTERED price stays the source of
|
|
909
|
+
// truth, this only picks the unit of the read-only "≈ …" conversion line.
|
|
910
|
+
// '' = show in the unit the price was entered in. Synced per user via
|
|
911
|
+
// VIEW_PREF_KEYS (list-shell.js) like the other view prefs.
|
|
912
|
+
// ============================================================
|
|
913
|
+
const DISPLAY_COST_UNIT_LS_KEY = 'formlab_cost_display_unit';
|
|
914
|
+
function getDisplayCostUnit() {
|
|
915
|
+
try { return localStorage.getItem(DISPLAY_COST_UNIT_LS_KEY) || ''; } catch (_) { return ''; }
|
|
916
|
+
}
|
|
917
|
+
function setDisplayCostUnit(u) {
|
|
918
|
+
u = u || '';
|
|
919
|
+
if (u && !(u in MASS_TO_KG) && !(u in VOL_TO_L)) return;
|
|
920
|
+
if (typeof saveViewPref === 'function') saveViewPref(DISPLAY_COST_UNIT_LS_KEY, u);
|
|
921
|
+
else { try { localStorage.setItem(DISPLAY_COST_UNIT_LS_KEY, u); } catch (_) {} }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Convert an ing.cost to price-per-`unit` in the cost's OWN currency.
|
|
925
|
+
// mass↔mass and vol↔vol are exact; mass↔vol needs the ingredient's density.
|
|
926
|
+
// Returns null when the conversion isn't possible (no density, unknown unit).
|
|
927
|
+
function costPerUnitInOwnCurrency(cost, unit, density, densityUnit) {
|
|
928
|
+
if (!cost || cost.value == null || cost.value === '' || !unit) return null;
|
|
929
|
+
const v = parseFloat(cost.value);
|
|
930
|
+
if (!isFinite(v)) return null;
|
|
931
|
+
const from = cost.unit || 'kg';
|
|
932
|
+
if (from === unit) return v;
|
|
933
|
+
const kgPerL = _densityToKgPerL(density, densityUnit);
|
|
934
|
+
return convertPricePerBasis(v, from, unit, kgPerL || null);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Unit switcher paired with displayCurrencyControlHtml on the Cost tab. Options
|
|
938
|
+
// are the common basis units plus the entered unit; the cross-family choices
|
|
939
|
+
// (volume for a mass price, or vice versa) are disabled until the ingredient
|
|
940
|
+
// has a density, with the reason in the option's tooltip.
|
|
941
|
+
function displayCostUnitControlHtml(cost, density, densityUnit, reRenderJs) {
|
|
942
|
+
const entered = (cost && cost.unit) || 'kg';
|
|
943
|
+
const cur = getDisplayCostUnit() || entered;
|
|
944
|
+
const hasDensity = !!_densityToKgPerL(density, densityUnit);
|
|
945
|
+
const fromMass = entered in MASS_TO_KG;
|
|
946
|
+
const list = COST_BASIS_UNITS.slice();
|
|
947
|
+
if (list.indexOf(entered) === -1) list.unshift(entered);
|
|
948
|
+
if (cur !== entered && list.indexOf(cur) === -1) list.push(cur);
|
|
949
|
+
const opts = list.map(u => {
|
|
950
|
+
const sameFamily = (u in MASS_TO_KG) === fromMass;
|
|
951
|
+
const off = !sameFamily && !hasDensity;
|
|
952
|
+
const t = off ? ' title="Set a density on the Identification tab to convert between mass and volume"' : '';
|
|
953
|
+
return `<option value="${u}"${u === cur ? ' selected' : ''}${off ? ' disabled' : ''}${t}>/ ${u}</option>`;
|
|
954
|
+
}).join('');
|
|
955
|
+
const after = String(reRenderJs || '');
|
|
956
|
+
return `<select class="cost-ccy-select cost-unit-select" onclick="event.stopPropagation()" `
|
|
957
|
+
+ `title="Show the price per this unit (the entered price is unchanged)" `
|
|
958
|
+
+ `onchange="setDisplayCostUnit(this.value);${after}" `
|
|
959
|
+
+ `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:#fff">${opts}</select>`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ============================================================
|
|
963
|
+
// FORMATTING
|
|
964
|
+
// ============================================================
|
|
965
|
+
function formatMoney(amount, currency) {
|
|
966
|
+
if (amount == null || !isFinite(amount)) return '—';
|
|
967
|
+
// Under one cent (a per-kg price of water, say), 2 decimals read "$0.00";
|
|
968
|
+
// show 3 significant digits instead ("$0.002"). Everything else unchanged
|
|
969
|
+
// (build 33g).
|
|
970
|
+
const tiny = amount !== 0 && Math.abs(amount) < 0.01;
|
|
971
|
+
try {
|
|
972
|
+
return new Intl.NumberFormat(undefined, tiny
|
|
973
|
+
? { style: 'currency', currency, maximumSignificantDigits: 3 }
|
|
974
|
+
: { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount);
|
|
975
|
+
} catch {
|
|
976
|
+
const sym = COST_CURRENCIES.find(c => c.code === currency)?.symbol || '';
|
|
977
|
+
return sym + (tiny ? String(+amount.toPrecision(3)) : amount.toFixed(2));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function formatCostPerUnit(cost) {
|
|
982
|
+
if (!cost || cost.value == null || cost.value === '') return '—';
|
|
983
|
+
const v = parseFloat(cost.value);
|
|
984
|
+
if (!isFinite(v)) return '—';
|
|
985
|
+
return `${formatMoney(v, cost.currency || 'USD')} / ${cost.unit || 'kg'}`;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ============================================================
|
|
989
|
+
// FORMULATION COST ROLLUP
|
|
990
|
+
// Returns { perKg, currency, breakdown, missing, basis }.
|
|
991
|
+
// Handles three cases:
|
|
992
|
+
// - all rows in wt%: cost-per-kg directly from $/kg × wt fraction
|
|
993
|
+
// - all rows in vol%: derive formulation density from per-row
|
|
994
|
+
// (vol fraction × ing density), then $/kg
|
|
995
|
+
// - mixed: best-effort wt% with vol rows in `missing`
|
|
996
|
+
// Volume rows need each ingredient's density to be priced.
|
|
997
|
+
// ============================================================
|
|
998
|
+
function computeFormulationCost(formulation, displayCcy, basisUnit, _seen) {
|
|
999
|
+
const comp = formulation.composition || [];
|
|
1000
|
+
// Currency precedence: explicit arg → viewer's display preference → formulation's saved currency → USD.
|
|
1001
|
+
// formulation.costCurrency is the currency the owner used when entering costs, but in a shared
|
|
1002
|
+
// workspace each viewer should always see costs in their own preferred currency.
|
|
1003
|
+
displayCcy = displayCcy || getDisplayCurrency() || formulation.costCurrency || 'USD';
|
|
1004
|
+
basisUnit = basisUnit || formulation.costBasisUnit || 'kg';
|
|
1005
|
+
const overrides = formulation.costOverrides || {};
|
|
1006
|
+
const breakdown = [];
|
|
1007
|
+
const missing = [];
|
|
1008
|
+
|
|
1009
|
+
// Recursion guard for nested formulations. _seen is the chain of
|
|
1010
|
+
// formulation ids currently being priced; if the same id shows up
|
|
1011
|
+
// again, that's a cycle (should have been caught at add time, but be
|
|
1012
|
+
// defensive) and we skip the offending sub-row.
|
|
1013
|
+
_seen = _seen || new Set();
|
|
1014
|
+
if (formulation && formulation.id) _seen = new Set([..._seen, formulation.id]);
|
|
1015
|
+
|
|
1016
|
+
// Resolve each sub-formula row to a "synthetic ingredient" with a
|
|
1017
|
+
// recursively-computed $/kg and a rolled-up density. The main loop
|
|
1018
|
+
// below then treats sub-formula rows just like ingredient rows.
|
|
1019
|
+
const subResolve = new Map();
|
|
1020
|
+
for (const c of comp) {
|
|
1021
|
+
if (!c.formulationId) continue;
|
|
1022
|
+
// By id through the draw's index (12a): a scan per row, per formula, was
|
|
1023
|
+
// most of the Inventory roll-up's cost at 20k records.
|
|
1024
|
+
const subF = (typeof flFindById === 'function') ? flFindById('formulations', c.formulationId) : db.formulations.find(g => g.id === c.formulationId);
|
|
1025
|
+
if (!subF) { missing.push({ reason: 'sub-formula deleted', row: c }); continue; }
|
|
1026
|
+
if (_seen.has(subF.id)) { missing.push({ reason: 'cycle detected', row: c }); continue; }
|
|
1027
|
+
const subRes = computeFormulationCost(subF, displayCcy, 'kg', _seen);
|
|
1028
|
+
const subDensity = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(subF) : null;
|
|
1029
|
+
subResolve.set(c, {
|
|
1030
|
+
subFormula: subF,
|
|
1031
|
+
perKgDisplay: subRes.perKg, // $/kg in displayCcy
|
|
1032
|
+
perKgOwn: subRes.perKg, // already converted
|
|
1033
|
+
density: subDensity, // kg/L, may be null
|
|
1034
|
+
missing: subRes.missing || [],
|
|
1035
|
+
basisOk: subRes.basisOk,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Classify each row's unit into one of five buckets:
|
|
1040
|
+
// wt-frac "wt%" / "%"
|
|
1041
|
+
// vol-frac "vol%"
|
|
1042
|
+
// mass-abs g / kg / mg / lb / oz / ton / ...
|
|
1043
|
+
// vol-abs mL / L / gal / drum / ...
|
|
1044
|
+
// unknown anything else (each, count, etc.)
|
|
1045
|
+
// NOTE: MASS_TO_KG / VOL_TO_L keys are case-sensitive ('L' for litre,
|
|
1046
|
+
// 'mL' for millilitre — distinct from 'l' / 'ml' which aren't keys).
|
|
1047
|
+
// We check the raw unit first; if that misses, fall back to a
|
|
1048
|
+
// case-normalized form so users typing "ML" or "kG" still resolve.
|
|
1049
|
+
const classifyUnit = u => {
|
|
1050
|
+
const raw = String(u || 'wt%').trim();
|
|
1051
|
+
const lower = raw.toLowerCase();
|
|
1052
|
+
if (lower.startsWith('wt') || raw === '%') return 'wt-frac';
|
|
1053
|
+
if (lower.startsWith('vol')) return 'vol-frac';
|
|
1054
|
+
if (raw in MASS_TO_KG) return 'mass-abs';
|
|
1055
|
+
if (raw in VOL_TO_L) return 'vol-abs';
|
|
1056
|
+
if (raw in LENGTH_TO_M) return 'length-abs';
|
|
1057
|
+
// Case-insensitive fallback
|
|
1058
|
+
for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return 'mass-abs';
|
|
1059
|
+
for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return 'vol-abs';
|
|
1060
|
+
for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return 'length-abs';
|
|
1061
|
+
return 'unknown';
|
|
1062
|
+
};
|
|
1063
|
+
// Map any user-typed unit back to the canonical key.
|
|
1064
|
+
const canonUnit = u => {
|
|
1065
|
+
const raw = String(u || '').trim();
|
|
1066
|
+
if (raw in MASS_TO_KG || raw in VOL_TO_L || raw in LENGTH_TO_M) return raw;
|
|
1067
|
+
const lower = raw.toLowerCase();
|
|
1068
|
+
for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return k;
|
|
1069
|
+
for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return k;
|
|
1070
|
+
for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return k;
|
|
1071
|
+
return raw;
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
const kinds = comp.map(c => classifyUnit(c.unit));
|
|
1075
|
+
const allWt = comp.length && kinds.every(k => k === 'wt-frac');
|
|
1076
|
+
const allVol = comp.length && kinds.every(k => k === 'vol-frac');
|
|
1077
|
+
const allAbs = comp.length && kinds.every(k => k === 'mass-abs' || k === 'vol-abs');
|
|
1078
|
+
const basis = allVol ? 'vol' : (allAbs ? 'abs' : 'wt');
|
|
1079
|
+
|
|
1080
|
+
// === Vol-% recipe: derive formulation density from per-row densities ===
|
|
1081
|
+
// Sub-formula rows use their rolled-up density (computed above).
|
|
1082
|
+
let formulationKgPerL = null;
|
|
1083
|
+
if (allVol) {
|
|
1084
|
+
let sumKg = 0, ok = true;
|
|
1085
|
+
for (const c of comp) {
|
|
1086
|
+
let d = null;
|
|
1087
|
+
if (c.formulationId) {
|
|
1088
|
+
d = subResolve.get(c)?.density || null;
|
|
1089
|
+
} else {
|
|
1090
|
+
const ing = db.ingredients.find(i => i.id === c.ingredientId);
|
|
1091
|
+
d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
|
|
1092
|
+
}
|
|
1093
|
+
const amt = parseFloat(c.amount);
|
|
1094
|
+
if (!d || !isFinite(amt)) { ok = false; break; }
|
|
1095
|
+
sumKg += (amt / 100) * d;
|
|
1096
|
+
}
|
|
1097
|
+
formulationKgPerL = ok ? sumKg : null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// === Absolute recipe: convert every row to kg (using density for volume
|
|
1101
|
+
// rows), sum to total mass, then use per-row mass fractions. Also track
|
|
1102
|
+
// total volume so we can derive the formulation density for basis-unit
|
|
1103
|
+
// conversion if all rows have densities. ===
|
|
1104
|
+
const massKgPerRow = new Map(); // c <span class="fl-ar fl-ar-e"></span> row mass in kg
|
|
1105
|
+
const volLPerRow = new Map(); // c <span class="fl-ar fl-ar-e"></span> row volume in L (when known)
|
|
1106
|
+
let totalMassKg = 0;
|
|
1107
|
+
let totalVolL = 0;
|
|
1108
|
+
let allAbsHaveDensity = true;
|
|
1109
|
+
if (allAbs) {
|
|
1110
|
+
for (const c of comp) {
|
|
1111
|
+
const u = canonUnit(c.unit || '');
|
|
1112
|
+
const amt = parseFloat(c.amount);
|
|
1113
|
+
let d = null;
|
|
1114
|
+
if (c.formulationId) {
|
|
1115
|
+
if (!subResolve.has(c)) continue;
|
|
1116
|
+
d = subResolve.get(c).density;
|
|
1117
|
+
} else {
|
|
1118
|
+
const ing = db.ingredients.find(i => i.id === c.ingredientId);
|
|
1119
|
+
if (!ing) continue;
|
|
1120
|
+
d = _densityToKgPerL(ing.density, ing.densityUnit);
|
|
1121
|
+
}
|
|
1122
|
+
if (!isFinite(amt)) continue;
|
|
1123
|
+
if (u in MASS_TO_KG) {
|
|
1124
|
+
const m = amt * MASS_TO_KG[u];
|
|
1125
|
+
massKgPerRow.set(c, m);
|
|
1126
|
+
totalMassKg += m;
|
|
1127
|
+
if (d) { const v = m / d; volLPerRow.set(c, v); totalVolL += v; }
|
|
1128
|
+
else allAbsHaveDensity = false;
|
|
1129
|
+
} else if (u in VOL_TO_L) {
|
|
1130
|
+
const v = amt * VOL_TO_L[u];
|
|
1131
|
+
if (d) {
|
|
1132
|
+
const m = v * d;
|
|
1133
|
+
massKgPerRow.set(c, m);
|
|
1134
|
+
volLPerRow.set(c, v);
|
|
1135
|
+
totalMassKg += m;
|
|
1136
|
+
totalVolL += v;
|
|
1137
|
+
} else {
|
|
1138
|
+
allAbsHaveDensity = false;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (allAbsHaveDensity && totalMassKg > 0 && totalVolL > 0) {
|
|
1143
|
+
formulationKgPerL = totalMassKg / totalVolL;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
let totalPerKg = 0;
|
|
1148
|
+
let totalPct = 0;
|
|
1149
|
+
|
|
1150
|
+
const tryAutoPrice = (ing, c, unit, amount, kind) => {
|
|
1151
|
+
if (!ing.cost || ing.cost.value == null || ing.cost.value === '') return { ok: false, reason: 'no cost on file' };
|
|
1152
|
+
const perKgOwn = costPerKgInOwnCurrency(ing.cost, ing.density, ing.densityUnit);
|
|
1153
|
+
if (perKgOwn == null) return { ok: false, reason: 'could not convert price unit (needs density?)' };
|
|
1154
|
+
const perKgDisplay = convertCurrencySync(perKgOwn, ing.cost.currency || 'USD', displayCcy);
|
|
1155
|
+
if (perKgDisplay == null) return { ok: false, reason: 'unknown currency rate' };
|
|
1156
|
+
|
|
1157
|
+
if (kind === 'wt-frac') {
|
|
1158
|
+
return { ok: true, perKgOwn, perKgDisplay, contribution: (amount / 100) * perKgDisplay };
|
|
1159
|
+
}
|
|
1160
|
+
if (kind === 'vol-frac') {
|
|
1161
|
+
const d = _densityToKgPerL(ing.density, ing.densityUnit);
|
|
1162
|
+
if (!allVol || !formulationKgPerL || !d) return { ok: false, reason: 'vol-% needs density for every row and a vol-only recipe' };
|
|
1163
|
+
const massFraction = ((amount / 100) * d) / formulationKgPerL;
|
|
1164
|
+
return { ok: true, perKgOwn, perKgDisplay, contribution: massFraction * perKgDisplay };
|
|
1165
|
+
}
|
|
1166
|
+
if (kind === 'mass-abs' || kind === 'vol-abs') {
|
|
1167
|
+
if (!allAbs) return { ok: false, reason: 'absolute amounts cannot be mixed with wt%/vol% in the same recipe' };
|
|
1168
|
+
const m = massKgPerRow.get(c);
|
|
1169
|
+
if (m == null || totalMassKg <= 0) {
|
|
1170
|
+
return { ok: false, reason: kind === 'vol-abs' ? 'volume amount needs ingredient density' : 'cannot determine row mass' };
|
|
1171
|
+
}
|
|
1172
|
+
const massFraction = m / totalMassKg;
|
|
1173
|
+
return { ok: true, perKgOwn, perKgDisplay, contribution: massFraction * perKgDisplay };
|
|
1174
|
+
}
|
|
1175
|
+
if (kind === 'length-abs') {
|
|
1176
|
+
return { ok: false, reason: 'length-based recipes are not auto-priced yet — set a per-row override' };
|
|
1177
|
+
}
|
|
1178
|
+
return { ok: false, reason: `unsupported composition unit "${c.unit}"` };
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
for (const c of comp) {
|
|
1182
|
+
// ---- Sub-formula row: treat as a synthetic ingredient priced
|
|
1183
|
+
// via the recursive computeFormulationCost above. ----
|
|
1184
|
+
if (c.formulationId) {
|
|
1185
|
+
const sub = subResolve.get(c);
|
|
1186
|
+
if (!sub) continue; // 'sub-formula deleted' / 'cycle detected' already in missing[]
|
|
1187
|
+
const amount = parseFloat(c.amount);
|
|
1188
|
+
if (!isFinite(amount)) { missing.push({ reason: 'no amount', subFormula: sub.subFormula }); continue; }
|
|
1189
|
+
const kind = classifyUnit((c.unit || 'wt%').toLowerCase());
|
|
1190
|
+
if (kind === 'unknown') { missing.push({ reason: `unsupported composition unit "${c.unit}"`, subFormula: sub.subFormula }); continue; }
|
|
1191
|
+
const perKgDisplay = sub.perKgDisplay;
|
|
1192
|
+
let contribution = null;
|
|
1193
|
+
if (kind === 'wt-frac') {
|
|
1194
|
+
contribution = (amount / 100) * perKgDisplay;
|
|
1195
|
+
} else if (kind === 'vol-frac') {
|
|
1196
|
+
if (!allVol || !formulationKgPerL || !sub.density) {
|
|
1197
|
+
missing.push({ reason: 'vol-% needs density for every row and a vol-only recipe', subFormula: sub.subFormula });
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const massFraction = ((amount / 100) * sub.density) / formulationKgPerL;
|
|
1201
|
+
contribution = massFraction * perKgDisplay;
|
|
1202
|
+
} else if (kind === 'mass-abs' || kind === 'vol-abs') {
|
|
1203
|
+
if (!allAbs) { missing.push({ reason: 'absolute amounts cannot be mixed with wt%/vol%', subFormula: sub.subFormula }); continue; }
|
|
1204
|
+
const m = massKgPerRow.get(c);
|
|
1205
|
+
if (m == null || totalMassKg <= 0) {
|
|
1206
|
+
missing.push({ reason: kind === 'vol-abs' ? 'volume amount needs sub-formula density' : 'cannot determine row mass', subFormula: sub.subFormula });
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const massFraction = m / totalMassKg;
|
|
1210
|
+
contribution = massFraction * perKgDisplay;
|
|
1211
|
+
} else {
|
|
1212
|
+
missing.push({ reason: `unsupported composition unit "${c.unit}"`, subFormula: sub.subFormula });
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
totalPerKg += contribution;
|
|
1216
|
+
totalPct += amount;
|
|
1217
|
+
breakdown.push({
|
|
1218
|
+
subFormula: sub.subFormula, ingredient: null,
|
|
1219
|
+
amount, unit: c.unit || 'wt%',
|
|
1220
|
+
perKgOwn: sub.perKgOwn, perKgDisplay,
|
|
1221
|
+
contribution, overridden: false,
|
|
1222
|
+
density: sub.density,
|
|
1223
|
+
});
|
|
1224
|
+
// Surface any deep-pricing gaps inside the sub-formula so the parent's
|
|
1225
|
+
// "X rows unpriced" warning still reflects reality. Preserve the
|
|
1226
|
+
// original ingredient (or subFormula) reference so the unpriced
|
|
1227
|
+
// tooltip can render the actual name — the earlier shape dropped
|
|
1228
|
+
// m.ingredient and the display fell back to "?" for anything at
|
|
1229
|
+
// depth >= 2 (Parent → SubB → ingredient inside SubB → all surfaced
|
|
1230
|
+
// as "?: (in SubB) no cost on file" instead of "PigmentX: …").
|
|
1231
|
+
for (const m of (sub.missing || [])) {
|
|
1232
|
+
missing.push({
|
|
1233
|
+
reason: `(in ${sub.subFormula.name || 'sub-formula'}) ${m.reason}`,
|
|
1234
|
+
ingredient: m.ingredient || null,
|
|
1235
|
+
subFormula: m.subFormula || sub.subFormula,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const ing = (typeof flFindById === 'function') ? flFindById('ingredients', c.ingredientId) : db.ingredients.find(i => i.id === c.ingredientId);
|
|
1242
|
+
if (!ing) { missing.push({ reason: 'ingredient deleted', row: c }); continue; }
|
|
1243
|
+
const unit = (c.unit || 'wt%').toLowerCase();
|
|
1244
|
+
const amount = parseFloat(c.amount);
|
|
1245
|
+
if (!isFinite(amount)) { missing.push({ reason: 'no amount', ingredient: ing }); continue; }
|
|
1246
|
+
|
|
1247
|
+
const kind = classifyUnit(unit);
|
|
1248
|
+
if (kind === 'unknown') {
|
|
1249
|
+
missing.push({ reason: `unsupported composition unit "${c.unit}"`, ingredient: ing });
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const auto = tryAutoPrice(ing, c, unit, amount, kind);
|
|
1254
|
+
if (auto.ok) {
|
|
1255
|
+
totalPerKg += auto.contribution;
|
|
1256
|
+
totalPct += amount;
|
|
1257
|
+
breakdown.push({
|
|
1258
|
+
ingredient: ing, amount, unit: c.unit || 'wt%',
|
|
1259
|
+
perKgOwn: auto.perKgOwn, perKgDisplay: auto.perKgDisplay,
|
|
1260
|
+
contribution: auto.contribution,
|
|
1261
|
+
overridden: false,
|
|
1262
|
+
});
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Auto-pricing failed. Check for a manual override.
|
|
1267
|
+
const ov = overrides[ing.id];
|
|
1268
|
+
if (ov && ov.value != null && isFinite(parseFloat(ov.value))) {
|
|
1269
|
+
const ovValueOwn = parseFloat(ov.value);
|
|
1270
|
+
const ovUnit = ov.basisUnit || 'kg';
|
|
1271
|
+
const ovCcy = ov.currency || 'USD';
|
|
1272
|
+
// Currency conversion: override was stored in ovCcy; rollup is in displayCcy.
|
|
1273
|
+
const ovValue = ovCcy === displayCcy ? ovValueOwn : convertCurrencySync(ovValueOwn, ovCcy, displayCcy);
|
|
1274
|
+
// Unit conversion (override is in "$ per basisUnit of formulation").
|
|
1275
|
+
let perBasisContribution = null;
|
|
1276
|
+
if (ovValue != null) {
|
|
1277
|
+
if (ovUnit === basisUnit) {
|
|
1278
|
+
perBasisContribution = ovValue;
|
|
1279
|
+
} else if (ovUnit in MASS_TO_KG && basisUnit in MASS_TO_KG) {
|
|
1280
|
+
const inKg = ovValue / MASS_TO_KG[ovUnit];
|
|
1281
|
+
perBasisContribution = inKg * MASS_TO_KG[basisUnit];
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const stale = perBasisContribution == null;
|
|
1285
|
+
breakdown.push({
|
|
1286
|
+
ingredient: ing, amount, unit: c.unit || 'wt%',
|
|
1287
|
+
perKgOwn: null, perKgDisplay: null,
|
|
1288
|
+
contribution: stale ? 0 : perBasisContribution,
|
|
1289
|
+
overridden: true,
|
|
1290
|
+
overrideValue: ovValueOwn,
|
|
1291
|
+
overrideUnit: ovUnit,
|
|
1292
|
+
overrideCurrency: ovCcy,
|
|
1293
|
+
overrideStale: stale,
|
|
1294
|
+
contributionIsAlreadyInBasis: true,
|
|
1295
|
+
});
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
missing.push({ reason: auto.reason, ingredient: ing });
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// === Convert $/kg of formulation → $/basisUnit of formulation ===
|
|
1303
|
+
// For per-row priced contributions, totalPerKg is the running sum in $/kg.
|
|
1304
|
+
// Compute scaling so we can also expose perBasisUnit and perBasisRow.
|
|
1305
|
+
let basisOk = true;
|
|
1306
|
+
let basisScale = 1; // multiplier from $/kg of formulation to $/basisUnit of formulation
|
|
1307
|
+
if (basisUnit in MASS_TO_KG) {
|
|
1308
|
+
basisScale = MASS_TO_KG[basisUnit];
|
|
1309
|
+
} else if (basisUnit in VOL_TO_L) {
|
|
1310
|
+
// Need a formulation density (kg/L) to do mass→volume conversion
|
|
1311
|
+
let formKgPerL = formulationKgPerL;
|
|
1312
|
+
if (!formKgPerL) {
|
|
1313
|
+
// Estimate from composition: weight-average density (only works if all densities present).
|
|
1314
|
+
// Sub-formula rows use their rolled-up density.
|
|
1315
|
+
let sumA = 0, sumW = 0;
|
|
1316
|
+
for (const c of comp) {
|
|
1317
|
+
let d = null;
|
|
1318
|
+
if (c.formulationId) {
|
|
1319
|
+
d = subResolve.get(c)?.density || null;
|
|
1320
|
+
} else {
|
|
1321
|
+
const ing = db.ingredients.find(i => i.id === c.ingredientId);
|
|
1322
|
+
d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
|
|
1323
|
+
}
|
|
1324
|
+
const a = parseFloat(c.amount);
|
|
1325
|
+
if (d && isFinite(a)) { sumA += a * d; sumW += a; }
|
|
1326
|
+
}
|
|
1327
|
+
formKgPerL = sumW ? sumA / sumW : null;
|
|
1328
|
+
}
|
|
1329
|
+
if (formKgPerL) {
|
|
1330
|
+
basisScale = formKgPerL * VOL_TO_L[basisUnit];
|
|
1331
|
+
} else {
|
|
1332
|
+
basisOk = false;
|
|
1333
|
+
}
|
|
1334
|
+
} else if (basisUnit !== 'kg') {
|
|
1335
|
+
basisOk = false;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Apply scale to per-row priced contributions (overridden rows already
|
|
1339
|
+
// carry their contribution in basis terms).
|
|
1340
|
+
let totalPerBasis = 0;
|
|
1341
|
+
for (const b of breakdown) {
|
|
1342
|
+
if (b.contributionIsAlreadyInBasis) {
|
|
1343
|
+
totalPerBasis += b.contribution;
|
|
1344
|
+
b.perBasis = b.contribution;
|
|
1345
|
+
} else if (basisOk) {
|
|
1346
|
+
b.perBasis = b.contribution * basisScale;
|
|
1347
|
+
totalPerBasis += b.perBasis;
|
|
1348
|
+
} else {
|
|
1349
|
+
b.perBasis = null;
|
|
1350
|
+
}
|
|
1351
|
+
// Per-kg of the ingredient → also expose per-basisUnit of the ingredient
|
|
1352
|
+
if (b.perKgDisplay != null && basisOk && !(basisUnit in VOL_TO_L)) {
|
|
1353
|
+
// Ingredient per-basisUnit: only meaningful for mass conversions
|
|
1354
|
+
b.perBasisRate = b.perKgDisplay * MASS_TO_KG[basisUnit];
|
|
1355
|
+
} else if (b.perKgDisplay != null && basisOk) {
|
|
1356
|
+
// Volume basis: use ingredient density (or sub-formula rolled-up density)
|
|
1357
|
+
let d = null;
|
|
1358
|
+
if (b.ingredient) {
|
|
1359
|
+
d = _densityToKgPerL(b.ingredient.density, b.ingredient.densityUnit);
|
|
1360
|
+
} else if (b.subFormula) {
|
|
1361
|
+
d = b.density || (typeof _rolledUpDensity === 'function' ? _rolledUpDensity(b.subFormula) : null);
|
|
1362
|
+
}
|
|
1363
|
+
b.perBasisRate = d ? b.perKgDisplay * d * VOL_TO_L[basisUnit] : null;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return {
|
|
1368
|
+
perKg: totalPerKg,
|
|
1369
|
+
perBasis: basisOk ? totalPerBasis : null,
|
|
1370
|
+
basisUnit,
|
|
1371
|
+
basisOk,
|
|
1372
|
+
coveragePct: totalPct,
|
|
1373
|
+
currency: displayCcy,
|
|
1374
|
+
breakdown,
|
|
1375
|
+
missing,
|
|
1376
|
+
basis, // 'wt' or 'vol' (recipe basis, not display basis)
|
|
1377
|
+
formulationKgPerL, // only meaningful when basis === 'vol'
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Common basis units the user can pick in the composition cost column.
|
|
1382
|
+
const COST_BASIS_UNITS = ['kg', 'g', 'lb', 'oz', 'ton', 'L', 'mL', 'gal'];
|
|
1383
|
+
|
|
1384
|
+
// Convert a money-per-basis value from one basis to another. Mass↔mass and
|
|
1385
|
+
// volume↔volume are direct; mass↔volume needs formulationKgPerL (derived
|
|
1386
|
+
// by computeFormulationCost when possible). Returns null if a needed
|
|
1387
|
+
// density is missing.
|
|
1388
|
+
function convertPricePerBasis(price, fromBasis, toBasis, formulationKgPerL) {
|
|
1389
|
+
if (price == null || !isFinite(price)) return null;
|
|
1390
|
+
if (fromBasis === toBasis) return price;
|
|
1391
|
+
const scaleFromKg = u => {
|
|
1392
|
+
if (u === 'kg') return 1;
|
|
1393
|
+
if (u in MASS_TO_KG) return MASS_TO_KG[u];
|
|
1394
|
+
if (u in VOL_TO_L) {
|
|
1395
|
+
if (!formulationKgPerL) return null;
|
|
1396
|
+
return formulationKgPerL * VOL_TO_L[u];
|
|
1397
|
+
}
|
|
1398
|
+
return null;
|
|
1399
|
+
};
|
|
1400
|
+
const sFrom = scaleFromKg(fromBasis);
|
|
1401
|
+
const sTo = scaleFromKg(toBasis);
|
|
1402
|
+
if (sFrom == null || sTo == null) return null;
|
|
1403
|
+
// price_per_kg = price_per_fromBasis / sFrom
|
|
1404
|
+
// price_per_toBasis = price_per_kg × sTo
|
|
1405
|
+
return price * (sTo / sFrom);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// ============================================================
|
|
1409
|
+
// COMPOSITION FRACTIONS — per-row mass/volume fractions of the
|
|
1410
|
+
// formulation, computed regardless of whether rows use wt%, vol%,
|
|
1411
|
+
// or absolute mass/volume amounts. Used by the "% of Total"
|
|
1412
|
+
// column when the user wants "by weight" / "by volume" instead of
|
|
1413
|
+
// raw-amount percentages.
|
|
1414
|
+
// ============================================================
|
|
1415
|
+
function computeCompositionFractions(formulation) {
|
|
1416
|
+
const comp = formulation.composition || [];
|
|
1417
|
+
|
|
1418
|
+
const canonUnit = u => {
|
|
1419
|
+
const raw = String(u || '').trim();
|
|
1420
|
+
if (raw in MASS_TO_KG || raw in VOL_TO_L || raw in LENGTH_TO_M) return raw;
|
|
1421
|
+
const lower = raw.toLowerCase();
|
|
1422
|
+
for (const k of Object.keys(MASS_TO_KG)) if (k.toLowerCase() === lower) return k;
|
|
1423
|
+
for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === lower) return k;
|
|
1424
|
+
for (const k of Object.keys(LENGTH_TO_M)) if (k.toLowerCase() === lower) return k;
|
|
1425
|
+
return raw;
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
// Pass 1: classify each row, compute absolute mass, volume, length when possible.
|
|
1429
|
+
// Sub-formula rows resolve to a "synthetic ingredient" with the sub-formula's
|
|
1430
|
+
// rolled-up density so the same mass/volume math applies.
|
|
1431
|
+
const rows = comp.map(c => {
|
|
1432
|
+
const rawU = (c.unit || 'wt%').trim();
|
|
1433
|
+
const u = rawU.toLowerCase();
|
|
1434
|
+
const amount = parseFloat(c.amount);
|
|
1435
|
+
let ing = null, subFormula = null, d = null;
|
|
1436
|
+
if (c.formulationId) {
|
|
1437
|
+
subFormula = db.formulations.find(g => g.id === c.formulationId) || null;
|
|
1438
|
+
d = subFormula && typeof _rolledUpDensity === 'function' ? _rolledUpDensity(subFormula) : null;
|
|
1439
|
+
} else {
|
|
1440
|
+
ing = db.ingredients.find(i => i.id === c.ingredientId);
|
|
1441
|
+
d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
|
|
1442
|
+
}
|
|
1443
|
+
const out = { composition: c, ingredient: ing, subFormula, amount, unit: rawU, density: d };
|
|
1444
|
+
const resolved = !!(ing || subFormula);
|
|
1445
|
+
if (!isFinite(amount) || !resolved) { out.kind = 'invalid'; return out; }
|
|
1446
|
+
if (u.startsWith('wt') || u === '%') { out.kind = 'wt-frac'; return out; }
|
|
1447
|
+
if (u.startsWith('vol')) { out.kind = 'vol-frac'; return out; }
|
|
1448
|
+
const cu = canonUnit(rawU);
|
|
1449
|
+
if (cu in MASS_TO_KG) {
|
|
1450
|
+
out.kind = 'mass-abs';
|
|
1451
|
+
out.massKg = amount * MASS_TO_KG[cu];
|
|
1452
|
+
out.volL = d ? out.massKg / d : null;
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
if (cu in VOL_TO_L) {
|
|
1456
|
+
out.kind = 'vol-abs';
|
|
1457
|
+
out.volL = amount * VOL_TO_L[cu];
|
|
1458
|
+
out.massKg = d ? out.volL * d : null;
|
|
1459
|
+
return out;
|
|
1460
|
+
}
|
|
1461
|
+
if (cu in LENGTH_TO_M) {
|
|
1462
|
+
out.kind = 'length-abs';
|
|
1463
|
+
out.lengthM = amount * LENGTH_TO_M[cu];
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
out.kind = 'unknown';
|
|
1467
|
+
return out;
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
// Sums + same-kind detection
|
|
1471
|
+
let totalMassKg = 0, totalVolL = 0, totalLengthM = 0;
|
|
1472
|
+
rows.forEach(r => {
|
|
1473
|
+
if (r.massKg != null) totalMassKg += r.massKg;
|
|
1474
|
+
if (r.volL != null) totalVolL += r.volL;
|
|
1475
|
+
if (r.lengthM != null) totalLengthM += r.lengthM;
|
|
1476
|
+
});
|
|
1477
|
+
const allWt = rows.length && rows.every(r => r.kind === 'wt-frac');
|
|
1478
|
+
const allVol = rows.length && rows.every(r => r.kind === 'vol-frac');
|
|
1479
|
+
const allLen = rows.length && rows.every(r => r.kind === 'length-abs');
|
|
1480
|
+
|
|
1481
|
+
// For pure wt% / vol% recipes, derive formulation density from densities
|
|
1482
|
+
// so we can also compute the *other* fraction (vol for wt%, mass for vol%).
|
|
1483
|
+
let wtFormKgPerL = null;
|
|
1484
|
+
let volFormKgPerL = null;
|
|
1485
|
+
if (allWt && rows.every(r => r.density)) {
|
|
1486
|
+
let invSum = 0; // sum over rows of (wt_frac / density)
|
|
1487
|
+
rows.forEach(r => { invSum += (r.amount / 100) / r.density; });
|
|
1488
|
+
if (invSum > 0) wtFormKgPerL = 1 / invSum;
|
|
1489
|
+
}
|
|
1490
|
+
if (allVol && rows.every(r => r.density)) {
|
|
1491
|
+
let sum = 0;
|
|
1492
|
+
rows.forEach(r => { sum += (r.amount / 100) * r.density; });
|
|
1493
|
+
if (sum > 0) volFormKgPerL = sum;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// Mixed-basis guard. A composition that combines PERCENTAGE rows (wt%/vol%)
|
|
1497
|
+
// with ABSOLUTE mass/volume rows (g, lb, mL, …) is under-determined: the
|
|
1498
|
+
// percentage rows carry no absolute mass, and the absolute rows carry no
|
|
1499
|
+
// percentage basis, so there is no shared 100% to normalize against. Without
|
|
1500
|
+
// this guard each group self-normalizes to ~100% and the two stack — e.g.
|
|
1501
|
+
// 25/25/50 wt% + one 5 lb row reports 200%. When we detect the mix we refuse
|
|
1502
|
+
// to fabricate a fraction (null it out) and flag `mixedBasis` so the UI can
|
|
1503
|
+
// explain and prompt for a single basis. (Length is a separate dimension and
|
|
1504
|
+
// is left alone.)
|
|
1505
|
+
const hasFracRow = rows.some(r => r.kind === 'wt-frac' || r.kind === 'vol-frac');
|
|
1506
|
+
const hasAbsMassVol = rows.some(r => r.kind === 'mass-abs' || r.kind === 'vol-abs');
|
|
1507
|
+
const mixedBasis = hasFracRow && hasAbsMassVol;
|
|
1508
|
+
|
|
1509
|
+
// Pass 2: fill in massFraction / volFraction per row
|
|
1510
|
+
rows.forEach(r => {
|
|
1511
|
+
if (mixedBasis && (r.kind === 'wt-frac' || r.kind === 'vol-frac' ||
|
|
1512
|
+
r.kind === 'mass-abs' || r.kind === 'vol-abs')) {
|
|
1513
|
+
// Ill-posed mix — no coherent fraction. Surface "—", not a fake number.
|
|
1514
|
+
r.massFraction = null;
|
|
1515
|
+
r.volFraction = null;
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
if (r.kind === 'wt-frac') {
|
|
1519
|
+
r.massFraction = r.amount / 100;
|
|
1520
|
+
r.volFraction = (wtFormKgPerL && r.density)
|
|
1521
|
+
? ((r.amount / 100) / r.density) * wtFormKgPerL
|
|
1522
|
+
: null;
|
|
1523
|
+
} else if (r.kind === 'vol-frac') {
|
|
1524
|
+
r.volFraction = r.amount / 100;
|
|
1525
|
+
r.massFraction = (volFormKgPerL && r.density)
|
|
1526
|
+
? ((r.amount / 100) * r.density) / volFormKgPerL
|
|
1527
|
+
: null;
|
|
1528
|
+
} else if (r.kind === 'mass-abs' || r.kind === 'vol-abs') {
|
|
1529
|
+
r.massFraction = (r.massKg != null && totalMassKg > 0) ? r.massKg / totalMassKg : null;
|
|
1530
|
+
r.volFraction = (r.volL != null && totalVolL > 0) ? r.volL / totalVolL : null;
|
|
1531
|
+
} else if (r.kind === 'length-abs') {
|
|
1532
|
+
r.lengthFraction = (r.lengthM != null && totalLengthM > 0) ? r.lengthM / totalLengthM : null;
|
|
1533
|
+
r.massFraction = null;
|
|
1534
|
+
r.volFraction = null;
|
|
1535
|
+
} else {
|
|
1536
|
+
r.massFraction = null;
|
|
1537
|
+
r.volFraction = null;
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
return { rows, totalMassKg, totalVolL, totalLengthM, allWt, allVol, allLen, mixedBasis };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Scale-to-batch: given a target batch (amount + unit), return the
|
|
1545
|
+
// absolute amount of each composition row in that unit. The math is
|
|
1546
|
+
// simply fraction × batchAmount, using mass fractions when the batch
|
|
1547
|
+
// unit is a mass and volume fractions when it's a volume — so it
|
|
1548
|
+
// works for %-recipes, absolute recipes, and mixed alike (the
|
|
1549
|
+
// fraction computation already normalizes them).
|
|
1550
|
+
//
|
|
1551
|
+
// Rows whose fraction can't be resolved in the chosen dimension
|
|
1552
|
+
// (e.g. a volume batch but the row lacks density) come back null so
|
|
1553
|
+
// the UI can show "—". Returns:
|
|
1554
|
+
// { unit, dim:'mass'|'volume', byRow: Map<compRowObj, number|null> } | null
|
|
1555
|
+
// Metric "ladders" for auto-scaling a value to the most readable unit
|
|
1556
|
+
// in its dimension. factor = base units (kg or L) per 1 of this unit.
|
|
1557
|
+
const _MASS_LADDER = [
|
|
1558
|
+
{ unit: 'µg', factor: 1e-9 }, { unit: 'mg', factor: 1e-6 },
|
|
1559
|
+
{ unit: 'g', factor: 1e-3 }, { unit: 'kg', factor: 1 }, { unit: 'ton', factor: 1000 },
|
|
1560
|
+
];
|
|
1561
|
+
const _VOL_LADDER = [
|
|
1562
|
+
{ unit: 'µL', factor: 1e-6 }, { unit: 'mL', factor: 1e-3 }, { unit: 'L', factor: 1 },
|
|
1563
|
+
];
|
|
1564
|
+
|
|
1565
|
+
// Re-express `value` (in `unit`) using the most readable unit of the
|
|
1566
|
+
// same dimension — the largest unit that keeps the magnitude >= 1.
|
|
1567
|
+
// Turns "0.00912 kg" into "9.12 g", "1500 kg" into "1.5 ton", etc.
|
|
1568
|
+
// Leaves the value untouched for non mass/volume units. Returns
|
|
1569
|
+
// { value, unit }.
|
|
1570
|
+
function smartScaleAmount(value, unit) {
|
|
1571
|
+
if (!isFinite(value)) return { value, unit };
|
|
1572
|
+
let ladder, base;
|
|
1573
|
+
if (unit in MASS_TO_KG) { ladder = _MASS_LADDER; base = value * MASS_TO_KG[unit]; }
|
|
1574
|
+
else if (unit in VOL_TO_L) { ladder = _VOL_LADDER; base = value * VOL_TO_L[unit]; }
|
|
1575
|
+
else return { value, unit };
|
|
1576
|
+
if (base === 0) return { value: 0, unit };
|
|
1577
|
+
const a = Math.abs(base);
|
|
1578
|
+
let chosen = ladder[0];
|
|
1579
|
+
for (const l of ladder) { if (a / l.factor >= 1) chosen = l; else break; }
|
|
1580
|
+
return { value: base / chosen.factor, unit: chosen.unit };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// → { unit, dim, byRow: Map<compRow, amount-in-batchUnit|null>, anyNull,
|
|
1584
|
+
// density?: { kgPerL, source: 'finished'|'ideal', how, batchId, temp },
|
|
1585
|
+
// totalMassKg?, partsSum? }
|
|
1586
|
+
//
|
|
1587
|
+
// A VOLUME target on a recipe is under-determined until you know the density
|
|
1588
|
+
// of the FINISHED mix: 250 mL of syrup weighs 250 × ρ_mix, and ρ_mix is not
|
|
1589
|
+
// the sum of parts (solutions contract or expand on mixing). So:
|
|
1590
|
+
// • finished density declared on the formula (measured on a batch / spec)
|
|
1591
|
+
// → total mass = V × ρ_mix; each row = its mass fraction of that; shown
|
|
1592
|
+
// in the batch unit via the ROW's own density (the volume you would
|
|
1593
|
+
// measure out of the pure ingredient). Row volumes therefore do NOT sum
|
|
1594
|
+
// to the target — `partsSum` says what they do sum to.
|
|
1595
|
+
// • no finished density → ideal-mix estimate, exactly as before: row volume
|
|
1596
|
+
// fractions × target (parts add up, which is the assumption).
|
|
1597
|
+
// Mass targets are unaffected either way.
|
|
1598
|
+
function scaleCompositionToBatch(formulation, batchAmount, batchUnit) {
|
|
1599
|
+
const amt = parseFloat(batchAmount);
|
|
1600
|
+
if (!isFinite(amt) || amt <= 0) return null;
|
|
1601
|
+
const dim = (batchUnit in MASS_TO_KG) ? 'mass'
|
|
1602
|
+
: (batchUnit in VOL_TO_L) ? 'volume'
|
|
1603
|
+
: null;
|
|
1604
|
+
if (!dim) return null;
|
|
1605
|
+
const fr = computeCompositionFractions(formulation);
|
|
1606
|
+
const comp = formulation.composition || [];
|
|
1607
|
+
const byRow = new Map();
|
|
1608
|
+
let anyNull = false;
|
|
1609
|
+
const pd = (typeof formulaPlanningDensity === 'function') ? formulaPlanningDensity(formulation) : null;
|
|
1610
|
+
const useFinished = dim === 'volume' && pd && pd.source === 'finished';
|
|
1611
|
+
if (useFinished) {
|
|
1612
|
+
const totalMassKg = amt * VOL_TO_L[batchUnit] * pd.kgPerL;
|
|
1613
|
+
let partsSum = 0;
|
|
1614
|
+
fr.rows.forEach((r, i) => {
|
|
1615
|
+
const mf = r.massFraction;
|
|
1616
|
+
const rowD = (r.density != null && isFinite(r.density) && r.density > 0) ? r.density : null; // kg/L
|
|
1617
|
+
const ok = (mf != null && isFinite(mf) && rowD != null);
|
|
1618
|
+
if (!ok) anyNull = true;
|
|
1619
|
+
const vol = ok ? ((mf * totalMassKg) / rowD) / VOL_TO_L[batchUnit] : null;
|
|
1620
|
+
if (ok) partsSum += vol;
|
|
1621
|
+
byRow.set(comp[i], vol);
|
|
1622
|
+
});
|
|
1623
|
+
return { unit: batchUnit, dim, byRow, anyNull, density: pd, totalMassKg, partsSum };
|
|
1624
|
+
}
|
|
1625
|
+
fr.rows.forEach((r, i) => {
|
|
1626
|
+
const frac = dim === 'mass' ? r.massFraction : r.volFraction;
|
|
1627
|
+
const ok = (frac != null && isFinite(frac));
|
|
1628
|
+
if (!ok) anyNull = true;
|
|
1629
|
+
byRow.set(comp[i], ok ? frac * amt : null);
|
|
1630
|
+
});
|
|
1631
|
+
const out = { unit: batchUnit, dim, byRow, anyNull };
|
|
1632
|
+
if (dim === 'volume' && pd) { out.density = pd; out.totalMassKg = amt * VOL_TO_L[batchUnit] * pd.kgPerL; out.partsSum = amt; }
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Effective on-hand amount of a batch for budget math (Tier 3). Once a batch
|
|
1637
|
+
// is Completed and has a logged actual yield, the budget reconciles against
|
|
1638
|
+
// that actual; otherwise it reserves against the planned target quantity.
|
|
1639
|
+
// Returns { amount, isActual } in the batch's own unit (actualYield shares
|
|
1640
|
+
// the target-quantity unit).
|
|
1641
|
+
function effectiveBatchAmount(batch) {
|
|
1642
|
+
if (!batch) return { amount: null, isActual: false };
|
|
1643
|
+
const ay = parseFloat(batch.actualYield);
|
|
1644
|
+
if (batch.status === 'Completed' && batch.actualYield != null && batch.actualYield !== '' && isFinite(ay) && ay >= 0) {
|
|
1645
|
+
return { amount: ay, isActual: true };
|
|
1646
|
+
}
|
|
1647
|
+
const tq = parseFloat(batch.targetQuantity);
|
|
1648
|
+
return { amount: isFinite(tq) ? tq : null, isActual: false };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Shared "Δ vs plan" severity tiers — one source of truth for the
|
|
1652
|
+
// planned-vs-actual deviation colors used in the DOE Matrix column and the
|
|
1653
|
+
// Compare chip, so the color key means the same thing everywhere.
|
|
1654
|
+
// <2 wt-% → on-target (green) · 2–10 → drift (amber) · ≥10 → off (red)
|
|
1655
|
+
function deviationTier(pct) {
|
|
1656
|
+
const p = Math.abs(parseFloat(pct));
|
|
1657
|
+
if (!isFinite(p)) return { tier: 'none', color: '#94a3b8', bg: 'transparent', label: '—' };
|
|
1658
|
+
if (p < 2) return { tier: 'on', color: '#15803d', bg: '#f0fdf4', label: 'on-target' };
|
|
1659
|
+
if (p < 10) return { tier: 'drift', color: '#b45309', bg: '#fffbeb', label: 'drift' };
|
|
1660
|
+
return { tier: 'off', color: '#b91c1c', bg: '#fef2f2', label: 'off-target' };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Plain-text tooltip + inline color legend for the deviation tiers.
|
|
1664
|
+
const DEVIATION_TIP = 'Δ vs plan = worst-case wt-% drift of the as-prepared composition from the proposal. Colors: under 2% on-target (green), 2–10% drift (amber), 10%+ off-target (red).';
|
|
1665
|
+
function deviationLegendHtml() {
|
|
1666
|
+
const dot = c => `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${c};vertical-align:middle;margin-right:3px"></span>`;
|
|
1667
|
+
return `<span title="${DEVIATION_TIP}" style="font-size:10.5px;color:#64748b;white-space:nowrap">Δ vs plan: ${dot('#15803d')}<2% ${dot('#b45309')}2–10% ${dot('#b91c1c')}≥10%</span>`;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Effective % active for grossing up a weigh-out amount of a technical
|
|
1671
|
+
// (impure) ingredient. Precedence:
|
|
1672
|
+
// 1. the ingredient's nominal `purity` (the stable spec / override), else
|
|
1673
|
+
// 2. the purity of the active lot we'd draw next (soonest expiry, then
|
|
1674
|
+
// oldest received) — so lot data entered on the CoA is used even with no
|
|
1675
|
+
// nominal set, else
|
|
1676
|
+
// 3. null → treat as 100% (no correction).
|
|
1677
|
+
// A nominal (or lot) value of ≥100 means "pure" → returns null (no correction).
|
|
1678
|
+
// Used by the batch AND sample weigh-outs so both correct identically. This is
|
|
1679
|
+
// a bench/weigh-out concept only — composition, cost, and property math keep
|
|
1680
|
+
// using the as-declared amounts.
|
|
1681
|
+
function effectivePurity(ing) {
|
|
1682
|
+
if (!ing) return null;
|
|
1683
|
+
const nom = parseFloat(ing.purity);
|
|
1684
|
+
if (isFinite(nom) && nom > 0) return nom < 100 ? nom : null;
|
|
1685
|
+
// No usable nominal → fall back to a representative active lot with purity.
|
|
1686
|
+
const lots = (typeof db !== 'undefined' && Array.isArray(db.lots))
|
|
1687
|
+
? db.lots.filter(l => l.ingredientId === ing.id
|
|
1688
|
+
&& (parseFloat(l.qtyRemaining) || 0) > 0
|
|
1689
|
+
&& (!l.status || l.status === 'active')
|
|
1690
|
+
&& l.purity != null && isFinite(parseFloat(l.purity)) && parseFloat(l.purity) > 0)
|
|
1691
|
+
: [];
|
|
1692
|
+
if (!lots.length) return null;
|
|
1693
|
+
lots.sort((a, b) => {
|
|
1694
|
+
const ax = a.expiryDate || '9999-12-31', bx = b.expiryDate || '9999-12-31';
|
|
1695
|
+
if (ax !== bx) return ax < bx ? -1 : 1;
|
|
1696
|
+
return (a.receivedDate || '').localeCompare(b.receivedDate || '');
|
|
1697
|
+
});
|
|
1698
|
+
const p = parseFloat(lots[0].purity);
|
|
1699
|
+
return (isFinite(p) && p > 0 && p < 100) ? p : null;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Lot-assay deviation warning. The weigh-out is grossed up on the ingredient's
|
|
1703
|
+
// NOMINAL purity (the design basis — see effectivePurity); a delivered lot may
|
|
1704
|
+
// assay differently. When the lot this row will draw from (the batch's consumed
|
|
1705
|
+
// lots, else the FIFO front lot) deviates from the nominal by ≥ PURITY_DEVIATION_WARN
|
|
1706
|
+
// absolute points, return the facts + the weigh amount that would deliver the
|
|
1707
|
+
// spec active at the lot's assay. Null when there's nothing to warn about.
|
|
1708
|
+
const PURITY_DEVIATION_WARN = 1; // absolute % points
|
|
1709
|
+
function lotPurityDeviation(ing, opts) {
|
|
1710
|
+
opts = opts || {};
|
|
1711
|
+
if (!ing || typeof db === 'undefined' || !Array.isArray(db.lots)) return null;
|
|
1712
|
+
const nominal = parseFloat(ing.purity);
|
|
1713
|
+
if (!(isFinite(nominal) && nominal > 0 && nominal < 100)) return null;
|
|
1714
|
+
let lots = [];
|
|
1715
|
+
if (opts.batchId && typeof lotsUsedInBatch === 'function') {
|
|
1716
|
+
lots = lotsUsedInBatch(db.stockMovements || [], opts.batchId)
|
|
1717
|
+
.map(u => db.lots.find(l => l && l.id === u.lotId)).filter(l => l && l.ingredientId === ing.id);
|
|
1718
|
+
}
|
|
1719
|
+
const consumed = lots.length > 0;
|
|
1720
|
+
if (!lots.length) {
|
|
1721
|
+
lots = db.lots.filter(l => l && l.ingredientId === ing.id && (parseFloat(l.qtyRemaining) || 0) > 0 && (!l.status || l.status === 'active'))
|
|
1722
|
+
.sort((a, b) => { const ax = a.expiryDate || '9999-12-31', bx = b.expiryDate || '9999-12-31'; return ax !== bx ? (ax < bx ? -1 : 1) : (a.receivedDate || '').localeCompare(b.receivedDate || ''); });
|
|
1723
|
+
}
|
|
1724
|
+
const lot = lots.find(l => l.purity != null && isFinite(parseFloat(l.purity)) && parseFloat(l.purity) > 0);
|
|
1725
|
+
if (!lot) return null;
|
|
1726
|
+
const p = parseFloat(lot.purity);
|
|
1727
|
+
const dev = p - nominal;
|
|
1728
|
+
if (Math.abs(dev) < PURITY_DEVIATION_WARN) return null;
|
|
1729
|
+
const amount = (opts.amount != null && isFinite(opts.amount)) ? opts.amount : null;
|
|
1730
|
+
return { lot, lotNumber: lot.lotNumber || lot.uid || '', lotPurity: p, nominal, deviation: +dev.toFixed(2), consumed,
|
|
1731
|
+
correctedAmount: amount != null ? amount * (nominal / p) : null };
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Gross up a single weigh-out amount for the row's ingredient purity (drift
|
|
1735
|
+
// model — other rows are NOT rebalanced). Returns the amount technicians
|
|
1736
|
+
// weigh, the purity used, and the active amount it delivers. Sub-formula rows
|
|
1737
|
+
// and rows with no purity data pass through unchanged.
|
|
1738
|
+
function correctAmountForPurity(comp, amt) {
|
|
1739
|
+
const passthrough = { amount: amt, purity: null, activeAmount: amt };
|
|
1740
|
+
if (!comp || !comp.ingredientId || amt == null || !isFinite(amt)) return passthrough;
|
|
1741
|
+
const ing = (typeof db !== 'undefined' && Array.isArray(db.ingredients))
|
|
1742
|
+
? db.ingredients.find(i => i.id === comp.ingredientId) : null;
|
|
1743
|
+
const p = effectivePurity(ing);
|
|
1744
|
+
if (p == null || !(p > 0) || p >= 100) return passthrough;
|
|
1745
|
+
return { amount: amt / (p / 100), purity: p, activeAmount: amt };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// A composition row carrying this role is the "balance / q.s." ingredient:
|
|
1749
|
+
// at weigh-out it's set to whatever amount holds the batch at its target size
|
|
1750
|
+
// (absorbing the purity gross-up), so concentrations stay on-spec. Kept in
|
|
1751
|
+
// sync with the role option lists in js/views/formulations.js.
|
|
1752
|
+
const QS_ROLE = 'Balance (q.s.)';
|
|
1753
|
+
|
|
1754
|
+
// Actual MASS of a set of as-prepared rows, in kg, from each row's OWN unit:
|
|
1755
|
+
// mass units directly; volume units × that ingredient's (or sub-formula's)
|
|
1756
|
+
// density — never the formula density, and never a sum of volumes. This is the
|
|
1757
|
+
// make-up-to-volume truth: weigh the solids, fill the solvent to the mark, log
|
|
1758
|
+
// what went in, and Σ row masses ÷ the mark volume is the finished density.
|
|
1759
|
+
// Returns { kg, n } or null when no row resolves / any comparable row can't.
|
|
1760
|
+
function sumActualRowsMassKg(rows, formula) {
|
|
1761
|
+
if (!Array.isArray(rows)) return null;
|
|
1762
|
+
let kg = 0, n = 0;
|
|
1763
|
+
for (const c of rows) {
|
|
1764
|
+
if (!c || !(c.ingredientId || c.formulationId)) continue;
|
|
1765
|
+
const amt = parseFloat(c.amount);
|
|
1766
|
+
if (!isFinite(amt)) continue;
|
|
1767
|
+
const u = (c.unit || '').trim();
|
|
1768
|
+
if (u in MASS_TO_KG) { kg += amt * MASS_TO_KG[u]; n++; continue; }
|
|
1769
|
+
if (u in VOL_TO_L) {
|
|
1770
|
+
let d = null;
|
|
1771
|
+
if (c.ingredientId) {
|
|
1772
|
+
const ing = (typeof db !== 'undefined' ? (db.ingredients || []) : []).find(i => i.id === c.ingredientId);
|
|
1773
|
+
d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
|
|
1774
|
+
} else if (typeof _rolledUpDensity === 'function') {
|
|
1775
|
+
const sub = (typeof db !== 'undefined' ? (db.formulations || []) : []).find(g => g.id === c.formulationId);
|
|
1776
|
+
d = sub ? _rolledUpDensity(sub) : null;
|
|
1777
|
+
}
|
|
1778
|
+
if (d == null || !isFinite(d) || d <= 0) return null;
|
|
1779
|
+
kg += amt * VOL_TO_L[u] * d; n++;
|
|
1780
|
+
}
|
|
1781
|
+
// % rows aren't absolute amounts — skipped, as everywhere else.
|
|
1782
|
+
}
|
|
1783
|
+
return n ? { kg, n } : null;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// Post-process weigh-out rows: if one row is the q.s. balance ingredient, set
|
|
1787
|
+
// its amount to targetQty − (sum of all other rows) so the total lands exactly
|
|
1788
|
+
// on target. Mutates rows in place. Returns { qsApplied, qsShort } — qsShort is
|
|
1789
|
+
// true when the other rows already exceed target (can't balance; balance
|
|
1790
|
+
// clamped to 0). Runs whenever a balance row exists, purity-corrected or not.
|
|
1791
|
+
function applyQsBalance(rows, targetQty) {
|
|
1792
|
+
const out = { qsApplied: false, qsShort: false };
|
|
1793
|
+
if (!Array.isArray(rows) || !isFinite(targetQty) || targetQty <= 0) return out;
|
|
1794
|
+
const bi = rows.findIndex(r => r && r.comp && r.comp.role === QS_ROLE);
|
|
1795
|
+
if (bi < 0) return out;
|
|
1796
|
+
let others = 0;
|
|
1797
|
+
rows.forEach((r, i) => { if (i !== bi && r.amount != null && isFinite(r.amount)) others += r.amount; });
|
|
1798
|
+
let bal = targetQty - others;
|
|
1799
|
+
out.qsApplied = true;
|
|
1800
|
+
out.qsShort = bal < -1e-9;
|
|
1801
|
+
if (out.qsShort) bal = 0;
|
|
1802
|
+
rows[bi].amount = bal;
|
|
1803
|
+
rows[bi].qs = true; // panel shows a q.s. tag
|
|
1804
|
+
rows[bi].purity = null; // the balance row isn't purity-corrected
|
|
1805
|
+
rows[bi].activeAmount = bal;
|
|
1806
|
+
return out;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Shared weigh-out panel (Tier 1/2). Renders planned per-ingredient amounts
|
|
1810
|
+
// and — once as-prepared actuals exist — Planned / Actual / Δ columns, all in
|
|
1811
|
+
// absolute amounts (the "more than percentages" bench view). Used by both
|
|
1812
|
+
// the batch detail and the sample modal.
|
|
1813
|
+
// w – result of _*PlannedWeighout(): { available, unit, qty, rows:[{key,name,amount,comp}], total, anyNull, reason, formula }
|
|
1814
|
+
// actuals – as-prepared composition rows ([{ ingredientId|formulationId, amount, unit }])
|
|
1815
|
+
// formula – linked formula (for density-aware unit conversion)
|
|
1816
|
+
// copyOnclick – onclick string for the "Copy into As-prepared" button
|
|
1817
|
+
// noun – 'batch' | 'sample' (hint wording)
|
|
1818
|
+
// Volume-target explainer under the weigh-out subtitle: which density turned
|
|
1819
|
+
// the target volume into a mass, where it came from, and (finished density)
|
|
1820
|
+
// that the per-row volumes are pure-ingredient volumes that do not add up to
|
|
1821
|
+
// the target because mixing isn't additive. Empty for mass targets.
|
|
1822
|
+
function _weighoutDensityLineHtml(w) {
|
|
1823
|
+
if (!w || !w.density || w.totalMassKg == null || !isFinite(w.totalMassKg)) return '';
|
|
1824
|
+
const d = w.density;
|
|
1825
|
+
const g = w.totalMassKg * 1000;
|
|
1826
|
+
const mass = g >= 1000 ? `${+(g / 1000).toFixed(3)} kg` : `${+g.toFixed(1)} g`;
|
|
1827
|
+
const rho = `${+d.kgPerL.toFixed(4)} g/mL`;
|
|
1828
|
+
const temp = (d.temp != null && d.temp !== '') ? ` @ ${esc(String(d.temp))} °C` : '';
|
|
1829
|
+
const qty = `${w.qty} ${esc(w.unit)}`;
|
|
1830
|
+
if (d.source === 'finished') {
|
|
1831
|
+
let src = d.how === 'measured' ? 'measured' : (d.how === 'spec' ? 'from spec' : 'entered');
|
|
1832
|
+
if (d.batchId && typeof db !== 'undefined') {
|
|
1833
|
+
const b = (db.batches || []).find(x => x.id === d.batchId);
|
|
1834
|
+
if (b) src += ` on <a href="#" onclick="navigate('batch-detail','${esc(b.id)}');return false" style="color:#2563eb">${esc(b.uid)}</a>`;
|
|
1835
|
+
}
|
|
1836
|
+
const parts = (w.partsSum != null && isFinite(w.partsSum) && Math.abs(w.partsSum - w.qty) > 1e-6)
|
|
1837
|
+
? ` Pure-ingredient volumes sum to ${+w.partsSum.toFixed(2)} ${esc(w.unit)} — volumes aren't additive on mixing; the masses are what to weigh.` : '';
|
|
1838
|
+
return `<div style="margin-top:4px;font-size:11.5px;color:#0f172a"><strong>${qty} ≙ ${mass}</strong> at finished density <strong>${rho}</strong>${temp} <span style="color:#64748b">(${src})</span>.${parts}</div>`;
|
|
1839
|
+
}
|
|
1840
|
+
return `<div style="margin-top:4px;font-size:11.5px;color:#92400e"><strong>${qty} ≙ ${mass}</strong> at <strong>${rho}</strong> — an <strong>ideal-mix estimate</strong> from the ingredient densities (assumes volumes add up; real mixes contract or expand). Record a measured density on a batch and adopt it as the formula's finished density to make this exact.</div>`;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
|
|
1844
|
+
const fmt = n => (n == null || !isFinite(n)) ? '—' : parseFloat(n.toFixed(4)).toString();
|
|
1845
|
+
const sgn = n => (n == null || !isFinite(n)) ? '—' : (n > 0 ? '+' : '') + parseFloat(n.toFixed(4)).toString();
|
|
1846
|
+
if (!w || !w.available) {
|
|
1847
|
+
if (w && w.reason === 'bad-unit')
|
|
1848
|
+
return `<div style="padding:8px 11px;margin-bottom:12px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;color:#92400e;font-size:12px"><span class="fl-warn" aria-hidden="true"></span> Set the ${noun} quantity in a <strong>mass or volume unit</strong> (g, kg, mL, L…) to get a weigh-out sheet — currently <strong>${esc(w.unit || '—')}</strong>.</div>`;
|
|
1849
|
+
if (w && w.reason === 'no-qty')
|
|
1850
|
+
return `<div style="padding:8px 11px;margin-bottom:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1e40af;font-size:12px">Set a <strong>${noun} quantity</strong> to compute the per-ingredient weigh-out amounts technicians need.</div>`;
|
|
1851
|
+
return '';
|
|
1852
|
+
}
|
|
1853
|
+
// Map as-prepared actuals by ingredient / sub-formula key. Only absolute
|
|
1854
|
+
// (mass/volume) rows are comparable to the planned weigh-out — a row left in
|
|
1855
|
+
// % is a concentration figure, handled by the proposal-vs-actual %-diff, not
|
|
1856
|
+
// here, so it's skipped (the panel stays planned-only in that case).
|
|
1857
|
+
const actMap = new Map();
|
|
1858
|
+
(Array.isArray(actuals) ? actuals : []).forEach(c => {
|
|
1859
|
+
const key = c.formulationId ? ('f:' + c.formulationId) : (c.ingredientId ? ('i:' + c.ingredientId) : '');
|
|
1860
|
+
const amt = parseFloat(c.amount);
|
|
1861
|
+
const u = c.unit || '';
|
|
1862
|
+
const comparable = (typeof isMassUnit === 'function' && isMassUnit(u)) || (typeof isVolUnit === 'function' && isVolUnit(u));
|
|
1863
|
+
if (key && comparable && isFinite(amt)) actMap.set(key, { amount: amt, unit: u });
|
|
1864
|
+
});
|
|
1865
|
+
const hasActuals = actMap.size > 0;
|
|
1866
|
+
// ---- Display unit: show the whole sheet in any unit of the SAME dimension as
|
|
1867
|
+
// the batch's target unit (a per-device preference), converting every amount
|
|
1868
|
+
// through convertQuantity. Defaults to the batch unit; keeps one consistent
|
|
1869
|
+
// unit per sheet (never mixed per row).
|
|
1870
|
+
const _woDim = (typeof isMassUnit === 'function' && isMassUnit(w.unit)) ? 'mass'
|
|
1871
|
+
: (typeof isVolUnit === 'function' && isVolUnit(w.unit)) ? 'volume' : null;
|
|
1872
|
+
let displayUnit = w.unit;
|
|
1873
|
+
if (_woDim) {
|
|
1874
|
+
let pref = null; try { pref = localStorage.getItem('formlab_weighout_unit_' + _woDim); } catch (_) {}
|
|
1875
|
+
if (pref && pref !== w.unit && typeof convertQuantity === 'function') {
|
|
1876
|
+
const t = convertQuantity(1, w.unit, pref, formula);
|
|
1877
|
+
if (t != null && isFinite(t)) displayUnit = pref;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const conv = amt => {
|
|
1881
|
+
if (amt == null || !isFinite(amt)) return null;
|
|
1882
|
+
if (displayUnit === w.unit) return amt;
|
|
1883
|
+
const v = (typeof convertQuantity === 'function') ? convertQuantity(amt, w.unit, displayUnit, formula) : null;
|
|
1884
|
+
return (v == null || !isFinite(v)) ? null : v;
|
|
1885
|
+
};
|
|
1886
|
+
const cv = amt => fmt(conv(amt)); // convert + format
|
|
1887
|
+
const cvs = amt => sgn(conv(amt)); // convert + signed format
|
|
1888
|
+
// Offer EVERY supported unit of the batch's dimension, deduped by conversion
|
|
1889
|
+
// factor (so µg/ug/mcg or t/ton/tonne collapse to one canonical spelling),
|
|
1890
|
+
// plus the batch's own unit and any custom units — sorted smallest-first.
|
|
1891
|
+
let _woUnits = [];
|
|
1892
|
+
if (_woDim) {
|
|
1893
|
+
const src = _woDim === 'mass' ? MASS_TO_KG : VOL_TO_L;
|
|
1894
|
+
const customs = _woDim === 'mass' ? (typeof _customMassUnits === 'function' ? _customMassUnits() : {}) : (typeof _customVolUnits === 'function' ? _customVolUnits() : {});
|
|
1895
|
+
const byFactor = new Map();
|
|
1896
|
+
Object.keys(src).forEach(u => { const f = src[u]; if (!byFactor.has(f)) byFactor.set(f, u); });
|
|
1897
|
+
const set = new Set([w.unit, ...byFactor.values()]);
|
|
1898
|
+
Object.keys(customs || {}).forEach(u => set.add(u));
|
|
1899
|
+
const factorOf = u => { const f = (u in src) ? src[u] : (customs && customs[u]); return (typeof f === 'number') ? f : 1; };
|
|
1900
|
+
_woUnits = [...set].sort((a, b) => factorOf(a) - factorOf(b));
|
|
1901
|
+
}
|
|
1902
|
+
const _woResetBtn = (_woDim && displayUnit !== w.unit)
|
|
1903
|
+
? `<button type="button" onclick="_weighoutResetDisplayUnit('${_woDim}')" title="Reset to the batch's target unit (${esc(w.unit)})" style="font-size:11px;padding:2px 7px;border:1px solid var(--border);border-radius:5px;background:#fff;color:#64748b;cursor:pointer;white-space:nowrap">↺ ${esc(w.unit)}</button>`
|
|
1904
|
+
: '';
|
|
1905
|
+
const unitDropdown = (_woDim && _woUnits.length > 1)
|
|
1906
|
+
? `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin-left:16px">
|
|
1907
|
+
<label style="font-size:11px;color:#64748b;display:inline-flex;align-items:center;gap:4px;white-space:nowrap">show in
|
|
1908
|
+
<select onchange="_weighoutSetDisplayUnit('${_woDim}', this.value)" title="Display the weigh-out amounts in this unit; the sheet stays in one consistent unit" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:#fff">
|
|
1909
|
+
${_woUnits.map(u => `<option value="${esc(u)}" ${u === displayUnit ? 'selected' : ''}>${esc(u)}</option>`).join('')}
|
|
1910
|
+
</select></label>
|
|
1911
|
+
${_woResetBtn}
|
|
1912
|
+
</span>`
|
|
1913
|
+
: '';
|
|
1914
|
+
// A logged actual in another unit converts to the sheet's unit through THAT
|
|
1915
|
+
// ROW's density (ingredient / sub-formula) — never the formula's. Cross-
|
|
1916
|
+
// dimension with no row density → null (shown raw, not fabricated).
|
|
1917
|
+
const toUnit = (amt, from, key) => {
|
|
1918
|
+
if (amt == null || !isFinite(amt)) return null;
|
|
1919
|
+
if (!from || from === w.unit) return amt;
|
|
1920
|
+
if (typeof convertQuantityAtDensity !== 'function') return null;
|
|
1921
|
+
const v = convertQuantityAtDensity(amt, from, w.unit, rowDensityKgPerL(key));
|
|
1922
|
+
return (v == null || !isFinite(v)) ? null : v;
|
|
1923
|
+
};
|
|
1924
|
+
let actualTotal = 0;
|
|
1925
|
+
const rows = w.rows.map(r => {
|
|
1926
|
+
const a = actMap.get(r.key);
|
|
1927
|
+
const planned = (r.amount != null && isFinite(r.amount)) ? r.amount : null;
|
|
1928
|
+
let actInUnit = null;
|
|
1929
|
+
if (a) { actInUnit = toUnit(a.amount, a.unit, r.key); if (actInUnit != null) actualTotal += actInUnit; }
|
|
1930
|
+
const delta = (hasActuals && planned != null && actInUnit != null) ? (actInUnit - planned) : null;
|
|
1931
|
+
const rel = (delta != null && planned > 0) ? Math.abs(delta) / planned : (delta != null && actInUnit > 0 ? 1 : null);
|
|
1932
|
+
return { key: r.key, name: r.name, planned, actInUnit, actRaw: a, delta, rel, purity: r.purity, activeAmount: r.activeAmount, qs: r.qs, lotDev: r.lotDev || null };
|
|
1933
|
+
});
|
|
1934
|
+
// Union in ingredients that were ADDED to the As-prepared table but aren't in
|
|
1935
|
+
// the recipe (a spike / colorant / q.s. carrier). Without this the weigh-out
|
|
1936
|
+
// silently drops them from the comparison AND from the Actual total, so the
|
|
1937
|
+
// total under-reports. They show with no Planned, their Actual amount, and a
|
|
1938
|
+
// full deviation (they're all "extra").
|
|
1939
|
+
if (hasActuals) {
|
|
1940
|
+
const plannedKeys = new Set(w.rows.map(r => r.key));
|
|
1941
|
+
actMap.forEach((a, key) => {
|
|
1942
|
+
if (plannedKeys.has(key)) return;
|
|
1943
|
+
const actInUnit = toUnit(a.amount, a.unit, key);
|
|
1944
|
+
if (actInUnit != null) actualTotal += actInUnit;
|
|
1945
|
+
let name = '(added)';
|
|
1946
|
+
if (key.slice(0, 2) === 'i:') { const ing = (typeof db !== 'undefined' && db.ingredients || []).find(i => i.id === key.slice(2)); name = ing ? ing.name : '(added ingredient)'; }
|
|
1947
|
+
else if (key.slice(0, 2) === 'f:') { const ff = (typeof db !== 'undefined' && db.formulations || []).find(x => x.id === key.slice(2)); name = ff ? '▸ ' + ff.name : '(added sub-formula)'; }
|
|
1948
|
+
rows.push({ key, name, planned: null, actInUnit, actRaw: a, delta: actInUnit, rel: (actInUnit > 0 ? 1 : null), purity: null, activeAmount: null, qs: false, _added: true });
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
const _rNumFmt = v => (typeof _fmpNum === 'function') ? _fmpNum(v) : (Math.abs(v) >= 100 ? (+v).toFixed(1) : (+v).toFixed(3)).replace(/\.?0+$/, '');
|
|
1952
|
+
const _purityTagBase = r => (r.purity == null) ? '' :
|
|
1953
|
+
` <span style="font-size:9.5px;color:#6d28d9;font-weight:600;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Grossed up for ${esc(String(+r.purity))}% purity, delivers ${cv(r.activeAmount)} ${esc(displayUnit)} of active">↑${esc(String(+r.purity))}%</span>`;
|
|
1954
|
+
// Lot-assay deviation: the lot this row draws from assays away from the nominal.
|
|
1955
|
+
const purityTag = r => _purityTagBase(r) + (!r.lotDev ? '' :
|
|
1956
|
+
` <span style="font-size:9.5px;color:#b45309;font-weight:600;background:#fffbeb;border:1px solid #fde68a;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Lot ${esc(String(r.lotDev.lotNumber))} assays ${esc(String(+r.lotDev.lotPurity))}% but the weigh-out is grossed up on the nominal ${esc(String(+r.lotDev.nominal))}%. ${r.lotDev.consumed ? 'This batch drew from that lot.' : 'FIFO will draw from that lot next.'} To deliver the spec active at the lot's assay, weigh ${r.lotDev.correctedAmount != null ? esc(_rNumFmt(r.lotDev.correctedAmount)) + ' ' + esc(String(displayUnit || w.unit || '')) : 'the corrected amount'} instead.">⚠ lot ${esc(String(+r.lotDev.lotPurity))}% vs ${esc(String(+r.lotDev.nominal))}%${r.lotDev.correctedAmount != null ? ' → ' + esc(_rNumFmt(r.lotDev.correctedAmount)) : ''}</span>`);
|
|
1957
|
+
const _woVolTarget = (typeof isVolUnit === 'function') && isVolUnit(w.unit);
|
|
1958
|
+
const qsTag = r => (!r.qs) ? '' :
|
|
1959
|
+
` <span style="font-size:9.5px;color:#0369a1;font-weight:600;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="${_woVolTarget ? `Balance ingredient — add it up to the ${w.qty} ${esc(w.unit)} mark rather than to this number, then log what actually went in` : 'Balance ingredient, set to hold the batch at its target size'}">${_woVolTarget ? `q.s. to ${w.qty} ${esc(w.unit)}` : 'q.s.'}</span>`;
|
|
1960
|
+
const dColor = rel => rel == null ? '#94a3b8' : (rel < 0.02 ? '#15803d' : rel < 0.10 ? '#b45309' : '#b91c1c');
|
|
1961
|
+
// Tint a whole row when it deviates, so off-plan ingredients pop at a glance
|
|
1962
|
+
// (matches the composition-% table's row tinting). On-target rows stay white.
|
|
1963
|
+
const dRowBg = rel => (rel == null || rel < 0.02) ? '' : (rel < 0.10 ? 'background:#fffbeb' : 'background:#fef2f2');
|
|
1964
|
+
// ---- Secondary reference column: each row shown in the OTHER dimension,
|
|
1965
|
+
// derived per-row from that ingredient's OWN density (sub-formulas use their
|
|
1966
|
+
// rolled-up density). The prepared unit stays the single basis; this is a
|
|
1967
|
+
// read-only convenience for batches that mix weighed and measured rows.
|
|
1968
|
+
// A row with no density shows — (never guessed from the formula aggregate),
|
|
1969
|
+
// and there's no total (the derived dimension isn't additive across rows).
|
|
1970
|
+
const _altDim = _woDim === 'mass' ? 'volume' : (_woDim === 'volume' ? 'mass' : null);
|
|
1971
|
+
const _ALT_SIBLING = { kg: 'L', g: 'mL', mg: 'µL', L: 'kg', mL: 'g', 'µL': 'mg' };
|
|
1972
|
+
// Units offered for the ≈ column — every supported unit of the ALT dimension,
|
|
1973
|
+
// deduped by conversion factor + any customs, smallest-first (mirrors the
|
|
1974
|
+
// show-in list). The column unit is a per-device preference; with none set it
|
|
1975
|
+
// auto-tracks the display unit's scale (g→mL, kg→L) via _ALT_SIBLING.
|
|
1976
|
+
let _altUnits = [];
|
|
1977
|
+
if (_altDim) {
|
|
1978
|
+
const src = _altDim === 'mass' ? MASS_TO_KG : VOL_TO_L;
|
|
1979
|
+
const customs = _altDim === 'mass' ? (typeof _customMassUnits === 'function' ? _customMassUnits() : {}) : (typeof _customVolUnits === 'function' ? _customVolUnits() : {});
|
|
1980
|
+
const byFactor = new Map();
|
|
1981
|
+
Object.keys(src).forEach(u => { const f = src[u]; if (!byFactor.has(f)) byFactor.set(f, u); });
|
|
1982
|
+
const set = new Set([...byFactor.values()]);
|
|
1983
|
+
Object.keys(customs || {}).forEach(u => set.add(u));
|
|
1984
|
+
const factorOf = u => { const f = (u in src) ? src[u] : (customs && customs[u]); return (typeof f === 'number') ? f : 1; };
|
|
1985
|
+
_altUnits = [...set].sort((a, b) => factorOf(a) - factorOf(b));
|
|
1986
|
+
}
|
|
1987
|
+
let _altPref = null; try { _altPref = _altDim ? localStorage.getItem('formlab_weighout_altunit_' + _altDim) : null; } catch (_) {}
|
|
1988
|
+
const altUnit = _altDim
|
|
1989
|
+
? ((_altPref && _altUnits.indexOf(_altPref) !== -1) ? _altPref : (_ALT_SIBLING[displayUnit] || (_altDim === 'volume' ? 'mL' : 'g')))
|
|
1990
|
+
: null;
|
|
1991
|
+
const rowDensity = r => {
|
|
1992
|
+
const key = (r && r.key) || '';
|
|
1993
|
+
if (key.slice(0, 2) === 'f:') { const sf = db.formulations.find(g => g.id === key.slice(2)); return (sf && typeof _rolledUpDensity === 'function') ? _rolledUpDensity(sf) : null; }
|
|
1994
|
+
if (key.slice(0, 2) === 'i:') { const ing = db.ingredients.find(i => i.id === key.slice(2)); return (ing && typeof _densityToKgPerL === 'function') ? _densityToKgPerL(ing.density, ing.densityUnit) : null; }
|
|
1995
|
+
return null;
|
|
1996
|
+
};
|
|
1997
|
+
const altConv = (amt, d) => {
|
|
1998
|
+
if (amt == null || !isFinite(amt) || d == null || !isFinite(d) || d <= 0 || !altUnit) return null;
|
|
1999
|
+
if (_woDim === 'mass') { const kg = amt * massToKg(w.unit); const b = volToL(altUnit); return (b != null) ? (kg / d) / b : null; }
|
|
2000
|
+
if (_woDim === 'volume') { const L = amt * volToL(w.unit); const b = massToKg(altUnit); return (b != null) ? (L * d) / b : null; }
|
|
2001
|
+
return null;
|
|
2002
|
+
};
|
|
2003
|
+
// ≈ column shows the row's PRIMARY amount in the other dimension — planned for
|
|
2004
|
+
// recipe rows, but the ACTUAL amount for an added ("not in recipe") row, which
|
|
2005
|
+
// has no planned. Otherwise a density-bearing add (e.g. TiO2) shows a blank ≈.
|
|
2006
|
+
const _altAmt = r => (r.planned != null) ? r.planned : r.actInUnit;
|
|
2007
|
+
const altCell = r => {
|
|
2008
|
+
const v = altConv(_altAmt(r), rowDensity(r));
|
|
2009
|
+
if (v == null) return `<span style="color:#cbd5e1" title="No density set for this ingredient — add one to show its ${esc(_altDim || '')} equivalent">—</span>`;
|
|
2010
|
+
return `≈ ${fmt(v)} <span translate="no" style="color:#94a3b8;font-size:10px">${esc(altUnit)}</span>`;
|
|
2011
|
+
};
|
|
2012
|
+
const _altAny = !!_altDim && rows.some(r => altConv(_altAmt(r), rowDensity(r)) != null);
|
|
2013
|
+
// Unit picker for the ≈ column (mirrors the show-in dropdown). Only when the
|
|
2014
|
+
// column is actually showing and there's more than one unit to choose.
|
|
2015
|
+
const altDropdown = (_altAny && _altDim && _altUnits.length > 1)
|
|
2016
|
+
? `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin-left:10px">
|
|
2017
|
+
<label style="font-size:11px;color:#64748b;display:inline-flex;align-items:center;gap:4px;white-space:nowrap">≈ in
|
|
2018
|
+
<select onchange="_weighoutSetAltUnit('${_altDim}', this.value)" title="Unit for the ≈ reference column — the other dimension, derived per-ingredient from its density" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:#fff">
|
|
2019
|
+
${_altUnits.map(u => `<option value="${esc(u)}" ${u === altUnit ? 'selected' : ''}>${esc(u)}</option>`).join('')}
|
|
2020
|
+
</select></label>
|
|
2021
|
+
</span>`
|
|
2022
|
+
: '';
|
|
2023
|
+
const th = (txt, extra = '') => `<th style="padding:5px 8px;color:#475569;font-weight:400;${extra}">${txt}</th>`;
|
|
2024
|
+
const head = `<tr style="background:#eff6ff">
|
|
2025
|
+
${th('#', 'text-align:left;width:28px')}
|
|
2026
|
+
${th('Ingredient / Sub-formula', 'text-align:left')}
|
|
2027
|
+
${th('Planned', 'text-align:right')}
|
|
2028
|
+
${hasActuals ? th('Actual', 'text-align:right') + th('Δ', 'text-align:right') : ''}
|
|
2029
|
+
${th('Unit', 'text-align:left')}
|
|
2030
|
+
${_altAny ? `<th style="padding:5px 8px;color:#475569;font-weight:400;text-align:right" title="Approx. ${esc(_altDim || '')} equivalent, derived per-ingredient from its own density. Reference only — not additive, so no total.">≈ ${esc(altUnit)}</th>` : ''}
|
|
2031
|
+
</tr>`;
|
|
2032
|
+
const body = rows.map((r, i) => `<tr data-wo-key="${esc(r.key)}" style="${hasActuals ? dRowBg(r.rel) : ''}">
|
|
2033
|
+
<td style="padding:4px 8px;color:#94a3b8">${i + 1}</td>
|
|
2034
|
+
<td style="padding:4px 8px">${esc(r.name)}${r._added ? ` <span style="font-size:9.5px;color:#b91c1c;font-weight:600;background:#fef2f2;border:1px solid #fecaca;border-radius:4px;padding:0 4px;vertical-align:middle;white-space:nowrap" title="Added to the as-prepared batch — not part of the linked recipe">+ not in recipe</span>` : ''}</td>
|
|
2035
|
+
<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;color:#0f172a">${r.planned != null ? `${cv(r.planned)}${purityTag(r)}${qsTag(r)}` : '<span style="color:#cbd5e1">—</span>'}</td>
|
|
2036
|
+
${hasActuals ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums">${
|
|
2037
|
+
r.actInUnit != null ? cv(r.actInUnit)
|
|
2038
|
+
: (r.actRaw ? `${fmt(r.actRaw.amount)} <span translate="no" style="color:#94a3b8;font-size:10px">${esc(r.actRaw.unit)}</span>` : '<span style="color:#cbd5e1">—</span>')
|
|
2039
|
+
}</td>
|
|
2040
|
+
<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:${dColor(r.rel)};font-weight:${r.delta ? 600 : 400}">${r.delta != null ? cvs(r.delta) : '—'}</td>` : ''}
|
|
2041
|
+
<td style="padding:4px 8px;color:#64748b">${esc(displayUnit)}</td>
|
|
2042
|
+
${_altAny ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:#64748b">${altCell(r)}</td>` : ''}
|
|
2043
|
+
</tr>`).join('');
|
|
2044
|
+
const totalDelta = hasActuals ? (actualTotal - w.total) : null;
|
|
2045
|
+
// Volume target: the Planned column's sum is the sum of PURE-ingredient
|
|
2046
|
+
// volumes — not a lab quantity (the finished batch is w.qty, and mixing isn't
|
|
2047
|
+
// additive). Say so on the row, and put the number that IS real — the total
|
|
2048
|
+
// mass to weigh, V × ρ — in the ≈ mass column instead of a dash.
|
|
2049
|
+
const _volTarget = _woVolTarget && w.totalMassKg != null && isFinite(w.totalMassKg);
|
|
2050
|
+
const _totalMassCell = (() => {
|
|
2051
|
+
if (!_volTarget || !_altAny || _altDim !== 'mass') return null;
|
|
2052
|
+
const g = w.totalMassKg * 1000;
|
|
2053
|
+
const inAlt = (typeof convertQuantity === 'function') ? convertQuantity(g, 'g', altUnit, formula) : null;
|
|
2054
|
+
return (inAlt != null && isFinite(inAlt)) ? `<strong>${fmt(inAlt)}</strong> <span translate="no" style="color:#94a3b8;font-size:10px">${esc(altUnit)}</span>` : null;
|
|
2055
|
+
})();
|
|
2056
|
+
const tfoot = `<tr style="border-top:1px solid #dbeafe">
|
|
2057
|
+
<td></td><td style="padding:5px 8px;font-weight:600">Total${_volTarget ? ` <span style="font-weight:400;color:#94a3b8;font-size:10.5px" title="Sum of the pure-ingredient volumes in the Planned column. Not a lab quantity — the finished batch is ${w.qty} ${esc(w.unit)}; the mass in the ≈ column is what the batch weighs.">(pure-ingredient volumes)</span>` : ''}</td>
|
|
2058
|
+
<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:${_volTarget ? 400 : 700};color:${_volTarget ? '#94a3b8' : 'inherit'}" title="${_volTarget ? `Volumes aren't additive on mixing — this sum has no bench meaning. Finished batch: ${w.qty} ${esc(w.unit)}.` : ''}">${cv(w.total)}</td>
|
|
2059
|
+
${hasActuals ? `<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:700">${cv(actualTotal)}</td>
|
|
2060
|
+
<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;color:#64748b">${cvs(totalDelta)}</td>` : ''}
|
|
2061
|
+
<td style="padding:5px 8px;color:#64748b">${esc(displayUnit)}</td>
|
|
2062
|
+
${_altAny ? (_totalMassCell
|
|
2063
|
+
? `<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums" title="Total mass of the finished ${w.qty} ${esc(w.unit)} = volume × finished density. This is what the batch weighs — the number to reconcile the balance against.">${_totalMassCell}</td>`
|
|
2064
|
+
: `<td style="padding:5px 8px;text-align:right;color:#cbd5e1" title="No total — the ${esc(_altDim || '')} equivalent isn't additive across ingredients">—</td>`) : ''}
|
|
2065
|
+
</tr>`;
|
|
2066
|
+
const densityNote = w.anyNull
|
|
2067
|
+
? `<div style="margin-top:7px;font-size:11.5px;color:#92400e"><span class="fl-warn" aria-hidden="true"></span> Some rows can't be scaled to ${esc(displayUnit)} — set the formula / ingredient <strong>density</strong> to convert % across mass↔volume.</div>`
|
|
2068
|
+
: '';
|
|
2069
|
+
// Purity / q.s. footer. Three cases: q.s. balanced (on target), q.s. short
|
|
2070
|
+
// (actives exceed target), or drift (corrected, no balance ingredient).
|
|
2071
|
+
let purityNote = '';
|
|
2072
|
+
if (w.qsApplied && w.qsShort) {
|
|
2073
|
+
purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#b91c1c"><span class="fl-warn" aria-hidden="true"></span> Purity-corrected amounts already <strong>exceed the ${cv(w.qty)} ${esc(displayUnit)} target</strong> — the q.s. ingredient can't balance it (clamped to 0). Increase the batch size or reduce the actives.</div>`;
|
|
2074
|
+
} else if (w.qsApplied && w.correctedAny) {
|
|
2075
|
+
purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#0369a1">↑ Actives <strong>grossed up for purity</strong>; the <strong>q.s. ingredient</strong> was shrunk to hold the batch at <strong>${cv(w.total)} ${esc(displayUnit)}</strong> — on target, concentrations on-spec.</div>`;
|
|
2076
|
+
} else if (w.correctedAny && w.nominalTotal != null && isFinite(w.nominalTotal)) {
|
|
2077
|
+
purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#5b21b6">↑ Amounts <strong>grossed up for purity</strong> so each delivers its target active. Total <strong>${cv(w.total)} ${esc(displayUnit)}</strong> vs ${cv(w.nominalTotal)} nominal (+${cv(w.total - w.nominalTotal)}). Other rows are unchanged, so the batch runs a little over target — mark a diluent's role <strong>“${esc(QS_ROLE)}”</strong> to auto-balance back to ${cv(w.qty)} ${esc(displayUnit)}.</div>`;
|
|
2078
|
+
}
|
|
2079
|
+
// Make-up-to-volume instruction: a VOLUME target with a balance row is best
|
|
2080
|
+
// made the pharmacist's way — weigh everything else, fill the balance
|
|
2081
|
+
// ingredient to the mark, log what went in. Nobody has to predict the
|
|
2082
|
+
// contraction; the logged masses ÷ the mark give the finished density, which
|
|
2083
|
+
// the batch page then offers to adopt on the formula.
|
|
2084
|
+
let makeUpNote = '';
|
|
2085
|
+
const _qsRow = w.rows.find(r => r.qs);
|
|
2086
|
+
if (_woVolTarget && w.qsApplied && _qsRow && !hasActuals) {
|
|
2087
|
+
const bal = (_qsRow.amount != null && isFinite(_qsRow.amount)) ? _qsRow.amount : null;
|
|
2088
|
+
const balMass = (bal != null && typeof convertQuantity === 'function' && _qsRow.comp && _qsRow.comp.ingredientId)
|
|
2089
|
+
? (() => { const ing = (db.ingredients || []).find(i => i.id === _qsRow.comp.ingredientId); const d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null; return (d && VOL_TO_L[w.unit]) ? bal * VOL_TO_L[w.unit] * d * 1000 : null; })() : null;
|
|
2090
|
+
makeUpNote = `<div style="margin-top:7px;padding:7px 10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-size:11.5px;color:#0c4a6e;line-height:1.45"><strong>Make up to volume.</strong> Weigh the other rows as planned, then add <strong>${esc(_qsRow.name)}</strong> up to the <strong>${w.qty} ${esc(w.unit)}</strong> mark and log the amount that actually went in${balMass != null ? ` (planned ≈ ${+balMass.toFixed(1)} g)` : ''}. Volumes aren't additive, so the mark — not the planned number — decides the balance. Once logged, the batch page derives the finished density from the total mass ÷ ${w.qty} ${esc(w.unit)} and offers to save it on the formula for next time.</div>`;
|
|
2091
|
+
}
|
|
2092
|
+
const title = hasActuals ? '⚖ Weigh-out — planned vs actual amounts' : '⚖ Weigh-out — target amounts';
|
|
2093
|
+
// One-line explainer so this table's job (concrete amounts) is unmistakable
|
|
2094
|
+
// and distinct from the composition-% table below it on the batch page.
|
|
2095
|
+
const subtitle = (hasActuals
|
|
2096
|
+
? `How much of each ingredient to weigh, in <strong>${esc(displayUnit)}</strong>. <strong>Planned</strong> = the recipe scaled to ${cv(w.qty)} ${esc(displayUnit)}; <strong>Actual</strong> = what you logged as prepared.`
|
|
2097
|
+
: `How much of each ingredient to weigh for a ${cv(w.qty)} ${esc(displayUnit)} ${esc(noun)}, in <strong>${esc(displayUnit)}</strong>.`)
|
|
2098
|
+
+ _weighoutDensityLineHtml(w);
|
|
2099
|
+
// Yield headline: the as-prepared input total vs the batch target, up front —
|
|
2100
|
+
// so a proportional over/under-make reads as a yield note, not as a mystery
|
|
2101
|
+
// (this is the "why is it +0.2 kg but composition matches" answer, surfaced).
|
|
2102
|
+
let yieldBanner = '';
|
|
2103
|
+
if (hasActuals && isFinite(actualTotal) && actualTotal > 0 && isFinite(w.qty) && w.qty > 0) {
|
|
2104
|
+
const d = actualTotal - w.qty;
|
|
2105
|
+
const pct = (d / w.qty) * 100;
|
|
2106
|
+
const near = Math.abs(pct) < 0.5;
|
|
2107
|
+
const mag = Math.abs(pct);
|
|
2108
|
+
const c = near ? '#15803d' : (mag < 5 ? '#b45309' : '#b91c1c');
|
|
2109
|
+
const bg = near ? '#f0fdf4' : (mag < 5 ? '#fffbeb' : '#fef2f2');
|
|
2110
|
+
const bd = near ? '#bbf7d0' : (mag < 5 ? '#fde68a' : '#fecaca');
|
|
2111
|
+
yieldBanner = `<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;background:${bg};border:1px solid ${bd};border-radius:7px;font-size:13px">
|
|
2112
|
+
<span style="font-weight:700;color:${c}">As-prepared total ${cv(actualTotal)} ${esc(displayUnit)}</span>
|
|
2113
|
+
${near
|
|
2114
|
+
? `<span style="color:#15803d;font-weight:600">✓ on the ${cv(w.qty)} ${esc(displayUnit)} target</span>`
|
|
2115
|
+
: `<span style="color:${c};font-weight:600">${cvs(d)} ${esc(displayUnit)} (${d > 0 ? '+' : ''}${pct.toFixed(1)}%) ${d > 0 ? 'over' : 'under'} the ${cv(w.qty)} ${esc(displayUnit)} target</span>`}
|
|
2116
|
+
</div>`;
|
|
2117
|
+
}
|
|
2118
|
+
const copyLabel = hasActuals ? '<span class="fl-import" aria-hidden="true"></span> Reset to planned' : '<span class="fl-import" aria-hidden="true"></span> Copy into As-prepared';
|
|
2119
|
+
return `
|
|
2120
|
+
<div style="border:1px solid #dbeafe;border-radius:8px;padding:12px 14px;margin-bottom:12px;background:#f8fbff">
|
|
2121
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;flex-wrap:wrap">
|
|
2122
|
+
<span style="font-size:13px;font-weight:600;color:#0f172a">${title}</span>
|
|
2123
|
+
<span style="font-size:11.5px;color:#64748b">scaled to ${cv(w.qty)} ${esc(displayUnit)}</span>
|
|
2124
|
+
${unitDropdown}
|
|
2125
|
+
${altDropdown}
|
|
2126
|
+
<button type="button" class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="${copyOnclick}" title="Seed / reset the as-prepared amounts to the planned weigh-out, then edit each to what you actually weighed.">${copyLabel}</button>
|
|
2127
|
+
</div>
|
|
2128
|
+
<div style="font-size:11px;color:#64748b;line-height:1.5;margin:0 0 9px">${subtitle}</div>
|
|
2129
|
+
${yieldBanner}
|
|
2130
|
+
<table style="width:100%;border-collapse:collapse;font-size:12px">
|
|
2131
|
+
<thead>${head}</thead>
|
|
2132
|
+
<tbody>${body}</tbody>
|
|
2133
|
+
<tfoot>${tfoot}</tfoot>
|
|
2134
|
+
</table>
|
|
2135
|
+
${densityNote}
|
|
2136
|
+
${purityNote}${makeUpNote}
|
|
2137
|
+
</div>`;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Re-render every weigh-out panel on screen (batch + sample) AND the
|
|
2141
|
+
// Proposal-vs-As-prepared diff card (its Δ column shares the same unit), in place.
|
|
2142
|
+
function _weighoutRerenderPanels() {
|
|
2143
|
+
try {
|
|
2144
|
+
document.querySelectorAll('[id^="batch-weighout-"]').forEach(el => {
|
|
2145
|
+
const b = (typeof getBatch === 'function') ? getBatch(el.id.slice('batch-weighout-'.length)) : null;
|
|
2146
|
+
if (b && typeof _batchWeighoutPanelHtml === 'function') el.innerHTML = _batchWeighoutPanelHtml(b);
|
|
2147
|
+
});
|
|
2148
|
+
document.querySelectorAll('[id^="sm-weighout-"]').forEach(el => {
|
|
2149
|
+
const s = (typeof getSample === 'function') ? getSample(el.id.slice('sm-weighout-'.length)) : null;
|
|
2150
|
+
if (s && typeof _sampleWeighoutPanelHtml === 'function') el.innerHTML = _sampleWeighoutPanelHtml(s);
|
|
2151
|
+
});
|
|
2152
|
+
document.querySelectorAll('[id^="batch-actual-diff-"]').forEach(el => {
|
|
2153
|
+
const id = el.id.slice('batch-actual-diff-'.length);
|
|
2154
|
+
if (typeof renderBatchActualDiff === 'function') el.innerHTML = renderBatchActualDiff(id);
|
|
2155
|
+
});
|
|
2156
|
+
} catch (_) {}
|
|
2157
|
+
}
|
|
2158
|
+
// Persist a per-dimension weigh-out display unit (per-device preference) + re-render.
|
|
2159
|
+
function _weighoutSetDisplayUnit(dim, unit) {
|
|
2160
|
+
try { localStorage.setItem('formlab_weighout_unit_' + dim, unit); } catch (_) {}
|
|
2161
|
+
_weighoutRerenderPanels();
|
|
2162
|
+
}
|
|
2163
|
+
// Reset the weigh-out display unit back to each sheet's own target unit.
|
|
2164
|
+
function _weighoutResetDisplayUnit(dim) {
|
|
2165
|
+
try { localStorage.removeItem('formlab_weighout_unit_' + dim); } catch (_) {}
|
|
2166
|
+
_weighoutRerenderPanels();
|
|
2167
|
+
}
|
|
2168
|
+
// Persist the ≈ reference column's unit (per alt-dimension, per-device) + re-render.
|
|
2169
|
+
function _weighoutSetAltUnit(dim, unit) {
|
|
2170
|
+
try { localStorage.setItem('formlab_weighout_altunit_' + dim, unit); } catch (_) {}
|
|
2171
|
+
_weighoutRerenderPanels();
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// Heuristic: pick a default for the "% of Total" basis based on which
|
|
2175
|
+
// unit dimension the highest-cost-contributing row uses. Falls back to
|
|
2176
|
+
// mass when nothing is priced yet.
|
|
2177
|
+
function defaultPercentBasis(formulation, costSummary) {
|
|
2178
|
+
const isVolUnit = u => {
|
|
2179
|
+
const s = String(u || '').toLowerCase().trim();
|
|
2180
|
+
if (s.startsWith('vol')) return true;
|
|
2181
|
+
const canon = (() => {
|
|
2182
|
+
if (s in VOL_TO_L) return true;
|
|
2183
|
+
for (const k of Object.keys(VOL_TO_L)) if (k.toLowerCase() === s) return true;
|
|
2184
|
+
return false;
|
|
2185
|
+
})();
|
|
2186
|
+
return canon;
|
|
2187
|
+
};
|
|
2188
|
+
if (costSummary?.breakdown?.length) {
|
|
2189
|
+
const top = costSummary.breakdown.reduce(
|
|
2190
|
+
(best, b) => (b.contribution > (best?.contribution ?? -Infinity) ? b : best),
|
|
2191
|
+
null
|
|
2192
|
+
);
|
|
2193
|
+
if (top) return isVolUnit(top.unit) ? 'volume' : 'mass';
|
|
2194
|
+
}
|
|
2195
|
+
// Fallback: scan composition units alone
|
|
2196
|
+
const comp = formulation?.composition || [];
|
|
2197
|
+
const volRows = comp.filter(c => isVolUnit(c.unit)).length;
|
|
2198
|
+
const massRows = comp.length - volRows;
|
|
2199
|
+
return volRows > massRows ? 'volume' : 'mass';
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// ============================================================
|
|
2203
|
+
// BATCH + PROJECT COST ROLLUP
|
|
2204
|
+
// ============================================================
|
|
2205
|
+
// Approximate kg conversion for batch.targetQuantity.unit. If
|
|
2206
|
+
// volume, falls back to *assume kg* unless density is known.
|
|
2207
|
+
function computeBatchCost(batch, displayCcy = getDisplayCurrency()) {
|
|
2208
|
+
const f = db.formulations.find(x => x.id === batch.formulationId);
|
|
2209
|
+
if (!f) return null;
|
|
2210
|
+
const perKg = computeFormulationCost(f, displayCcy);
|
|
2211
|
+
// Batch size in kg via the shared quantityToKg (mass units convert directly;
|
|
2212
|
+
// volume units use the formula's rolled-up density). This is the SAME path the
|
|
2213
|
+
// sample/detail-field "Cost" cell uses, so the card and that field always agree.
|
|
2214
|
+
// (It replaced a private _batchSizeInKg that lowercased the unit before a
|
|
2215
|
+
// case-sensitive VOL_TO_L lookup — so "L" became "l", missed the table, and
|
|
2216
|
+
// every volume-unit batch silently reported no total.)
|
|
2217
|
+
const _umk = (typeof _batchUnitMassKg === 'function') ? _batchUnitMassKg(batch) : null;
|
|
2218
|
+
const sizeKg = quantityToKg(batch.targetQuantity, batch.unit, f, _umk);
|
|
2219
|
+
return {
|
|
2220
|
+
formulation: f,
|
|
2221
|
+
perKg: perKg.perKg,
|
|
2222
|
+
sizeKg,
|
|
2223
|
+
total: sizeKg != null ? perKg.perKg * sizeKg : null,
|
|
2224
|
+
currency: displayCcy,
|
|
2225
|
+
missing: perKg.missing,
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// Finished-goods best-by: a formulation's shelf life (months) applied to a
|
|
2230
|
+
// prepared date → the expiry Date (or ISO string). Null when there's no shelf
|
|
2231
|
+
// life on the formula or the prepared date is missing/unparseable. Shared by the
|
|
2232
|
+
// inventory roll-up, batch detail/list and sample detail/list so they agree.
|
|
2233
|
+
function shelfLifeExpiry(formulation, preparedDate) {
|
|
2234
|
+
const m = formulation && formulation.shelfLifeMonths;
|
|
2235
|
+
if (m == null || m === '' || !isFinite(+m) || +m <= 0) return null;
|
|
2236
|
+
const pm = Date.parse(preparedDate);
|
|
2237
|
+
if (!isFinite(pm)) return null;
|
|
2238
|
+
// UTC arithmetic: a date-only prepared date parses as UTC midnight, and adding
|
|
2239
|
+
// months in LOCAL time then reading the UTC day back landed a day early for
|
|
2240
|
+
// US users whenever the span crossed a DST change (build 33w). A day past the
|
|
2241
|
+
// target month's end clamps to its last day (Jan 31 + 1 month = Feb 28/29).
|
|
2242
|
+
const d = new Date(pm);
|
|
2243
|
+
const day = d.getUTCDate();
|
|
2244
|
+
d.setUTCDate(1);
|
|
2245
|
+
d.setUTCMonth(d.getUTCMonth() + (+m));
|
|
2246
|
+
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
|
2247
|
+
d.setUTCDate(Math.min(day, last));
|
|
2248
|
+
return d;
|
|
2249
|
+
}
|
|
2250
|
+
function shelfLifeExpiryISO(formulation, preparedDate) {
|
|
2251
|
+
const d = shelfLifeExpiry(formulation, preparedDate);
|
|
2252
|
+
return d ? d.toISOString().slice(0, 10) : null;
|
|
2253
|
+
}
|
|
2254
|
+
// A small coloured badge for a best-by date (red if past, amber if ≤30d).
|
|
2255
|
+
function shelfLifeBadgeHtml(iso) {
|
|
2256
|
+
if (!iso) return '—';
|
|
2257
|
+
const d = Math.floor((Date.parse(iso) - Date.now()) / 86400000);
|
|
2258
|
+
let style = 'color:var(--text)', note = '';
|
|
2259
|
+
if (d < 0) { style = 'background:#fee2e2;color:#991b1b;padding:1px 7px;border-radius:10px;font-weight:600'; note = ' · expired'; }
|
|
2260
|
+
else if (d <= 30) { style = 'background:#fef3c7;color:#b45309;padding:1px 7px;border-radius:10px;font-weight:600'; note = ` · ${d}d`; }
|
|
2261
|
+
else if (d <= 90) { style = 'color:#b45309'; note = ` · ${d}d`; }
|
|
2262
|
+
return `<span style="${style}">${(typeof esc === 'function' ? esc(iso) : iso)}${note}</span>`;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function computeProjectCost(projectId, displayCcy = getDisplayCurrency()) {
|
|
2266
|
+
const project = db.projects.find(p => p.id === projectId);
|
|
2267
|
+
if (!project) return null;
|
|
2268
|
+
const formulations = db.formulations.filter(f => f.projectId === projectId);
|
|
2269
|
+
const batches = db.batches.filter(b => formulations.some(f => f.id === b.formulationId));
|
|
2270
|
+
const batchSummaries = batches.map(b => ({ batch: b, summary: computeBatchCost(b, displayCcy) }))
|
|
2271
|
+
.filter(x => x.summary);
|
|
2272
|
+
const total = batchSummaries.reduce((s, x) => s + (x.summary.total || 0), 0);
|
|
2273
|
+
const counted = batchSummaries.filter(x => x.summary.total != null).length;
|
|
2274
|
+
return {
|
|
2275
|
+
project,
|
|
2276
|
+
currency: displayCcy,
|
|
2277
|
+
batchCount: batches.length,
|
|
2278
|
+
pricedBatchCount: counted,
|
|
2279
|
+
total,
|
|
2280
|
+
batchSummaries,
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// ============================================================
|
|
2285
|
+
// PRICE CHANGE / ALERTS — uses ing.cost.history (set on save).
|
|
2286
|
+
// Compares the current cost to the OLDEST history entry by default;
|
|
2287
|
+
// returns percent change in the cost's own currency, normalized to
|
|
2288
|
+
// $/kg so different units don't distort the comparison.
|
|
2289
|
+
// ============================================================
|
|
2290
|
+
function ingredientCostChangePct(ing) {
|
|
2291
|
+
if (!ing?.cost?.history?.length) return null;
|
|
2292
|
+
const cur = costPerKgInOwnCurrency(ing.cost, ing.density, ing.densityUnit);
|
|
2293
|
+
// Walk back to oldest entry that has a comparable per-kg value
|
|
2294
|
+
for (let i = 0; i < ing.cost.history.length; i++) {
|
|
2295
|
+
const old = ing.cost.history[i];
|
|
2296
|
+
const oldPerKg = costPerKgInOwnCurrency(old, ing.density, ing.densityUnit);
|
|
2297
|
+
// Convert to same currency for fair comparison
|
|
2298
|
+
const oldInCurCcy = convertCurrencySync(oldPerKg, old.currency || 'USD', ing.cost.currency || 'USD');
|
|
2299
|
+
if (cur != null && oldInCurCcy && oldInCurCcy > 0) {
|
|
2300
|
+
return { pct: (cur - oldInCurCcy) / oldInCurCcy * 100, baseline: oldInCurCcy, current: cur, since: old.updatedAt };
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function getPriceAlerts(thresholdPct = 10) {
|
|
2307
|
+
const alerts = [];
|
|
2308
|
+
for (const ing of db.ingredients) {
|
|
2309
|
+
const change = ingredientCostChangePct(ing);
|
|
2310
|
+
if (change && Math.abs(change.pct) >= thresholdPct) {
|
|
2311
|
+
alerts.push({ ingredient: ing, ...change });
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
alerts.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct));
|
|
2315
|
+
return alerts;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// ============================================================
|
|
2319
|
+
// PHYSICAL-QUANTITY COST (samples / batches)
|
|
2320
|
+
// A sample (quantity) or batch (targetQuantity) is a physical amount of
|
|
2321
|
+
// a formula, so its cost = formula $/kg × quantity-in-kg. Mass units
|
|
2322
|
+
// convert directly; volume units need the formula's rolled-up density.
|
|
2323
|
+
// ============================================================
|
|
2324
|
+
// Convert a quantity between units for budget bookkeeping. Mass↔mass and
|
|
2325
|
+
// volume↔volume convert directly; mass↔volume needs the formula's rolled-up
|
|
2326
|
+
// density (kg/L). Returns the value in `toUnit`, or null when not possible
|
|
2327
|
+
// (count/"each" units, or cross-dimension with no known density).
|
|
2328
|
+
function convertQuantity(value, fromUnit, toUnit, formula) {
|
|
2329
|
+
const dens = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(formula) : null; // kg/L
|
|
2330
|
+
return convertQuantityAtDensity(value, fromUnit, toUnit, dens);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// Same conversion with an EXPLICIT density (kg/L === g/mL). Use this for a
|
|
2334
|
+
// single ingredient row — 41.86 g of sucrose is 26.3 mL at sucrose's 1.59,
|
|
2335
|
+
// not 40 mL at the formula's 1.0465. Mass↔volume without a density → null.
|
|
2336
|
+
function convertQuantityAtDensity(value, fromUnit, toUnit, dens) {
|
|
2337
|
+
const v = parseFloat(value);
|
|
2338
|
+
if (!isFinite(v)) return null;
|
|
2339
|
+
const from = (fromUnit || '').trim(), to = (toUnit || '').trim();
|
|
2340
|
+
if (!to) return null;
|
|
2341
|
+
if (from === to) return v;
|
|
2342
|
+
const fM = (typeof isMassUnit === 'function') && isMassUnit(from);
|
|
2343
|
+
const tM = (typeof isMassUnit === 'function') && isMassUnit(to);
|
|
2344
|
+
const fV = (typeof isVolUnit === 'function') && isVolUnit(from);
|
|
2345
|
+
const tV = (typeof isVolUnit === 'function') && isVolUnit(to);
|
|
2346
|
+
if (fM && tM) { const a = massToKg(from), b = massToKg(to); return (a != null && b != null) ? v * a / b : null; }
|
|
2347
|
+
if (fV && tV) { const a = volToL(from), b = volToL(to); return (a != null && b != null) ? v * a / b : null; }
|
|
2348
|
+
if (dens == null || !isFinite(dens) || dens <= 0) return null;
|
|
2349
|
+
if (fM && tV) { const kg = v * massToKg(from); const L = kg / dens; const b = volToL(to); return (b != null) ? L / b : null; }
|
|
2350
|
+
if (fV && tM) { const L = v * volToL(from); const kg = L * dens; const b = massToKg(to); return (b != null) ? kg / b : null; }
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// Density (kg/L) of ONE composition row: an ingredient's own density, or a
|
|
2355
|
+
// sub-formula's planning density. Keyed 'i:<id>' / 'f:<id>' like the
|
|
2356
|
+
// weigh-out rows, or pass a composition row object. null when unknown.
|
|
2357
|
+
function rowDensityKgPerL(keyOrRow) {
|
|
2358
|
+
let ingId = null, subId = null;
|
|
2359
|
+
if (typeof keyOrRow === 'string') {
|
|
2360
|
+
if (keyOrRow.slice(0, 2) === 'i:') ingId = keyOrRow.slice(2);
|
|
2361
|
+
else if (keyOrRow.slice(0, 2) === 'f:') subId = keyOrRow.slice(2);
|
|
2362
|
+
} else if (keyOrRow) { ingId = keyOrRow.ingredientId || null; subId = keyOrRow.formulationId || null; }
|
|
2363
|
+
const D = (typeof db !== 'undefined') ? db : { ingredients: [], formulations: [] };
|
|
2364
|
+
if (ingId) { const ing = (D.ingredients || []).find(i => i.id === ingId); return ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null; }
|
|
2365
|
+
if (subId) { const sf = (D.formulations || []).find(g => g.id === subId); return (sf && typeof _rolledUpDensity === 'function') ? _rolledUpDensity(sf) : null; }
|
|
2366
|
+
return null;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Classify whether `fromUnit` can be expressed as `toUnit`:
|
|
2370
|
+
// 'same' — identical units
|
|
2371
|
+
// 'ok' — same dimension, or cross-dimension with a known density
|
|
2372
|
+
// 'needs-density'— mass↔volume but the formula has no density (FIXABLE)
|
|
2373
|
+
// 'incompatible' — count/unknown units that no density can bridge
|
|
2374
|
+
// Lets callers show a light, actionable warning instead of a silent "—".
|
|
2375
|
+
function unitConvertibility(fromUnit, toUnit, formula) {
|
|
2376
|
+
const from = (fromUnit || '').trim(), to = (toUnit || '').trim();
|
|
2377
|
+
if (!from || !to || from === to) return 'same';
|
|
2378
|
+
const fM = (typeof isMassUnit === 'function') && isMassUnit(from);
|
|
2379
|
+
const tM = (typeof isMassUnit === 'function') && isMassUnit(to);
|
|
2380
|
+
const fV = (typeof isVolUnit === 'function') && isVolUnit(from);
|
|
2381
|
+
const tV = (typeof isVolUnit === 'function') && isVolUnit(to);
|
|
2382
|
+
if ((fM && tM) || (fV && tV)) return 'ok';
|
|
2383
|
+
if ((fM && tV) || (fV && tM)) {
|
|
2384
|
+
const dens = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(formula) : null;
|
|
2385
|
+
return (dens != null && isFinite(dens) && dens > 0) ? 'ok' : 'needs-density';
|
|
2386
|
+
}
|
|
2387
|
+
return 'incompatible';
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function quantityToKg(qty, unit, formula, unitMassKg) {
|
|
2391
|
+
const q = parseFloat(qty);
|
|
2392
|
+
if (!isFinite(q) || q <= 0) return null;
|
|
2393
|
+
const u = unit || '';
|
|
2394
|
+
if (typeof isMassUnit === 'function' && isMassUnit(u)) {
|
|
2395
|
+
const f = massToKg(u); return (f != null) ? q * f : null;
|
|
2396
|
+
}
|
|
2397
|
+
if (typeof isVolUnit === 'function' && isVolUnit(u)) {
|
|
2398
|
+
const litres = volToL(u); if (litres == null) return null;
|
|
2399
|
+
const dens = (typeof _rolledUpDensity === 'function') ? _rolledUpDensity(formula) : null; // kg/L
|
|
2400
|
+
if (dens == null || !isFinite(dens)) return null;
|
|
2401
|
+
return q * litres * dens;
|
|
2402
|
+
}
|
|
2403
|
+
// Count unit (tablet / capsule / unit / piece …) with a known per-unit mass:
|
|
2404
|
+
// total mass = count × mass-per-unit. Lets dose-form batches have a cost/yield.
|
|
2405
|
+
if (unitMassKg != null && isFinite(unitMassKg) && unitMassKg > 0) return q * unitMassKg;
|
|
2406
|
+
return null;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// Returns { perKg, kg, computed, ccy, convertible } in the display currency.
|
|
2410
|
+
function quantityCost(qty, unit, formula, displayCcy) {
|
|
2411
|
+
const ccy = displayCcy ||
|
|
2412
|
+
(typeof getDisplayCurrency === 'function' ? getDisplayCurrency() : null) ||
|
|
2413
|
+
(formula && formula.costCurrency) || 'USD';
|
|
2414
|
+
let perKg = null;
|
|
2415
|
+
try {
|
|
2416
|
+
const c = formula && typeof computeFormulationCost === 'function' ? computeFormulationCost(formula, ccy) : null;
|
|
2417
|
+
if (c && isFinite(c.perKg)) perKg = c.perKg;
|
|
2418
|
+
} catch (_) { perKg = null; }
|
|
2419
|
+
const kg = quantityToKg(qty, unit, formula);
|
|
2420
|
+
const computed = (perKg != null && kg != null) ? perKg * kg : null;
|
|
2421
|
+
return { perKg, kg, computed, ccy, convertible: kg != null };
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// Open the global Display-currency setting — Settings modal → Preferences tab
|
|
2425
|
+
// (where the selector actually lives) — and scroll it into view with a brief
|
|
2426
|
+
// highlight. The old link went to navigate('data') (Import / Export), which has
|
|
2427
|
+
// NO currency selector, so the link went nowhere useful.
|
|
2428
|
+
function goToDisplayCurrencySetting() {
|
|
2429
|
+
if (typeof openSettingsModal === 'function') openSettingsModal('prefs');
|
|
2430
|
+
else if (typeof navigate === 'function') navigate('data'); // fallback if the modal helper is unavailable
|
|
2431
|
+
const focus = () => {
|
|
2432
|
+
const el = document.getElementById('display-currency-card');
|
|
2433
|
+
if (!el) return;
|
|
2434
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
2435
|
+
el.style.transition = 'box-shadow .35s ease';
|
|
2436
|
+
el.style.boxShadow = '0 0 0 3px rgba(37,99,235,0.35)';
|
|
2437
|
+
setTimeout(() => { el.style.boxShadow = ''; }, 1400);
|
|
2438
|
+
};
|
|
2439
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focus);
|
|
2440
|
+
else setTimeout(focus, 50);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// Effective cost for a batch/sample: the manual override when set, else the
|
|
2444
|
+
// live computed cost — plus the drift between them. Mirrors entityCostValueHtml's
|
|
2445
|
+
// override resolution (same currency conversion) so the list Cost column and the
|
|
2446
|
+
// detail Cost field always agree. Returns:
|
|
2447
|
+
// { total, computed, currency, overridden, note, driftPct }
|
|
2448
|
+
// total is null only when there's neither an override nor a computable cost.
|
|
2449
|
+
function entityEffectiveCost(qty, unit, formula, override, overrideCcy, note) {
|
|
2450
|
+
const { computed, ccy } = quantityCost(qty, unit, formula);
|
|
2451
|
+
const ovRaw = (override != null && override !== '' && isFinite(parseFloat(override))) ? parseFloat(override) : null;
|
|
2452
|
+
const ovCcy = overrideCcy || ccy;
|
|
2453
|
+
let ov = ovRaw;
|
|
2454
|
+
if (ovRaw != null && ovCcy !== ccy && typeof convertCurrencySync === 'function') {
|
|
2455
|
+
const conv = convertCurrencySync(ovRaw, ovCcy, ccy);
|
|
2456
|
+
if (isFinite(conv)) ov = conv;
|
|
2457
|
+
}
|
|
2458
|
+
const overridden = ov != null;
|
|
2459
|
+
return {
|
|
2460
|
+
total: overridden ? ov : computed,
|
|
2461
|
+
computed,
|
|
2462
|
+
currency: ccy,
|
|
2463
|
+
overridden,
|
|
2464
|
+
note: (note != null && String(note).trim() !== '') ? String(note).trim() : '',
|
|
2465
|
+
driftPct: (overridden && computed != null && computed > 0) ? (ov - computed) / computed * 100 : null,
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// Amber-flag threshold: a manual override this far (%) from the live computed
|
|
2470
|
+
// cost is surfaced as "stale" in the compact list cell.
|
|
2471
|
+
const COST_DRIFT_PCT = 10;
|
|
2472
|
+
|
|
2473
|
+
// Compact Cost-cell HTML for LIST views (batch/sample tables + cards). Plain
|
|
2474
|
+
// formatted total for a computed cost; a superscript "ᴹ" marker when it's a
|
|
2475
|
+
// manual override — amber when the override has drifted >= COST_DRIFT_PCT from
|
|
2476
|
+
// computed — with the computed value + drift + note in the tooltip. "—" when
|
|
2477
|
+
// unpriceable. `label` names the value in the non-override tooltip.
|
|
2478
|
+
function entityCostCellHtml(summary, label) {
|
|
2479
|
+
const lbl = label || 'Cost';
|
|
2480
|
+
if (!summary || summary.total == null) {
|
|
2481
|
+
return `<span title="${esc(lbl)}, not priced (no ingredient prices, or a volume amount with no density)" style="color:#94a3b8">—</span>`;
|
|
2482
|
+
}
|
|
2483
|
+
const money = v => (typeof formatMoney === 'function') ? formatMoney(v, summary.currency) : (summary.currency + ' ' + (+v).toFixed(2));
|
|
2484
|
+
const amt = money(summary.total);
|
|
2485
|
+
if (!summary.overridden) return `<span title="${esc(lbl)} (live ingredient prices)">${amt}</span>`;
|
|
2486
|
+
const drift = summary.driftPct;
|
|
2487
|
+
const drifted = drift != null && Math.abs(drift) >= COST_DRIFT_PCT;
|
|
2488
|
+
const compStr = summary.computed != null ? money(summary.computed) : null;
|
|
2489
|
+
const dStr = (drift != null) ? ` (${drift > 0 ? '+' : ''}${drift.toFixed(0)}%)` : '';
|
|
2490
|
+
const tip = 'Manual override'
|
|
2491
|
+
+ (compStr ? ` · computed ${compStr}${dStr}` : '')
|
|
2492
|
+
+ (drifted ? ' · drifted from live cost' : '')
|
|
2493
|
+
+ (summary.note ? ` · “${summary.note}”` : '');
|
|
2494
|
+
const mark = drifted
|
|
2495
|
+
? ` <span style="color:#b45309;font-size:11px" aria-hidden="true">⚠</span>`
|
|
2496
|
+
: `<sup style="color:#2563eb;font-size:9px;font-weight:700;margin-left:1px" aria-hidden="true">M</sup>`;
|
|
2497
|
+
return `<span title="${esc(tip)}">${amt}${mark}</span>`;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Value-cell HTML for a sample/batch "Cost" detail field. Shows the
|
|
2501
|
+
// computed total cost (with breakdown), or — if a manual override is
|
|
2502
|
+
// set — the override + a divergence warning vs the live computed value.
|
|
2503
|
+
// o = { kind:'sample'|'batch', id, qty, unit, formula, override }.
|
|
2504
|
+
function entityCostValueHtml(o) {
|
|
2505
|
+
const { perKg, computed, ccy, convertible } = quantityCost(o.qty, o.unit, o.formula);
|
|
2506
|
+
const money = v => (typeof formatMoney === 'function') ? formatMoney(v, ccy) : (ccy + ' ' + (+v).toFixed(2));
|
|
2507
|
+
const ovRaw = (o.override != null && o.override !== '' && isFinite(parseFloat(o.override))) ? parseFloat(o.override) : null;
|
|
2508
|
+
// The override was entered in o.overrideCcy (legacy values w/o a stamp are
|
|
2509
|
+
// assumed to be in the current display currency). Convert it INTO the
|
|
2510
|
+
// display currency so both the shown value and the divergence vs computed
|
|
2511
|
+
// are apples-to-apples regardless of the active display currency.
|
|
2512
|
+
const ovCcy = o.overrideCcy || ccy;
|
|
2513
|
+
const ov = (ovRaw != null && ovCcy !== ccy && typeof convertCurrencySync === 'function')
|
|
2514
|
+
? convertCurrencySync(ovRaw, ovCcy, ccy) : ovRaw;
|
|
2515
|
+
const note = (o.note != null && String(o.note).trim() !== '') ? String(o.note).trim() : '';
|
|
2516
|
+
const editBtn = `<a onclick="openCostOverrideModal('${o.kind}','${o.id}')" style="color:var(--primary);cursor:pointer;font-size:11px;margin-left:8px">${ov != null ? 'Edit' : 'Override'}</a>`;
|
|
2517
|
+
let chipTitle = note ? 'Reason: ' + note : 'Manually overridden cost';
|
|
2518
|
+
if (ovRaw != null && ovCcy !== ccy) chipTitle += ` · entered as ${formatMoney(ovRaw, ovCcy)}`;
|
|
2519
|
+
const manualChip = `<span title="${esc(chipTitle)}" style="font-size:10.5px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e40af;padding:1px 6px;border-radius:999px;margin-left:6px">manual</span>`;
|
|
2520
|
+
|
|
2521
|
+
// Currency source hint: viewer's own display preference always wins.
|
|
2522
|
+
// Only fall back to "from formula" if the user has no display preference set.
|
|
2523
|
+
const fromFormula = !!(o.formula && o.formula.costCurrency && !getDisplayCurrency());
|
|
2524
|
+
const ccyLine = `<div style="font-size:10.5px;color:#94a3b8;margin-top:2px">${esc(ccy)} · <a onclick="event.stopPropagation();${fromFormula ? `navigate('formulation-detail','${o.formula.id}')` : `goToDisplayCurrencySetting()`}" title="${fromFormula ? esc('Currency inherited from the formula; click to change it there') : esc('Global display currency; click to change in Settings → Preferences')}" style="color:var(--primary);cursor:pointer">${fromFormula ? 'from formula' : 'display currency'}</a></div>`;
|
|
2525
|
+
|
|
2526
|
+
if (ov != null) {
|
|
2527
|
+
let ref = '';
|
|
2528
|
+
if (computed != null) {
|
|
2529
|
+
if (computed > 0) {
|
|
2530
|
+
const pct = (ov - computed) / computed * 100;
|
|
2531
|
+
if (Math.abs(pct) >= 1) {
|
|
2532
|
+
ref = `<span title="Manual override differs from the live computed cost; recheck if the recipe or ingredient prices changed" style="margin-left:8px;font-size:10.5px;background:#fef3c7;border:1px solid #fcd34d;color:#92400e;padding:1px 6px;border-radius:999px"><span class="fl-warn" aria-hidden="true"></span> computed ${money(computed)} (Δ ${pct > 0 ? '+' : ''}${pct.toFixed(0)}%)</span>`;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
if (!ref) ref = `<span style="font-size:11px;color:var(--text-muted);margin-left:8px">computed ${money(computed)}</span>`;
|
|
2536
|
+
}
|
|
2537
|
+
const noteLine = note ? `<div style="font-size:11px;color:var(--text-muted);font-style:italic;margin-top:3px">“${esc(note)}”</div>` : '';
|
|
2538
|
+
return `<strong>${money(ov)}</strong>${manualChip}${ref}${editBtn}${ccyLine}${noteLine}`;
|
|
2539
|
+
}
|
|
2540
|
+
if (perKg == null) {
|
|
2541
|
+
// No formula is not "no ingredient prices" (35m): say whether it's in Trash.
|
|
2542
|
+
if (!o.formula) {
|
|
2543
|
+
const rec = ((typeof db !== 'undefined' && db && db[o.kind === 'batch' ? 'batches' : 'samples']) || []).find(x => x && x.id === o.id);
|
|
2544
|
+
const fid = rec && rec.formulationId;
|
|
2545
|
+
const why = !fid ? 'no formula' : (typeof flTrashEntryFor === 'function' && flTrashEntryFor('formulation', fid)) ? 'formula is in Trash' : 'formula deleted';
|
|
2546
|
+
return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:var(--text-muted)">${why}</span>${editBtn}`;
|
|
2547
|
+
}
|
|
2548
|
+
return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:var(--text-muted)">no ingredient prices</span>${editBtn}`;
|
|
2549
|
+
}
|
|
2550
|
+
if (!convertible) {
|
|
2551
|
+
// Light, actionable warning: mass↔volume just needs a density (fixable);
|
|
2552
|
+
// count/"each" units genuinely can't be priced by mass.
|
|
2553
|
+
const why = (typeof unitConvertibility === 'function') ? unitConvertibility(o.unit, 'kg', o.formula) : 'incompatible';
|
|
2554
|
+
const msg = (why === 'needs-density')
|
|
2555
|
+
? `⚠ add a density to the formula to price ${esc(o.unit || 'this unit')} by mass`
|
|
2556
|
+
: `can't convert ${esc(o.unit || 'this unit')} to mass`;
|
|
2557
|
+
const col = (why === 'needs-density') ? '#b45309' : 'var(--text-muted)';
|
|
2558
|
+
return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:${col}">${msg}</span>${editBtn}`;
|
|
2559
|
+
}
|
|
2560
|
+
const breakdown = `<span style="font-size:11px;color:var(--text-muted);margin-left:6px">${esc(String(o.qty))} ${esc(o.unit || '')} × ${money(perKg)}/kg</span>`;
|
|
2561
|
+
return `<strong>${money(computed)}</strong>${breakdown}${editBtn}${ccyLine}`;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function openCostOverrideModal(kind, id) {
|
|
2565
|
+
const ent = (kind === 'sample' ? db.samples : db.batches).find(x => x.id === id);
|
|
2566
|
+
if (!ent) return;
|
|
2567
|
+
const qty = (kind === 'sample') ? ent.quantity : ent.targetQuantity;
|
|
2568
|
+
const formula = db.formulations.find(f => f.id === ent.formulationId);
|
|
2569
|
+
const { computed, ccy } = quantityCost(qty, ent.unit, formula);
|
|
2570
|
+
// Prefill the input in the CURRENT display currency, converting a value
|
|
2571
|
+
// that was stored in a different currency, so editing is always in `ccy`.
|
|
2572
|
+
let cur = '';
|
|
2573
|
+
if (ent.costOverride != null && ent.costOverride !== '' && isFinite(parseFloat(ent.costOverride))) {
|
|
2574
|
+
const stored = parseFloat(ent.costOverride);
|
|
2575
|
+
const sCcy = ent.costOverrideCcy || ccy;
|
|
2576
|
+
cur = (sCcy !== ccy && typeof convertCurrencySync === 'function')
|
|
2577
|
+
? +convertCurrencySync(stored, sCcy, ccy).toPrecision(6) : stored;
|
|
2578
|
+
}
|
|
2579
|
+
const curNote = (ent.costOverrideNote != null) ? ent.costOverrideNote : '';
|
|
2580
|
+
const compStr = (computed != null) ? formatMoney(computed, ccy) : '—';
|
|
2581
|
+
const body = `
|
|
2582
|
+
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px;line-height:1.5">
|
|
2583
|
+
Computed cost is <strong>${compStr}</strong>. Enter a manual override to record the actual cost — it'll be flagged on the detail page whenever it diverges from the live computed value.
|
|
2584
|
+
</p>
|
|
2585
|
+
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">Override (${esc(ccy)})</label>
|
|
2586
|
+
<input id="cost-ov-input" type="number" step="any" value="${esc(String(cur))}" placeholder="${computed != null ? (+computed).toPrecision(4) : ''}" style="width:100%;padding:7px 9px;border:1px solid var(--border);border-radius:6px;font-size:14px" autofocus />
|
|
2587
|
+
<label style="font-size:12px;font-weight:600;display:block;margin:12px 0 4px">Reason <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
2588
|
+
<input id="cost-ov-note" type="text" value="${esc(String(curNote))}" placeholder="e.g. actual lot price incl. freight & MOQ" style="width:100%;padding:7px 9px;border:1px solid var(--border);border-radius:6px;font-size:13px" />
|
|
2589
|
+
<p style="font-size:11.5px;color:var(--text-muted);margin-top:8px">Leave the override blank and save to clear it (and its reason) and fall back to the computed cost.</p>`;
|
|
2590
|
+
showModal(`${kind === 'sample' ? 'Sample' : 'Batch'} cost override`, body, () => _saveCostOverride(kind, id));
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function _saveCostOverride(kind, id) {
|
|
2594
|
+
const ent = (kind === 'sample' ? db.samples : db.batches).find(x => x.id === id);
|
|
2595
|
+
if (!ent) return;
|
|
2596
|
+
const el = document.getElementById('cost-ov-input');
|
|
2597
|
+
const noteEl = document.getElementById('cost-ov-note');
|
|
2598
|
+
const raw = el ? String(el.value).trim() : '';
|
|
2599
|
+
const note = noteEl ? String(noteEl.value).trim() : '';
|
|
2600
|
+
// The modal entered the value in the current display currency — stamp it so
|
|
2601
|
+
// the override survives a later display-currency change.
|
|
2602
|
+
const qty = (kind === 'sample') ? ent.quantity : ent.targetQuantity;
|
|
2603
|
+
const formula = db.formulations.find(f => f.id === ent.formulationId);
|
|
2604
|
+
const ccy = quantityCost(qty, ent.unit, formula).ccy;
|
|
2605
|
+
if (raw === '' || !isFinite(parseFloat(raw))) {
|
|
2606
|
+
delete ent.costOverride;
|
|
2607
|
+
delete ent.costOverrideNote; // clearing the override clears its reason
|
|
2608
|
+
delete ent.costOverrideCcy;
|
|
2609
|
+
} else {
|
|
2610
|
+
ent.costOverride = parseFloat(raw);
|
|
2611
|
+
ent.costOverrideCcy = ccy;
|
|
2612
|
+
if (note) ent.costOverrideNote = note; else delete ent.costOverrideNote;
|
|
2613
|
+
}
|
|
2614
|
+
ent.updatedAt = (typeof now === 'function') ? now() : new Date().toISOString();
|
|
2615
|
+
saveDB();
|
|
2616
|
+
if (typeof _proHook === 'function') _proHook(kind, 'update', ent); // kind = 'sample' | 'batch'
|
|
2617
|
+
if (kind === 'sample' && typeof renderSampleDetail === 'function') renderSampleDetail(id);
|
|
2618
|
+
else if (kind === 'batch' && typeof renderBatchDetail === 'function') renderBatchDetail(id);
|
|
2619
|
+
}
|