thatcher 1.0.83 → 1.0.85
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/package.json +1 -1
- package/src/lib/busybase/store.js +2 -2
- package/src/lib/config-generator-engine.js +2 -0
- package/src/lib/formula-fields.js +82 -12
- package/src/lib/inventory-forecast.js +53 -0
- package/src/ui/entity-renderer.js +19 -1
- package/src/ui/page-handler.js +26 -4
- package/src/ui/report-renderer.js +31 -0
package/package.json
CHANGED
|
@@ -199,7 +199,7 @@ export async function list(entity, where = {}, options = {}) {
|
|
|
199
199
|
const { decryptFields } = await import('../field-encryption.js');
|
|
200
200
|
const decryptedRows = rows.map(r => decryptFields(r, spec.fields));
|
|
201
201
|
const { computeFormulaFields } = await import('../formula-fields.js');
|
|
202
|
-
const withFormulas = decryptedRows.map(r => computeFormulaFields(r, spec.fields));
|
|
202
|
+
const withFormulas = await Promise.all(decryptedRows.map(r => computeFormulaFields(r, spec.fields, entity)));
|
|
203
203
|
return attachRefDisplays(entity, withFormulas);
|
|
204
204
|
}
|
|
205
205
|
|
|
@@ -234,7 +234,7 @@ export async function get(entity, id, options = {}) {
|
|
|
234
234
|
const { decryptFields } = await import('../field-encryption.js');
|
|
235
235
|
const decrypted = decryptFields(row, spec.fields);
|
|
236
236
|
const { computeFormulaFields } = await import('../formula-fields.js');
|
|
237
|
-
const withFormula = computeFormulaFields(decrypted, spec.fields);
|
|
237
|
+
const withFormula = await computeFormulaFields(decrypted, spec.fields, entity);
|
|
238
238
|
const [withDisplay] = await attachRefDisplays(entity, [withFormula]);
|
|
239
239
|
return withDisplay;
|
|
240
240
|
}
|
|
@@ -172,6 +172,7 @@ const PROJECT_ENTITY_DEFAULT = {
|
|
|
172
172
|
status: { type: 'enum', options: ['planning', 'active', 'on_hold', 'completed', 'cancelled'], default: 'planning', label: 'Status' },
|
|
173
173
|
start_date: { type: 'date', label: 'Start Date' },
|
|
174
174
|
due_date: { type: 'date', label: 'Due Date' },
|
|
175
|
+
task_count: { type: 'formula', aggregate: { related_entity: 'task', ref_field: 'project_id', aggregate_field: 'id', fn: 'count' }, label: 'Task Count', readonly: true },
|
|
175
176
|
},
|
|
176
177
|
};
|
|
177
178
|
|
|
@@ -208,6 +209,7 @@ const PRODUCT_ENTITY_DEFAULT = {
|
|
|
208
209
|
description: { type: 'textarea', label: 'Description' },
|
|
209
210
|
unit_price: { type: 'currency', label: 'Unit Price' },
|
|
210
211
|
reorder_threshold: { type: 'number', min: 0, label: 'Reorder Threshold' },
|
|
212
|
+
lead_time_days: { type: 'number', min: 0, default: 14, label: 'Lead Time (days)' },
|
|
211
213
|
},
|
|
212
214
|
};
|
|
213
215
|
|
|
@@ -1,26 +1,96 @@
|
|
|
1
1
|
import { evaluateFormula } from './formula-evaluator.js';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
3
|
+
const AGGREGATE_FUNCTIONS = new Set(['count', 'sum', 'avg']);
|
|
4
|
+
|
|
5
|
+
// An aggregate formula's list() call re-enters computeFormulaFields for every
|
|
6
|
+
// related row, which could itself have an aggregate formula pointing back at
|
|
7
|
+
// the original entity -- a config mistake (A aggregates B, B aggregates A)
|
|
8
|
+
// would otherwise recurse without bound. A simple depth counter (not
|
|
9
|
+
// per-request-scoped -- formula evaluation has no request context threaded
|
|
10
|
+
// through it, same as every other computeFormulaFields caller) caps this at
|
|
11
|
+
// a depth no legitimate one-hop-or-two-hop aggregate design would ever need.
|
|
12
|
+
const MAX_AGGREGATE_DEPTH = 3;
|
|
13
|
+
let currentAggregateDepth = 0;
|
|
14
|
+
|
|
15
|
+
// Cross-entity aggregate formula (task_count-style): join through a field on
|
|
16
|
+
// the RELATED entity that points back to THIS record's id -- the reverse
|
|
17
|
+
// direction of a normal ref field, since the "one" side of a one-to-many
|
|
18
|
+
// relationship never names the "many" side directly. Reuses the exact same
|
|
19
|
+
// list(relatedEntity, {[ref_field]: id}) query shape resource_allocation's
|
|
20
|
+
// capacity check and the rollup report's ref-join already use, not new join
|
|
21
|
+
// logic. Runs WITHOUT a user context in scope -- the same unscoped-internal
|
|
22
|
+
// read pattern checkStockBalance/checkResourceCapacity/the same-record
|
|
23
|
+
// formula path all already establish for computeFormulaFields' call sites in
|
|
24
|
+
// busybase/store.js -- an aggregate is more visibly "reading across
|
|
25
|
+
// entities" than the same-record arithmetic case, so this is stated
|
|
26
|
+
// explicitly rather than left implicit: it is consistent with, not an
|
|
27
|
+
// expansion of, the existing unscoped-lookup contract.
|
|
28
|
+
async function evaluateAggregateFormula(aggregate, record, entityName) {
|
|
29
|
+
const { related_entity, ref_field, aggregate_field, fn } = aggregate || {};
|
|
30
|
+
if (typeof related_entity !== 'string' || typeof ref_field !== 'string' || typeof aggregate_field !== 'string') {
|
|
31
|
+
throw new Error('aggregate formula requires related_entity, ref_field, and aggregate_field');
|
|
32
|
+
}
|
|
33
|
+
if (!AGGREGATE_FUNCTIONS.has(fn)) {
|
|
34
|
+
throw new Error(`aggregate formula fn must be one of ${[...AGGREGATE_FUNCTIONS].join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { getSpec } = await import('@/config/spec-helpers');
|
|
38
|
+
const relatedSpec = getSpec(related_entity);
|
|
39
|
+
if (!relatedSpec) throw new Error(`aggregate formula references unknown entity "${related_entity}"`);
|
|
40
|
+
if (!relatedSpec.fields?.[ref_field]) {
|
|
41
|
+
throw new Error(`aggregate formula's ref_field "${ref_field}" does not exist on entity "${related_entity}"`);
|
|
42
|
+
}
|
|
43
|
+
if (aggregate_field !== 'id' && !relatedSpec.fields?.[aggregate_field]) {
|
|
44
|
+
throw new Error(`aggregate formula's aggregate_field "${aggregate_field}" does not exist on entity "${related_entity}"`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (currentAggregateDepth >= MAX_AGGREGATE_DEPTH) {
|
|
48
|
+
throw new Error(`aggregate formula exceeded max nesting depth (${MAX_AGGREGATE_DEPTH}) -- check for a cross-entity aggregate cycle`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
currentAggregateDepth++;
|
|
52
|
+
let relatedRows;
|
|
53
|
+
try {
|
|
54
|
+
const { list } = await import('./busybase/store');
|
|
55
|
+
relatedRows = await list(related_entity, { [ref_field]: record.id });
|
|
56
|
+
} finally {
|
|
57
|
+
currentAggregateDepth--;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fn === 'count') return relatedRows.length;
|
|
61
|
+
if (!relatedRows.length) return 0;
|
|
62
|
+
const values = relatedRows.map(r => Number(r[aggregate_field]) || 0);
|
|
63
|
+
const total = values.reduce((sum, v) => sum + v, 0);
|
|
64
|
+
if (fn === 'sum') return total;
|
|
65
|
+
return total / values.length; // avg
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Formula fields are never stored -- computed at read time so the result
|
|
69
|
+
// can never drift from its inputs the way a separately-writable computed
|
|
70
|
+
// column could. Runs right after decryption in busybase/store.js's
|
|
71
|
+
// get()/list(), the same choke point field-encryption already uses, so it
|
|
72
|
+
// is transparent to every existing caller of either function. Two shapes:
|
|
73
|
+
// fieldDef.formula (same-record arithmetic expression) and fieldDef.aggregate
|
|
74
|
+
// (cross-entity count/sum/avg) -- mutually exclusive per field, checked here.
|
|
75
|
+
export async function computeFormulaFields(record, specFields, entityName) {
|
|
10
76
|
if (!record || !specFields) return record;
|
|
11
77
|
let result = record;
|
|
12
78
|
for (const [key, fieldDef] of Object.entries(specFields)) {
|
|
13
|
-
if (fieldDef.type !== 'formula'
|
|
14
|
-
|
|
79
|
+
if (fieldDef.type !== 'formula') continue;
|
|
80
|
+
if (!fieldDef.formula && !fieldDef.aggregate) continue;
|
|
81
|
+
if (result === record) result = { ...record };
|
|
15
82
|
try {
|
|
16
|
-
if (
|
|
17
|
-
|
|
83
|
+
if (fieldDef.aggregate) {
|
|
84
|
+
result[key] = await evaluateAggregateFormula(fieldDef.aggregate, record, entityName);
|
|
85
|
+
} else {
|
|
86
|
+
const allowedFieldNames = new Set(Object.keys(specFields));
|
|
87
|
+
result[key] = evaluateFormula(fieldDef.formula, record, allowedFieldNames);
|
|
88
|
+
}
|
|
18
89
|
} catch {
|
|
19
90
|
// A malformed/unreferenceable formula must not crash the read path for
|
|
20
91
|
// every other field on the record -- degrade to null for this field
|
|
21
92
|
// only, the same fail-soft pattern current_stock/total_hours already
|
|
22
93
|
// use on their own computation failures in page-handler.js.
|
|
23
|
-
if (result === record) result = { ...record };
|
|
24
94
|
result[key] = null;
|
|
25
95
|
}
|
|
26
96
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Single source of truth for inventory demand-forecast math, shared between
|
|
2
|
+
// page-handler.js's product detail-view computation and any future forecast
|
|
3
|
+
// summary view -- kept in one place so they never drift, the same pattern
|
|
4
|
+
// contract-expiry.js already established for expiry math.
|
|
5
|
+
const DEFAULT_WINDOW_DAYS = 90;
|
|
6
|
+
const DEFAULT_LEAD_TIME_DAYS = 14;
|
|
7
|
+
const DAY_SECONDS = 86400;
|
|
8
|
+
|
|
9
|
+
// Average daily consumption over a trailing window: sum of every OUTBOUND
|
|
10
|
+
// (negative quantity) movement within the window, divided by the window's
|
|
11
|
+
// day-count -- not the number of movements, so sparse activity (e.g. one
|
|
12
|
+
// large monthly shipment) still averages correctly over the full period
|
|
13
|
+
// rather than only the days something happened.
|
|
14
|
+
export function averageDailyConsumption(movements, windowDays = DEFAULT_WINDOW_DAYS, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
15
|
+
const windowStart = nowSeconds - windowDays * DAY_SECONDS;
|
|
16
|
+
const outboundTotal = movements
|
|
17
|
+
.filter(m => Number(m.created_at) >= windowStart && Number(m.quantity) < 0)
|
|
18
|
+
.reduce((sum, m) => sum + Math.abs(Number(m.quantity) || 0), 0);
|
|
19
|
+
return outboundTotal / windowDays;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// null (not Infinity, not a crash) when there is no consumption to project
|
|
23
|
+
// against -- a caller rendering this must be able to tell "we don't know"
|
|
24
|
+
// apart from "never" without special-casing an infinite number.
|
|
25
|
+
export function daysUntilStockout(currentStock, avgDailyConsumption) {
|
|
26
|
+
if (!Number.isFinite(avgDailyConsumption) || avgDailyConsumption <= 0) return null;
|
|
27
|
+
if (currentStock <= 0) return 0;
|
|
28
|
+
return currentStock / avgDailyConsumption;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function suggestedReorderDate(daysUntilStockoutValue, leadTimeDays = DEFAULT_LEAD_TIME_DAYS, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
32
|
+
if (daysUntilStockoutValue == null) return null;
|
|
33
|
+
const daysUntilReorder = daysUntilStockoutValue - leadTimeDays;
|
|
34
|
+
return nowSeconds + daysUntilReorder * DAY_SECONDS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isReorderDue(reorderDateSeconds, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
38
|
+
if (reorderDateSeconds == null) return false;
|
|
39
|
+
return reorderDateSeconds <= nowSeconds;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function computeInventoryForecast(product, movements, currentStock, options = {}) {
|
|
43
|
+
const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
44
|
+
const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
|
|
45
|
+
const leadTimeDays = product.lead_time_days ?? DEFAULT_LEAD_TIME_DAYS;
|
|
46
|
+
|
|
47
|
+
const avgDailyConsumption = averageDailyConsumption(movements, windowDays, nowSeconds);
|
|
48
|
+
const daysUntilOut = daysUntilStockout(currentStock, avgDailyConsumption);
|
|
49
|
+
const reorderDate = suggestedReorderDate(daysUntilOut, leadTimeDays, nowSeconds);
|
|
50
|
+
const reorderDue = isReorderDue(reorderDate, nowSeconds);
|
|
51
|
+
|
|
52
|
+
return { avg_daily_consumption: avgDailyConsumption, days_until_stockout: daysUntilOut, reorder_date: reorderDate, reorder_due: reorderDue };
|
|
53
|
+
}
|
|
@@ -139,6 +139,24 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
139
139
|
})()
|
|
140
140
|
: ''
|
|
141
141
|
|
|
142
|
+
// Forecast fields are computed alongside current_stock (page-handler.js's
|
|
143
|
+
// inventory-forecast.js), same not-a-spec-field treatment.
|
|
144
|
+
const forecastRow = entityName === 'product' && item.days_until_stockout !== undefined
|
|
145
|
+
? (() => {
|
|
146
|
+
const daysLabel = item.days_until_stockout == null ? 'Unknown (no recent consumption)' : `${Math.round(item.days_until_stockout)} days`
|
|
147
|
+
const reorderLabel = item.reorder_date == null ? '-' : new Date(item.reorder_date * 1000).toISOString().slice(0, 10)
|
|
148
|
+
const pillCls = item.reorder_due ? 'pill-danger' : 'pill-success'
|
|
149
|
+
return `<div class="detail-row">
|
|
150
|
+
<span class="detail-row-label">Days Until Stockout</span>
|
|
151
|
+
<span class="detail-row-value">${esc(daysLabel)}</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="detail-row">
|
|
154
|
+
<span class="detail-row-label">Suggested Reorder Date</span>
|
|
155
|
+
<span class="detail-row-value"><span class="pill ${pillCls}">${esc(reorderLabel)}${item.reorder_due ? ' (Reorder Now)' : ''}</span></span>
|
|
156
|
+
</div>`
|
|
157
|
+
})()
|
|
158
|
+
: ''
|
|
159
|
+
|
|
142
160
|
// total_hours/billable_amount are computed (page-handler.js sums time_entry
|
|
143
161
|
// rows, directly for a task or joined through task for a project), same
|
|
144
162
|
// not-a-spec-field treatment as current_stock above.
|
|
@@ -177,7 +195,7 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
177
195
|
|
|
178
196
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
179
197
|
|
|
180
|
-
const fieldRows = stockRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
|
|
198
|
+
const fieldRows = stockRow + forecastRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
|
|
181
199
|
`<div class="detail-row">
|
|
182
200
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
183
201
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
package/src/ui/page-handler.js
CHANGED
|
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
|
|
|
9
9
|
import { renderBoardView } from '@/ui/board-view-renderer.js';
|
|
10
10
|
import { renderGridView } from '@/ui/grid-view-renderer.js';
|
|
11
11
|
import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
|
|
12
|
-
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport } from '@/ui/report-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport } from '@/ui/report-renderer.js';
|
|
13
13
|
import { renderClientProgress } from '@/ui/client-progress-renderer.js';
|
|
14
14
|
import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
|
|
15
15
|
import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
|
|
@@ -118,12 +118,18 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
118
118
|
if (entityName === 'product') {
|
|
119
119
|
// current_stock is derived, never a stored/editable field -- summed fresh
|
|
120
120
|
// from stock_movement history so it can never drift from the ledger the
|
|
121
|
-
// way a separately-writable counter could.
|
|
121
|
+
// way a separately-writable counter could. stock_movement carries no
|
|
122
|
+
// organization_id (product is a system_entity, exempt from multi-tenancy
|
|
123
|
+
// auto-scoping the same way every other system entity this session is),
|
|
124
|
+
// so this unscoped list() matches the entity's actual access model
|
|
125
|
+
// rather than bypassing a row-access check that was never in effect.
|
|
122
126
|
try {
|
|
123
127
|
const movements = await list('stock_movement', { product_id: id });
|
|
124
128
|
const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
const { computeInventoryForecast } = await import('@/lib/inventory-forecast.js');
|
|
130
|
+
const forecast = computeInventoryForecast(resolvedItem, movements, currentStock);
|
|
131
|
+
resolvedItem = { ...resolvedItem, current_stock: currentStock, ...forecast };
|
|
132
|
+
} catch { resolvedItem = { ...resolvedItem, current_stock: 0, avg_daily_consumption: 0, days_until_stockout: null, reorder_date: null, reorder_due: false }; }
|
|
127
133
|
}
|
|
128
134
|
if (entityName === 'task') {
|
|
129
135
|
// total_hours/billable_amount are derived from time_entry history, same
|
|
@@ -370,6 +376,22 @@ export async function handlePage(pathname, req, res) {
|
|
|
370
376
|
const relatedRecords = await list(relatedEntity, {}, { user });
|
|
371
377
|
return renderRollupReport(user, entityName, spec, items, refField, relatedEntity, relatedSpec, relatedRecords, rollupField);
|
|
372
378
|
}
|
|
379
|
+
if (report === 'inventory-forecast' && entityName === 'product') {
|
|
380
|
+
// items is already the {user}-scoped product list from above -- no new
|
|
381
|
+
// unscoped bulk query. stock_movement itself carries no organization_id
|
|
382
|
+
// (product is a system_entity, exempt from multi-tenancy auto-scoping
|
|
383
|
+
// like every other system entity this session), so it is read
|
|
384
|
+
// per-product the same unscoped way the detail view already does.
|
|
385
|
+
const { computeInventoryForecast } = await import('@/lib/inventory-forecast.js');
|
|
386
|
+
const forecastRows = [];
|
|
387
|
+
for (const p of items) {
|
|
388
|
+
const movements = await list('stock_movement', { product_id: p.id });
|
|
389
|
+
const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
|
|
390
|
+
const forecast = computeInventoryForecast(p, movements, currentStock);
|
|
391
|
+
forecastRows.push({ ...p, current_stock: currentStock, ...forecast });
|
|
392
|
+
}
|
|
393
|
+
return renderInventoryForecastReport(user, spec, forecastRows);
|
|
394
|
+
}
|
|
373
395
|
const view = params.get('view');
|
|
374
396
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
375
397
|
if (view === 'grid') return renderGridView(user, entityName, spec, items);
|
|
@@ -206,3 +206,34 @@ export function renderCountOverTimeReport(user, entityName, spec, records, dateF
|
|
|
206
206
|
${barChart(buckets)}`;
|
|
207
207
|
return page(user, `${label} Report | Thatcher`, null, content);
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
// One table per-product rather than a bar chart: unlike count-by-field/
|
|
211
|
+
// sum-by-field's single grouped metric, a forecast is inherently multiple
|
|
212
|
+
// values per row (stock, consumption rate, days-out, reorder date) -- a bar
|
|
213
|
+
// chart of any single one of those would discard the others, so a table is
|
|
214
|
+
// the correct shape here, reusing the same page()/esc() rendering discipline
|
|
215
|
+
// as every other report rather than inventing a new chart type.
|
|
216
|
+
export function renderInventoryForecastReport(user, spec, forecastRows) {
|
|
217
|
+
const label = getEntityLabel(spec, true) || 'Products';
|
|
218
|
+
const rows = forecastRows.map(p => {
|
|
219
|
+
const daysLabel = p.days_until_stockout == null ? 'Unknown' : String(Math.round(p.days_until_stockout))
|
|
220
|
+
const reorderLabel = p.reorder_date == null ? '-' : new Date(p.reorder_date * 1000).toISOString().slice(0, 10)
|
|
221
|
+
const rowCls = p.reorder_due ? 'style="background:var(--color-danger-bg,#fee2e2)"' : ''
|
|
222
|
+
return `<tr ${rowCls}>
|
|
223
|
+
<td>${esc(p.name || p.id)}</td>
|
|
224
|
+
<td>${esc(String(p.current_stock))}</td>
|
|
225
|
+
<td>${esc((p.avg_daily_consumption || 0).toFixed(2))}</td>
|
|
226
|
+
<td>${esc(daysLabel)}</td>
|
|
227
|
+
<td>${esc(reorderLabel)}${p.reorder_due ? ' <span class="pill pill-danger">Reorder Now</span>' : ''}</td>
|
|
228
|
+
</tr>`;
|
|
229
|
+
}).join('') || emptyState('No products to forecast', 'bar-chart');
|
|
230
|
+
|
|
231
|
+
const content = `<div class="page-header">
|
|
232
|
+
<div><h1 class="page-title">${esc(label)}: Inventory Forecast</h1><p class="page-subtitle">${forecastRows.length} products</p></div>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="table-wrap"><table class="data-table">
|
|
235
|
+
<thead><tr><th>Product</th><th>Current Stock</th><th>Avg Daily Use</th><th>Days Until Stockout</th><th>Suggested Reorder</th></tr></thead>
|
|
236
|
+
<tbody>${rows}</tbody>
|
|
237
|
+
</table></div>`;
|
|
238
|
+
return page(user, `Inventory Forecast | Thatcher`, null, content);
|
|
239
|
+
}
|