@feedmepos/mf-report 5.28.5 → 5.29.0

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.
Files changed (35) hide show
  1. package/dist/{App-Bc8Mp5vR.js → App-CuBsRwfx.js} +4 -4
  2. package/dist/{DateRangeSelect-cbSEHMzW.js → DateRangeSelect-CzjlwjuC.js} +1 -1
  3. package/dist/{Default.vue_vue_type_script_setup_true_lang-BpJFA_ET.js → Default.vue_vue_type_script_setup_true_lang-Di9_F5Yk.js} +1 -1
  4. package/dist/{DynamicWidget.vue_vue_type_script_setup_true_lang-Dx-cxJP6.js → DynamicWidget.vue_vue_type_script_setup_true_lang-C73R6Fb9.js} +5 -5
  5. package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-Cxrti7_L.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-tLRFdLNs.js} +9 -10
  6. package/dist/{InsightView-Bo0Fb5QG.js → InsightView-BcnmLQ8c.js} +5 -5
  7. package/dist/{Integration-B4tC2MlL.js → Integration-D4VFT1M8.js} +3 -3
  8. package/dist/{Integrations-RB8iuvdJ.js → Integrations-v8oF1TL7.js} +2 -2
  9. package/dist/{Layout-DBCPtUH7.js → Layout-Ds8RSWmi.js} +1 -1
  10. package/dist/{LocationSelect.vue_vue_type_script_setup_true_lang-w2H9qOgJ.js → LocationSelect.vue_vue_type_script_setup_true_lang-Dk6u8nUH.js} +1638 -1634
  11. package/dist/{MenuTab.vue_vue_type_script_setup_true_lang-BLVGUSWf.js → MenuTab.vue_vue_type_script_setup_true_lang-BWt7pqFb.js} +5 -5
  12. package/dist/{NavigationTab.vue_vue_type_script_setup_true_lang-N31ee-oZ.js → NavigationTab.vue_vue_type_script_setup_true_lang-CU00GINo.js} +3853 -3823
  13. package/dist/OverviewView-1v80g6KX.js +81 -0
  14. package/dist/{Report-Di6GyP1r.js → Report-DcFB4gM6.js} +3 -3
  15. package/dist/ReportCanvasView-BQO6Mywc.js +5190 -0
  16. package/dist/{ReportEditor-BfNmiEVn.js → ReportEditor-Bgcisf1s.js} +7 -7
  17. package/dist/{ReportView-JWJ-6FCu.js → ReportView-Q_juD8Kr.js} +6 -6
  18. package/dist/{ReportViewRoute-BMjjJqN2.js → ReportViewRoute-DwStGqyz.js} +2 -2
  19. package/dist/{Setting-D13NIemB.js → Setting-C9rlM23O.js} +3 -3
  20. package/dist/{Template-D16bqzrx.js → Template-BZd0NB5w.js} +3 -3
  21. package/dist/{TileEditor-1Sn9ZSb2.js → TileEditor-B2F9r3RS.js} +6 -6
  22. package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-BOXsV7-r.js → TilePageStack.vue_vue_type_script_setup_true_lang-BmbrJAph.js} +2 -2
  23. package/dist/{TransitionFade-DwJkmMTb.js → TransitionFade-Dkeo9qr0.js} +5 -5
  24. package/dist/{app-CVBllEjW.js → app-CoQ_GRKo.js} +1819 -1326
  25. package/dist/app.js +1 -1
  26. package/dist/assets/{formatChartData.worker-DZp5dSDQ.js → formatChartData.worker-D7sGqQhX.js} +34 -34
  27. package/dist/assets/{processTableData.worker-Kyuhw4lS.js → processTableData.worker-CltxwQxU.js} +34 -34
  28. package/dist/{report-Cn4Ik1zm.js → report-Dau_so1j.js} +1 -1
  29. package/dist/report-canvas-libs/components-1.js +1042 -0
  30. package/dist/report-canvas-libs/fm-ui-1.js +599 -180
  31. package/dist/style.css +1 -1
  32. package/dist/{useRestaurantPermission-C8bLPfk8.js → useRestaurantPermission-CPoHZTxx.js} +1 -1
  33. package/package.json +2 -1
  34. package/dist/OverviewView-WKe6Zxe1.js +0 -61
  35. package/dist/ReportCanvasView-Dg2ItHBO.js +0 -1336
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * FM.components — declarative section renderers, delivered as the `components@1` lib.
3
+ * A component section is config rendered by this tested code; the model writes no JS.
4
+ * Renderers subscribe ONLY synchronously during mount (the frame attributes errors and
5
+ * rows callbacks to the section it is currently mounting) and treat config as data:
6
+ * unknown keys are ignored, the server already validated shape at write time.
7
+ */
8
+ ;(function () {
9
+ "use strict"
10
+
11
+ var FM = (window.FM = window.FM || {})
12
+ if (FM.components) return
13
+
14
+ // Colours come from the Vue-v2 Rebus tokens the frame embeds; Chart.js needs them resolved,
15
+ // so they are read at paint time. The literal is only the fallback for a frame without the sheet.
16
+ function token(name, fallback) {
17
+ var value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
18
+ return value || fallback
19
+ }
20
+ function px(name, fallback) {
21
+ var value = parseFloat(token(name, ""))
22
+ return isFinite(value) ? value : fallback
23
+ }
24
+ // chart families per the chart-design guide: lines step through blossom, bars take grape
25
+ var LINE_STEPS = [
26
+ ["--fm-feature-blossom-fg", "#8c146a"],
27
+ ["--fm-feature-blossom-accent", "#ffc4c3"],
28
+ ["--fm-feature-grape-accent", "#8f61e8"],
29
+ ["--fm-feature-aqua-accent", "#3ab0ff"]
30
+ ]
31
+ function lineColor(index) {
32
+ var step = LINE_STEPS[index % LINE_STEPS.length]
33
+ return token(step[0], step[1])
34
+ }
35
+ function barColor() {
36
+ return token("--fm-feature-grape-fg", "#512191")
37
+ }
38
+ function compareColor() {
39
+ return token("--fm-text-disabled", "#b2b3b6")
40
+ }
41
+ function axis() {
42
+ return {
43
+ border: { display: false },
44
+ ticks: { color: token("--fm-text-muted", "#6b6c70"), font: { size: px("--fm-font-size-14", 14) } }
45
+ }
46
+ }
47
+ function gridColor() {
48
+ return token("--fm-border-default", "#f0f0f0")
49
+ }
50
+ function chartOpts() {
51
+ return {
52
+ responsive: true,
53
+ maintainAspectRatio: false,
54
+ animation: false,
55
+ elements: { bar: { borderRadius: px("--fm-radius-xs", 4) } },
56
+ plugins: {
57
+ legend: { display: false },
58
+ tooltip: {
59
+ backgroundColor: token("--fm-bg-inverse", "#1c1c1e"),
60
+ titleColor: token("--fm-text-inverse", "#fff"),
61
+ bodyColor: token("--fm-text-inverse", "#fff"),
62
+ cornerRadius: px("--fm-radius-sm", 8),
63
+ padding: 10,
64
+ boxWidth: 8,
65
+ boxHeight: 8,
66
+ borderWidth: 0
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ // --- formatting: every value leaves PG as a string; env is read at call time
73
+
74
+ function num(value) {
75
+ var parsed = Number(value)
76
+ return isFinite(parsed) ? parsed : 0
77
+ }
78
+
79
+ function intl(digits) {
80
+ return new Intl.NumberFormat((window.env && window.env.locale) || "en-MY", {
81
+ minimumFractionDigits: digits,
82
+ maximumFractionDigits: digits
83
+ })
84
+ }
85
+
86
+ function formatValue(format, value) {
87
+ if (value === null || value === undefined || value === "") return "—"
88
+ switch (format) {
89
+ case "money":
90
+ return intl(2).format(num(value))
91
+ case "amount":
92
+ return intl(2).format(num(value))
93
+ case "count":
94
+ return intl(0).format(num(value))
95
+ case "percent":
96
+ return intl(1).format(num(value)) + "%"
97
+ case "ratio":
98
+ return intl(1).format(num(value) * 100) + "%"
99
+ case "datetime":
100
+ return new Date(value).toLocaleString((window.env && window.env.locale) || "en-MY", {
101
+ dateStyle: "medium",
102
+ timeStyle: "short"
103
+ })
104
+ default:
105
+ return String(value)
106
+ }
107
+ }
108
+
109
+ /** Axis ticks: RM 0, RM 2k, RM 10k — abbreviated, per the chart guide. */
110
+ function abbrev(format) {
111
+ return function (value) {
112
+ var v = num(value)
113
+ var currency = format === "money" ? ((window.env && window.env.currency) || "") + " " : ""
114
+ if (Math.abs(v) >= 1000000) return currency + (v / 1000000).toFixed(1).replace(/\.0$/, "") + "m"
115
+ if (Math.abs(v) >= 1000) return currency + (v / 1000).toFixed(1).replace(/\.0$/, "") + "k"
116
+ // a tiny domain otherwise rounds every tick to the same label
117
+ return currency + intl(Math.abs(v) > 0 && Math.abs(v) < 10 ? 2 : 0).format(v)
118
+ }
119
+ }
120
+
121
+ /** Marks a node as the config it renders, so a comment on it can name the config path. */
122
+ function part(node, path) {
123
+ if (path) node.setAttribute("data-fm-part", path)
124
+ return node
125
+ }
126
+
127
+ function el(tag, className, text) {
128
+ var node = document.createElement(tag)
129
+ if (className) node.className = className
130
+ if (text !== undefined && text !== null) node.textContent = String(text)
131
+ return node
132
+ }
133
+
134
+ function tile(root, title, base) {
135
+ var card = el("div", "fmr-card fmr-tile")
136
+ if (title) {
137
+ var head = el("div", "fmr-tile-head")
138
+ head.appendChild(part(el("h2", "fmr-tile-title", title), base + "title"))
139
+ card.appendChild(head)
140
+ }
141
+ root.appendChild(card)
142
+ return card
143
+ }
144
+
145
+ function emptyNote(config) {
146
+ return el("p", "fmr-note", config.empty || "No data in this period.")
147
+ }
148
+
149
+ /** '$row.col' / '$x' references resolve against the interaction; literals pass through. */
150
+ function resolveSet(setConfig, row, x) {
151
+ var values = {}
152
+ Object.keys(setConfig || {}).forEach(function (param) {
153
+ var value = setConfig[param]
154
+ if (typeof value === "string" && value.indexOf("$row.") === 0) {
155
+ values[param] = row ? row[value.slice(5)] : null
156
+ } else if (value === "$x") {
157
+ values[param] = x
158
+ } else {
159
+ values[param] = value
160
+ }
161
+ })
162
+ return values
163
+ }
164
+
165
+ // --- controls: one shared implementation, at every placement
166
+
167
+ var LOCATION_CONTEXT = "locations"
168
+
169
+ function paramType(name) {
170
+ var meta = FM.paramMeta && FM.paramMeta(name)
171
+ return meta ? meta.type : undefined
172
+ }
173
+
174
+ function declaredOptions(name) {
175
+ var meta = FM.paramMeta && FM.paramMeta(name)
176
+ return meta && meta.options && meta.options.length ? meta.options : null
177
+ }
178
+
179
+ /** A list declared with an empty default means every value, so ticking nothing is not "None". */
180
+ function emptyMeansAll(name) {
181
+ var meta = FM.paramMeta && FM.paramMeta(name)
182
+ return !!(meta && Array.isArray(meta.default) && !meta.default.length)
183
+ }
184
+
185
+ function sameValue(a, b) {
186
+ return JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b)
187
+ }
188
+
189
+ /**
190
+ * Segmented only for a small static scalar domain: an array or a source that refreshes would
191
+ * otherwise change interaction style under the merchant's hand.
192
+ */
193
+ function presentationOf(control, count, multiple, dynamic) {
194
+ if (control.kind) return control.kind
195
+ var wanted = control.presentation || "auto"
196
+ if (wanted !== "auto") return wanted
197
+ return !multiple && !dynamic && count > 1 && count <= 5 ? "segmented" : "dropdown"
198
+ }
199
+
200
+ /** A date control commits both endpoints in one write, so no query sees half a range. */
201
+ function mountDate(mount, control) {
202
+ var single = !control.toParam
203
+ function reading(values) {
204
+ return {
205
+ from: values[control.param],
206
+ to: control.toParam ? values[control.toParam] : values[control.param]
207
+ }
208
+ }
209
+ var handle = FM.ui.dateRange(mount, {
210
+ single: single,
211
+ value: reading(FM.params()),
212
+ onChange: function (range) {
213
+ var patch = {}
214
+ patch[control.param] = range.from
215
+ if (control.toParam) patch[control.toParam] = range.to
216
+ FM.setParams(patch)
217
+ }
218
+ })
219
+ FM.onParams(single ? [control.param] : [control.param, control.toParam], function (values) {
220
+ handle.setValue(reading(values))
221
+ })
222
+ }
223
+
224
+ function mountSelection(mount, note, control) {
225
+ var name = control.param
226
+ var type = paramType(name)
227
+ var multiple = type === "string[]" || type === "number[]"
228
+ var source = control.optionsFrom
229
+ var outlets = !!(source && source.context === LOCATION_CONTEXT)
230
+ var optionQuery = source && source.query ? source.query : null
231
+ var dynamic = outlets || !!optionQuery
232
+
233
+ function fromSource() {
234
+ if (outlets) {
235
+ return (window.env.locations || []).map(function (location) {
236
+ return { label: location.name || location.id, value: location.id }
237
+ })
238
+ }
239
+ var labelColumn = source.label || source.value
240
+ var seen = Object.create(null)
241
+ var out = []
242
+ FM.rows(optionQuery).forEach(function (row) {
243
+ var value = row[source.value]
244
+ if (value === null || value === undefined) return
245
+ var key = String(value)
246
+ if (seen[key]) return
247
+ seen[key] = 1
248
+ var text = row[labelColumn]
249
+ out.push({ label: String(text === null || text === undefined ? value : text), value: value })
250
+ })
251
+ return out
252
+ }
253
+
254
+ /** One source, and a declared domain is the ceiling a dynamic one may offer within. */
255
+ function options() {
256
+ if (control.options) return control.options
257
+ if (!dynamic) return declaredOptions(name) || []
258
+ var offered = fromSource()
259
+ var declared = declaredOptions(name)
260
+ if (!declared) return offered
261
+ var allowed = Object.create(null)
262
+ declared.forEach(function (option) {
263
+ allowed[String(option.value)] = 1
264
+ })
265
+ return offered.filter(function (option) {
266
+ return allowed[String(option.value)]
267
+ })
268
+ }
269
+
270
+ function write(value) {
271
+ var patch = {}
272
+ patch[name] = value
273
+ FM.setParams(patch)
274
+ }
275
+
276
+ var items = options()
277
+ var presentation = presentationOf(control, items.length, multiple, dynamic)
278
+ var current = FM.params()[name]
279
+ var handle
280
+ if (presentation === "segmented") {
281
+ handle = FM.ui.segmented(mount, { options: items, value: current, onChange: write })
282
+ } else {
283
+ handle = FM.ui.dropdown(mount, {
284
+ options: items,
285
+ multiple: multiple,
286
+ value: current,
287
+ placeholder: multiple && emptyMeansAll(name) ? "All" : null,
288
+ icon: outlets ? "store" : null,
289
+ all: outlets
290
+ ? {
291
+ label: control.label ? "All " + control.label.toLowerCase() : "All",
292
+ active: FM.paramIntent(name) === "all",
293
+ onSelect: function () {
294
+ var patch = {}
295
+ var intent = {}
296
+ patch[name] = options().map(function (option) {
297
+ return option.value
298
+ })
299
+ intent[name] = "all"
300
+ FM.setParams(patch, { intent: intent })
301
+ }
302
+ }
303
+ : null,
304
+ onChange: write
305
+ })
306
+ }
307
+
308
+ /** A value the refreshed source no longer offers is said, never silently rewritten. */
309
+ function checkMissing(value) {
310
+ if (!items.length || value === null || value === undefined) return false
311
+ var offered = Object.create(null)
312
+ items.forEach(function (option) {
313
+ offered[String(option.value)] = 1
314
+ })
315
+ var picked = Array.isArray(value) ? value : [value]
316
+ return picked.some(function (one) {
317
+ return !offered[String(one)]
318
+ })
319
+ }
320
+
321
+ function say(text) {
322
+ note.hidden = !text
323
+ note.textContent = text || ""
324
+ }
325
+
326
+ function repaint(values) {
327
+ var value = values[name]
328
+ if (!sameValue(handle.getValue(), value)) handle.setValue(value)
329
+ if (handle.setAll) handle.setAll(FM.paramIntent(name) === "all")
330
+ if (FM.paramIntent(name) === "all") return say("")
331
+ say(checkMissing(value) ? "The saved selection is no longer offered." : "")
332
+ }
333
+
334
+ FM.onParams([name], function (values) {
335
+ // the outlet list resolves after the first paint, so the options are re-read here too
336
+ if (outlets) {
337
+ items = options()
338
+ handle.setOptions(items)
339
+ }
340
+ repaint(values)
341
+ })
342
+
343
+ if (optionQuery) {
344
+ FM.onRows(optionQuery, function () {
345
+ var status = FM.status(optionQuery)
346
+ items = options()
347
+ handle.setOptions(items)
348
+ // only its own source disables it: a chart refreshing must never take the filter away
349
+ if (handle.setDisabled) handle.setDisabled(status === "loading" && !items.length)
350
+ if (status === "error") return say("Options unavailable.")
351
+ repaint(FM.params())
352
+ })
353
+ }
354
+ }
355
+
356
+ /** Controls are canvas elements: a standalone bar, or a row below a widget's title. */
357
+ function renderControls(root, controls, base) {
358
+ if (!controls || !controls.length) return
359
+ if (!FM.ui) throw new Error("fm-ui@1 lib missing")
360
+ var row = el("div", "fm-controls")
361
+ root.appendChild(row)
362
+ controls.forEach(function (control, index) {
363
+ var slot = part(el("div", "fm-control"), base + "controls." + index)
364
+ if (control.label)
365
+ slot.appendChild(el("span", "fm-control-label fm-text-body-sm-500", control.label))
366
+ var mount = el("span")
367
+ slot.appendChild(mount)
368
+ var note = el("span", "fm-control-note fm-text-body-sm-400")
369
+ note.hidden = true
370
+ slot.appendChild(note)
371
+ row.appendChild(slot)
372
+ if (control.type === "date") mountDate(mount, control)
373
+ else mountSelection(mount, note, control)
374
+ })
375
+ }
376
+
377
+ // --- filter-bar: a standalone grouping container for controls
378
+
379
+ function renderFilterBar(root, config, base) {
380
+ renderControls(root, config.controls, base || "")
381
+ }
382
+
383
+ // --- kpi-row: tiles with an optional previous-period chip
384
+
385
+ function renderKpiRow(root, config, base) {
386
+ base = base || ""
387
+ renderControls(root, config.controls, base)
388
+ var row = el("div", "fmr-kpis")
389
+ root.appendChild(row)
390
+ var tiles = config.tiles || []
391
+ var nodes = tiles.map(function (tileConfig, index) {
392
+ var card = part(el("div", "fmr-card fmr-kpi"), base + "tiles." + index)
393
+ card.appendChild(el("div", "fmr-kpi-label", tileConfig.label))
394
+ var value = el("div", "fmr-kpi-value", "—")
395
+ card.appendChild(value)
396
+ // the comparison gets its own line: on the value line it has only the room the
397
+ // number leaves, which at four money tiles across is none
398
+ var delta = el("div", "fmr-kpi-delta")
399
+ card.appendChild(delta)
400
+ if (tileConfig.caption) card.appendChild(el("div", "fmr-kpi-note", tileConfig.caption))
401
+ row.appendChild(card)
402
+ return { value: value, delta: delta }
403
+ })
404
+
405
+ function paint() {
406
+ tiles.forEach(function (tileConfig, index) {
407
+ var rows = FM.rows(tileConfig.query)
408
+ var target = nodes[index].value
409
+ var deltaNode = nodes[index].delta
410
+ target.textContent = ""
411
+ deltaNode.textContent = ""
412
+ var raw = rows.length ? rows[0][tileConfig.value] : null
413
+ if (tileConfig.format === "money" && raw !== null && raw !== undefined) {
414
+ target.appendChild(el("span", "fmr-kpi-cur", (window.env && window.env.currency) || ""))
415
+ target.appendChild(document.createTextNode(formatValue("amount", raw)))
416
+ } else {
417
+ target.appendChild(document.createTextNode(formatValue(tileConfig.format || "amount", raw)))
418
+ }
419
+ if (tileConfig.compareQuery && raw !== null && raw !== undefined) {
420
+ var prevRows = FM.rows(tileConfig.compareQuery)
421
+ var prev = prevRows.length ? num(prevRows[0][tileConfig.compareValue || tileConfig.value]) : null
422
+ if (prev !== null && prev !== 0) {
423
+ var delta = ((num(raw) - prev) / Math.abs(prev)) * 100
424
+ var goodWhen = tileConfig.deltaGoodWhen || "up"
425
+ var good = goodWhen === "up" ? delta >= 0 : delta <= 0
426
+ var chipClass = Math.abs(delta) < 0.05 ? "flat" : good ? "up" : "down"
427
+ var arrow = Math.abs(delta) < 0.05 ? "→" : delta >= 0 ? "↗" : "↘"
428
+ deltaNode.appendChild(
429
+ el("span", "fmr-trend " + chipClass, arrow + " " + intl(1).format(Math.abs(delta)) + "%")
430
+ )
431
+ }
432
+ }
433
+ })
434
+ }
435
+
436
+ var seen = {}
437
+ tiles.forEach(function (tileConfig) {
438
+ ;[tileConfig.query, tileConfig.compareQuery].forEach(function (queryId) {
439
+ if (!queryId || seen[queryId]) return
440
+ seen[queryId] = true
441
+ FM.onRows(queryId, paint)
442
+ })
443
+ })
444
+ }
445
+
446
+ // --- charts
447
+
448
+ function mountChart(body, build) {
449
+ var wrap = el("div", "fmr-chart")
450
+ var canvas = document.createElement("canvas")
451
+ wrap.appendChild(canvas)
452
+ body.appendChild(wrap)
453
+ var chart = null
454
+ return function paint(data, note) {
455
+ if (!window.Chart) {
456
+ body.textContent = ""
457
+ body.appendChild(el("p", "fmr-note error", "chart library unavailable"))
458
+ return
459
+ }
460
+ if (note) {
461
+ wrap.hidden = true
462
+ if (!body.__note) {
463
+ body.__note = note
464
+ body.appendChild(note)
465
+ }
466
+ } else {
467
+ wrap.hidden = false
468
+ if (body.__note) {
469
+ body.removeChild(body.__note)
470
+ body.__note = null
471
+ }
472
+ if (chart) {
473
+ chart.data = data
474
+ chart.update()
475
+ } else {
476
+ chart = new window.Chart(canvas, build(data))
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ function renderTimeseries(root, config, base) {
483
+ base = base || ""
484
+ var card = tile(root, config.title, base)
485
+ renderControls(card, config.controls, base)
486
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
487
+ card.appendChild(body)
488
+ var compareQuery = config.compare && (config.compare.query || config.query)
489
+ var paint = mountChart(body, function (data) {
490
+ var ax = axis()
491
+ return {
492
+ type: "line",
493
+ data: data,
494
+ options: Object.assign({}, chartOpts(), {
495
+ scales: {
496
+ x: Object.assign({}, ax, { grid: { display: false } }),
497
+ y: Object.assign({}, ax, {
498
+ // a value axis cropped above zero exaggerates every wiggle into a peak
499
+ beginAtZero: true,
500
+ grid: { color: gridColor() },
501
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
502
+ })
503
+ }
504
+ })
505
+ }
506
+ })
507
+
508
+ function refresh() {
509
+ var rows = FM.rows(config.query)
510
+ if (!rows.length && FM.status(config.query) !== "loading") {
511
+ paint(null, emptyNote(config))
512
+ return
513
+ }
514
+ var labels = rows.map(function (row) {
515
+ var raw = row[config.x]
516
+ if (config.xType === "day") {
517
+ var day = new Date(raw)
518
+ if (!isNaN(day)) {
519
+ return day.toLocaleDateString((window.env && window.env.locale) || "en-MY", {
520
+ month: "short",
521
+ day: "numeric"
522
+ })
523
+ }
524
+ }
525
+ return String(raw)
526
+ })
527
+ // a mixed series is drawn as its own family: bars take grape, lines step through blossom.
528
+ // chart.js draws the order-sorted metasets backwards, so the lower order lands on top —
529
+ // the line has to stay readable where it crosses its own bars.
530
+ var lineIndex = 0
531
+ // few enough days to read one by one: draw the points, or the line is a bare triangle
532
+ var marker = rows.length <= 14 ? 3 : 0
533
+ var datasets = (config.series || []).map(function (series) {
534
+ var data = rows.map(function (row) { return num(row[series.y]) })
535
+ if (series.kind === "bar") {
536
+ return {
537
+ label: series.label,
538
+ type: "bar",
539
+ data: data,
540
+ backgroundColor: barColor(),
541
+ borderWidth: 0,
542
+ maxBarThickness: 20,
543
+ order: 2
544
+ }
545
+ }
546
+ var color = lineColor(lineIndex++)
547
+ return {
548
+ label: series.label,
549
+ type: "line",
550
+ data: data,
551
+ borderColor: color,
552
+ backgroundColor: color,
553
+ borderWidth: 2,
554
+ tension: 0,
555
+ pointRadius: marker,
556
+ pointHoverRadius: 4,
557
+ fill: false,
558
+ order: 1
559
+ }
560
+ })
561
+ if (config.compare) {
562
+ var compareRows = FM.rows(compareQuery)
563
+ datasets.push({
564
+ label: config.compare.label || "Previous period",
565
+ type: "line",
566
+ // aligned by row index: both windows are the same length by construction
567
+ data: rows.map(function (row, index) {
568
+ var match = compareRows[index]
569
+ return match ? num(match[config.compare.y]) : null
570
+ }),
571
+ borderColor: compareColor(),
572
+ borderDash: [4, 4],
573
+ borderWidth: 2,
574
+ tension: 0,
575
+ pointRadius: marker,
576
+ pointHoverRadius: 4,
577
+ fill: false,
578
+ order: 3
579
+ })
580
+ }
581
+ paint({ labels: labels, datasets: datasets }, null)
582
+ }
583
+
584
+ FM.onRows(config.query, refresh)
585
+ if (compareQuery && compareQuery !== config.query) FM.onRows(compareQuery, refresh)
586
+ }
587
+
588
+ function renderBar(root, config, base) {
589
+ base = base || ""
590
+ var card = tile(root, config.title, base)
591
+ renderControls(card, config.controls, base)
592
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
593
+ card.appendChild(body)
594
+ var current = []
595
+ var total = 0
596
+ // shareDenominator only reads .share, so config doubles as a column
597
+ var denominator = shareDenominator(config)
598
+ var paint = mountChart(body, function (data) {
599
+ var opts = chartOpts()
600
+ var ax = axis()
601
+ return {
602
+ type: "bar",
603
+ data: data,
604
+ options: Object.assign({}, opts, {
605
+ onClick: config.clickSet
606
+ ? function (event, elements) {
607
+ if (!elements || !elements.length) return
608
+ var index = elements[0].index
609
+ var row = current[index]
610
+ FM.setParams(resolveSet(config.clickSet, row, row ? row[config.x] : null))
611
+ }
612
+ : undefined,
613
+ plugins: Object.assign({}, opts.plugins, {
614
+ tooltip: config.share
615
+ ? Object.assign({}, opts.plugins.tooltip, {
616
+ callbacks: {
617
+ label: function (context) {
618
+ var amount = context.formattedValue
619
+ if (!total) return amount
620
+ var row = current[context.dataIndex]
621
+ var pct = intl(1).format((num(row ? row[config.y] : 0) / total) * 100)
622
+ return amount + " · " + pct + "% of total"
623
+ }
624
+ }
625
+ })
626
+ : opts.plugins.tooltip
627
+ }),
628
+ scales: {
629
+ x: Object.assign({}, ax, { grid: { display: false } }),
630
+ y: Object.assign({}, ax, {
631
+ // a value axis cropped above zero exaggerates every wiggle into a peak
632
+ beginAtZero: true,
633
+ grid: { color: gridColor() },
634
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
635
+ })
636
+ }
637
+ })
638
+ }
639
+ })
640
+
641
+ function refresh() {
642
+ var rows = FM.rows(config.query)
643
+ current = rows
644
+ if (config.share) {
645
+ if (denominator) {
646
+ var denominatorRows = FM.rows(denominator.query)
647
+ total = denominatorRows.length ? num(denominatorRows[0][denominator.value]) : 0
648
+ } else {
649
+ total = rows.reduce(function (sum, row) { return sum + num(row[config.y]) }, 0)
650
+ }
651
+ }
652
+ if (!rows.length && FM.status(config.query) !== "loading") {
653
+ paint(null, emptyNote(config))
654
+ return
655
+ }
656
+ paint(
657
+ {
658
+ labels: rows.map(function (row) {
659
+ var label = String(row[config.x])
660
+ if (!config.share || !total) return label
661
+ return label + " · " + intl(1).format((num(row[config.y]) / total) * 100) + "%"
662
+ }),
663
+ datasets: [
664
+ {
665
+ data: rows.map(function (row) { return num(row[config.y]) }),
666
+ backgroundColor: barColor(),
667
+ maxBarThickness: 20
668
+ }
669
+ ]
670
+ },
671
+ null
672
+ )
673
+ }
674
+
675
+ FM.onRows(config.query, refresh)
676
+ if (denominator && denominator.query !== config.query) FM.onRows(denominator.query, refresh)
677
+ }
678
+
679
+ // --- table: FM.ui.table plus header sorting and a client-side share column
680
+
681
+ function shareDenominator(column) {
682
+ var denominator = column.share && column.share.denominator
683
+ return denominator && typeof denominator === "object" ? denominator : null
684
+ }
685
+
686
+ function renderTable(root, config, base) {
687
+ base = base || ""
688
+ var card = tile(root, config.title, base)
689
+ renderControls(card, config.controls, base)
690
+ if (config.caption)
691
+ card.appendChild(part(el("p", "fmr-tile-note", config.caption), base + "caption"))
692
+ var mount = part(el("div"), base + "table")
693
+ card.appendChild(mount)
694
+
695
+ var sortKey = config.sort ? config.sort.key : null
696
+ var sortDir = config.sort ? config.sort.dir : "desc"
697
+ var shareTotals = {}
698
+
699
+ var columns = []
700
+ ;(config.columns || []).forEach(function (column, index) {
701
+ var format = column.format
702
+ columns.push({
703
+ part: base + "columns." + index,
704
+ key: column.key,
705
+ label: column.label || column.key,
706
+ align: column.align || (format && format !== "text" && format !== "datetime" && format !== "image" ? "right" : "left"),
707
+ dim: column.dim,
708
+ image: column.image,
709
+ format: format === "image" ? "image" : function (value) { return formatValue(format, value) }
710
+ })
711
+ if (column.share) {
712
+ columns.push({
713
+ part: base + "columns." + index + ".share",
714
+ key: column.key,
715
+ label: "Share",
716
+ align: "right",
717
+ dim: true,
718
+ format: function (value, row) {
719
+ var total = shareTotals[column.key]
720
+ var denominator = shareDenominator(column)
721
+ if (denominator && denominator.key) total = total ? total[String(row[denominator.key])] : 0
722
+ return total ? intl(1).format((num(value) / total) * 100) + "%" : "—"
723
+ }
724
+ })
725
+ }
726
+ })
727
+
728
+ var control = FM.ui.table(mount, {
729
+ columns: columns,
730
+ rows: [],
731
+ pageSize: config.pageSize === undefined ? 20 : config.pageSize,
732
+ empty: config.empty || "No rows in this period.",
733
+ onRowClick: config.rowClickSet
734
+ ? function (row) { FM.setParams(resolveSet(config.rowClickSet, row, null)) }
735
+ : undefined
736
+ })
737
+
738
+ function ordered() {
739
+ var rows = FM.rows(config.query).slice()
740
+ ;(config.columns || []).forEach(function (column) {
741
+ if (!column.share) return
742
+ var denominator = shareDenominator(column)
743
+ if (denominator && denominator.key) {
744
+ var byKey = {}
745
+ FM.rows(denominator.query).forEach(function (row) {
746
+ byKey[String(row[denominator.key])] = num(row[denominator.value])
747
+ })
748
+ shareTotals[column.key] = byKey
749
+ } else if (denominator) {
750
+ var denominatorRows = FM.rows(denominator.query)
751
+ shareTotals[column.key] = denominatorRows.length
752
+ ? num(denominatorRows[0][denominator.value])
753
+ : 0
754
+ } else {
755
+ shareTotals[column.key] = rows.reduce(function (sum, row) { return sum + num(row[column.key]) }, 0)
756
+ }
757
+ })
758
+ if (!sortKey) return rows
759
+ rows.sort(function (a, b) {
760
+ var left = a[sortKey]
761
+ var right = b[sortKey]
762
+ var ln = Number(left)
763
+ var rn = Number(right)
764
+ var compared =
765
+ isFinite(ln) && isFinite(rn) ? ln - rn : String(left ?? "").localeCompare(String(right ?? ""))
766
+ return sortDir === "asc" ? compared : -compared
767
+ })
768
+ return rows
769
+ }
770
+
771
+ // header click cycles the sort; thead persists across setRows, so bind once
772
+ var headers = control.element.querySelectorAll("th")
773
+ Array.prototype.forEach.call(headers, function (th, index) {
774
+ var column = columns[index]
775
+ if (!column) return
776
+ th.style.cursor = "pointer"
777
+ th.addEventListener("click", function () {
778
+ if (sortKey === column.key) sortDir = sortDir === "desc" ? "asc" : "desc"
779
+ else {
780
+ sortKey = column.key
781
+ sortDir = "desc"
782
+ }
783
+ Array.prototype.forEach.call(headers, function (other) { other.removeAttribute("aria-sort") })
784
+ th.setAttribute("aria-sort", sortDir === "asc" ? "ascending" : "descending")
785
+ control.setRows(ordered())
786
+ })
787
+ })
788
+
789
+ var subscribed = {}
790
+ function subscribe(queryId) {
791
+ if (!queryId || subscribed[queryId]) return
792
+ subscribed[queryId] = true
793
+ FM.onRows(queryId, function () { control.setRows(ordered()) })
794
+ }
795
+ subscribe(config.query)
796
+ ;(config.columns || []).forEach(function (column) {
797
+ var denominator = shareDenominator(column)
798
+ if (denominator) subscribe(denominator.query)
799
+ })
800
+ }
801
+
802
+ // --- detail-panel: a dialog over the report while its param is set; closing writes null back
803
+
804
+ function renderDetailPanel(root, config, base) {
805
+ base = base || ""
806
+ var host = el("div")
807
+ root.appendChild(host)
808
+ var panel = FM.ui.dialog(host, {
809
+ title: config.title || "",
810
+ subtitle: config.title || "",
811
+ onClose: function () {
812
+ // an external clear already closed it; writing null again would be a second params push
813
+ if (!FM.params()[config.showWhen]) return
814
+ var patch = {}
815
+ patch[config.showWhen] = null
816
+ FM.setParams(patch)
817
+ }
818
+ })
819
+ part(panel.element.querySelector(".fm-dlg-title"), base + "title")
820
+ var body = el("div")
821
+ panel.body.appendChild(body)
822
+
823
+ var renderers = { timeseries: renderTimeseries, table: renderTable, bar: renderBar }
824
+ var mounted = false
825
+
826
+ // titleLookup swaps a raw drill id for the matching row's text column
827
+ function titleValue(value) {
828
+ var lookup = config.titleLookup
829
+ if (lookup) {
830
+ var rows = FM.rows(lookup.query)
831
+ for (var i = 0; i < rows.length; i++) {
832
+ if (String(rows[i][lookup.value]) === String(value)) {
833
+ var text = rows[i][lookup.text]
834
+ if (text !== null && text !== undefined && text !== "") return String(text)
835
+ }
836
+ }
837
+ }
838
+ return String(value)
839
+ }
840
+
841
+ function sync() {
842
+ var value = FM.params()[config.showWhen]
843
+ var open = value !== null && value !== undefined && value !== ""
844
+ if (!open) {
845
+ panel.close()
846
+ return
847
+ }
848
+ // the entity the viewer clicked is the headline; the panel's own title says what it shows
849
+ panel.setTitle(titleValue(value))
850
+ panel.setSubtitle(config.title || "")
851
+ if (!mounted) {
852
+ mounted = true
853
+ renderers[config.body.component](body, config.body.config, base + "body.config.")
854
+ }
855
+ panel.open()
856
+ }
857
+
858
+ // the body's own subscription re-fires on every params push, which drives visibility;
859
+ // mounting the body up front would subscribe outside the frame's mount attribution
860
+ var probe = config.body && config.body.config ? config.body.config.query : null
861
+ if (probe) {
862
+ mounted = true
863
+ renderers[config.body.component](body, config.body.config, base + "body.config.")
864
+ FM.onRows(probe, sync)
865
+ }
866
+ if (config.titleLookup) FM.onRows(config.titleLookup.query, sync)
867
+ sync()
868
+ }
869
+
870
+ // --- heatmap: day-of-week × hour grid on one hue; grey where no row; its own legend
871
+ var HEATMAP_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
872
+ var HEAT_BANDS = [0.14, 0.32, 0.54, 0.78, 1]
873
+ var NO_DATA = "var(--fm-border-default, #f0f0f0)"
874
+ function rgba(hex, alpha) {
875
+ var h = hex.replace("#", "")
876
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
877
+ var n = parseInt(h, 16)
878
+ if (!isFinite(n)) return hex
879
+ return "rgba(" + ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255) + ", " + alpha + ")"
880
+ }
881
+ function heatBand(t) {
882
+ var clamped = Math.max(0, Math.min(1, t))
883
+ return HEAT_BANDS[Math.min(HEAT_BANDS.length - 1, Math.floor(clamped * HEAT_BANDS.length))]
884
+ }
885
+ function heatShade(t) {
886
+ return rgba(token("--fm-action-primary-bg", "#ff7823"), heatBand(t))
887
+ }
888
+ function heatLabel(text, align) {
889
+ var node = el("div", "fmr-heatmap-label", text)
890
+ node.style.fontSize = "12px"
891
+ node.style.color = "var(--fm-text-muted, #6b6c70)"
892
+ node.style.textAlign = align
893
+ node.style.padding = "0 4px"
894
+ return node
895
+ }
896
+ function heatSwatch(background) {
897
+ var node = el("span", "fmr-heatmap-swatch")
898
+ node.style.display = "inline-block"
899
+ node.style.width = "14px"
900
+ node.style.height = "14px"
901
+ node.style.borderRadius = "var(--fm-radius-xs, 4px)"
902
+ node.style.background = background
903
+ return node
904
+ }
905
+ function heatLegend() {
906
+ var row = el("div", "fmr-heatmap-legend")
907
+ row.style.display = "flex"
908
+ row.style.alignItems = "center"
909
+ row.style.gap = "6px"
910
+ row.style.marginTop = "10px"
911
+ row.style.fontSize = "12px"
912
+ row.style.color = "var(--fm-text-muted, #6b6c70)"
913
+ row.appendChild(el("span", null, "Quieter"))
914
+ HEAT_BANDS.forEach(function (alpha) { row.appendChild(heatSwatch(rgba(token("--fm-action-primary-bg", "#ff7823"), alpha))) })
915
+ row.appendChild(el("span", null, "Busier"))
916
+ var gap = el("span")
917
+ gap.style.width = "12px"
918
+ row.appendChild(gap)
919
+ row.appendChild(heatSwatch(NO_DATA))
920
+ row.appendChild(el("span", null, "No data"))
921
+ return row
922
+ }
923
+ function renderHeatmap(root, config, base) {
924
+ base = base || ""
925
+ var card = tile(root, config.title, base)
926
+ renderControls(card, config.controls, base)
927
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
928
+ card.appendChild(body)
929
+ var days = config.weekStart === "sunday" ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6, 0]
930
+ var format = config.format || "amount"
931
+ function refresh() {
932
+ body.innerHTML = ""
933
+ var rows = FM.rows(config.query)
934
+ if (!rows.length && FM.status(config.query) !== "loading") {
935
+ body.appendChild(emptyNote(config))
936
+ return
937
+ }
938
+ var cells = {}
939
+ var totals = {}
940
+ var max = 0
941
+ rows.forEach(function (row) {
942
+ var d = ((num(row[config.y]) % 7) + 7) % 7
943
+ var key = d + ":" + num(row[config.x])
944
+ cells[key] = (cells[key] || 0) + num(row[config.value])
945
+ totals[d] = (totals[d] || 0) + num(row[config.value])
946
+ if (cells[key] > max) max = cells[key]
947
+ })
948
+ var grid = el("div", "fmr-heatmap")
949
+ grid.style.display = "grid"
950
+ grid.style.gridTemplateColumns = "44px repeat(24, minmax(0, 1fr))" + (config.rowTotals ? " 88px" : "")
951
+ grid.style.gap = "2px"
952
+ grid.style.alignItems = "center"
953
+ grid.appendChild(el("div"))
954
+ for (var h = 0; h < 24; h++) grid.appendChild(heatLabel(String(h), "center"))
955
+ if (config.rowTotals) grid.appendChild(heatLabel("Total", "right"))
956
+ days.forEach(function (d) {
957
+ grid.appendChild(heatLabel(HEATMAP_DAYS[d], "left"))
958
+ for (var hh = 0; hh < 24; hh++) {
959
+ var value = cells[d + ":" + hh]
960
+ var cell = el("div", "fmr-heatmap-cell")
961
+ cell.style.minHeight = "26px"
962
+ cell.style.borderRadius = "var(--fm-radius-xs, 4px)"
963
+ if (value === undefined) {
964
+ cell.className += " fmr-heatmap-cell-empty"
965
+ cell.style.background = NO_DATA
966
+ cell.title = HEATMAP_DAYS[d] + " " + hh + ":00 — no data"
967
+ } else {
968
+ cell.style.background = heatShade(max ? value / max : 0)
969
+ cell.title = HEATMAP_DAYS[d] + " " + hh + ":00 — " + formatValue(format, value)
970
+ }
971
+ grid.appendChild(cell)
972
+ }
973
+ if (config.rowTotals) grid.appendChild(heatLabel(formatValue(format, totals[d] || 0), "right"))
974
+ })
975
+ body.appendChild(grid)
976
+ body.appendChild(heatLegend())
977
+ }
978
+ FM.onRows(config.query, refresh)
979
+ refresh()
980
+ }
981
+
982
+ /**
983
+ * The head of a page the viewer navigated into. It names the entity the page is about and
984
+ * carries the one click back — the page's own panels are the sections sharing its showWhen.
985
+ */
986
+ function renderPageHeader(root, config, base) {
987
+ base = base || ""
988
+ var head = el("div", "fmr-page-head")
989
+ var back = part(el("button", "fmr-back", config.backLabel || "Back"), base + "back")
990
+ back.type = "button"
991
+ back.addEventListener("click", function () { FM.setParams(config.back) })
992
+ var title = part(el("h2", "fmr-tile-title", config.title || ""), base + "title")
993
+ head.appendChild(back)
994
+ head.appendChild(title)
995
+ root.appendChild(head)
996
+
997
+ // titleLookup swaps a raw drill id for the matching row's text column
998
+ function titleValue(value) {
999
+ var lookup = config.titleLookup
1000
+ if (lookup) {
1001
+ var rows = FM.rows(lookup.query)
1002
+ for (var i = 0; i < rows.length; i++) {
1003
+ if (String(rows[i][lookup.value]) === String(value)) {
1004
+ var text = rows[i][lookup.text]
1005
+ if (text !== null && text !== undefined && text !== "") return String(text)
1006
+ }
1007
+ }
1008
+ }
1009
+ return String(value)
1010
+ }
1011
+
1012
+ function sync() {
1013
+ var value = FM.params()[config.showWhen]
1014
+ if (value === null || value === undefined || value === "") return
1015
+ var shown = titleValue(value)
1016
+ title.textContent = config.title ? config.title + " — " + shown : shown
1017
+ }
1018
+
1019
+ FM.onParams([config.showWhen], sync)
1020
+ if (config.titleLookup) FM.onRows(config.titleLookup.query, sync)
1021
+ sync()
1022
+ }
1023
+
1024
+ var RENDERERS = {
1025
+ "filter-bar": renderFilterBar,
1026
+ "kpi-row": renderKpiRow,
1027
+ timeseries: renderTimeseries,
1028
+ bar: renderBar,
1029
+ table: renderTable,
1030
+ heatmap: renderHeatmap,
1031
+ "detail-panel": renderDetailPanel,
1032
+ "page-header": renderPageHeader
1033
+ }
1034
+
1035
+ FM.components = {
1036
+ render: function (el, section) {
1037
+ var renderer = RENDERERS[section.component]
1038
+ if (!renderer) throw new Error('unknown component "' + section.component + '"')
1039
+ renderer(el, section.config || {}, "")
1040
+ }
1041
+ }
1042
+ })()