@bluemarble/bi 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1325 @@
1
+ import { a as useDashboard, c as layoutItemKey, i as DashboardProvider, n as INSPECT_ATTRIBUTE, r as useModelQuery, s as LAYOUT_ITEM_ATTRIBUTE, t as ModelDictionary } from "../ModelDictionary-AjNbffwG.js";
2
+ import { Box, Checkbox, CircularProgress, FormControl, InputLabel, ListItemText, MenuItem, OutlinedInput, Paper, Select, Table, TableBody, TableCell, TableHead, TableRow, TableSortLabel, Typography } from "@mui/material";
3
+ import { Bar, BarChart as BarChart$1, CartesianGrid, Cell, ComposedChart, LabelList, Line, Pie, PieChart, ResponsiveContainer, Tooltip as Tooltip$1, XAxis, YAxis } from "recharts";
4
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/client/chartKit.tsx
7
+ /**
8
+ * Peças comuns dos gráficos ligados ao modelo: paleta, filtro por clique, legenda e os estados de
9
+ * carregando/erro/vazio.
10
+ */
11
+ /**
12
+ * Paleta categórica, sempre nesta ordem (a série N tem sempre a mesma cor). Laranja Rocha primeiro;
13
+ * a ordem passa no validador de daltonismo (CVD ΔE ≥ 9 entre vizinhas) e de visão normal (≥ 19).
14
+ */
15
+ const SERIES_COLORS = [
16
+ "#F58220",
17
+ "#2a78d6",
18
+ "#1baf7a",
19
+ "#eda100",
20
+ "#e87ba4",
21
+ "#008300",
22
+ "#6250d6",
23
+ "#e34948"
24
+ ];
25
+ /** "Outros" (fatias agrupadas) fica neutro, fora da paleta. */
26
+ const OTHER_COLOR = "#9e9e9e";
27
+ const seriesColor = (index) => SERIES_COLORS[index % SERIES_COLORS.length];
28
+ /** Valor numérico de uma coluna da linha (`null` quando vazio). */
29
+ const numberOf = (row, key) => key && row[key] !== null && row[key] !== void 0 ? Number(row[key]) : null;
30
+ /**
31
+ * Filtro cruzado a partir do gráfico: clicar num valor filtra o dashboard por ele (Ctrl/Cmd+clique
32
+ * acumula; clicar de novo no único selecionado limpa). O gráfico deve ignorar o próprio filtro na
33
+ * consulta (`ignoreFilters`) e só destacar a seleção.
34
+ */
35
+ function useClickFilter(dimension, enabled) {
36
+ const { filters, setFilter } = useDashboard();
37
+ const selected = enabled && dimension ? filters[dimension] ?? [] : [];
38
+ const toggle = (value, event) => {
39
+ if (!enabled || !dimension) return;
40
+ if (event?.ctrlKey || event?.metaKey) setFilter(dimension, selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]);
41
+ else setFilter(dimension, selected.length === 1 && selected[0] === value ? [] : [value]);
42
+ };
43
+ return {
44
+ selected,
45
+ /** Há seleção e este valor não está nela. */
46
+ isDimmed: (value) => selected.length > 0 && !selected.includes(value),
47
+ toggle,
48
+ clear: () => {
49
+ if (enabled && dimension && selected.length) setFilter(dimension, []);
50
+ }
51
+ };
52
+ }
53
+ function Legend({ items, loading }) {
54
+ return /* @__PURE__ */ jsxs(Box, {
55
+ sx: {
56
+ display: "flex",
57
+ flexWrap: "wrap",
58
+ columnGap: 1.5,
59
+ rowGap: .5,
60
+ mb: 1,
61
+ fontSize: 14,
62
+ color: "#555",
63
+ minHeight: 21
64
+ },
65
+ children: [items.map((item) => /* @__PURE__ */ jsxs(Box, {
66
+ sx: {
67
+ display: "flex",
68
+ alignItems: "center",
69
+ gap: .5
70
+ },
71
+ children: [/* @__PURE__ */ jsx(Box, { sx: {
72
+ width: 9,
73
+ height: 9,
74
+ borderRadius: "50%",
75
+ bgcolor: item.color
76
+ } }), item.label]
77
+ }, item.label)), loading && /* @__PURE__ */ jsx(CircularProgress, {
78
+ size: 14,
79
+ sx: { ml: "auto" }
80
+ })]
81
+ });
82
+ }
83
+ /** Erro / carregando / sem dados no lugar do gráfico; `null` quando há o que desenhar (função, não componente). */
84
+ function chartPlaceholder({ height, error, isLoading, empty }) {
85
+ if (!error && !empty) return null;
86
+ return /* @__PURE__ */ jsx(Box, {
87
+ sx: {
88
+ height,
89
+ display: "flex",
90
+ alignItems: "center",
91
+ justifyContent: "center",
92
+ color: error ? "error.main" : "#999"
93
+ },
94
+ children: error ? "Erro ao carregar o gráfico" : isLoading ? "Carregando…" : "Sem dados para os filtros selecionados"
95
+ });
96
+ }
97
+ function TooltipBox({ title, children }) {
98
+ return /* @__PURE__ */ jsxs(Box, {
99
+ sx: {
100
+ bgcolor: "#fff",
101
+ border: "1px solid #ccc",
102
+ p: 1,
103
+ fontSize: 13
104
+ },
105
+ children: [/* @__PURE__ */ jsx(Box, {
106
+ sx: { fontWeight: 700 },
107
+ children: title
108
+ }), children]
109
+ });
110
+ }
111
+ //#endregion
112
+ //#region src/client/format.ts
113
+ const formatters = /* @__PURE__ */ new Map();
114
+ function formatNumber(value, decimals = 2) {
115
+ if (value === null || value === void 0 || value === "") return "";
116
+ const number = Number(value);
117
+ if (Number.isNaN(number)) return String(value);
118
+ let formatter = formatters.get(decimals);
119
+ if (!formatter) {
120
+ formatter = new Intl.NumberFormat("pt-BR", {
121
+ minimumFractionDigits: decimals,
122
+ maximumFractionDigits: decimals
123
+ });
124
+ formatters.set(decimals, formatter);
125
+ }
126
+ return formatter.format(number);
127
+ }
128
+ const formatCurrency = (value, decimals = 2) => {
129
+ const text = formatNumber(value, decimals);
130
+ return text && `R$ ${text}`;
131
+ };
132
+ /**
133
+ * Formata pelo dicionário do modelo (`format`/`decimals` da medida). `fallbackDecimals` é o padrão
134
+ * do componente quando a medida não define `decimals`.
135
+ */
136
+ function formatMeasure(value, meta, fallbackDecimals = 2) {
137
+ const decimals = meta?.decimals ?? (meta?.format === "integer" ? 0 : fallbackDecimals);
138
+ switch (meta?.format) {
139
+ case "currency": return formatCurrency(value, decimals);
140
+ case "percent": {
141
+ if (value === null || value === void 0 || value === "") return "";
142
+ const text = formatNumber(Number(value) * 100, decimals);
143
+ return text && `${text}%`;
144
+ }
145
+ default: return formatNumber(value, decimals);
146
+ }
147
+ }
148
+ /** Formatter de uma medida pelo dicionário do modelo; sem `format`, número com `fallbackDecimals`. */
149
+ function measureFormatter(meta, measure, fallbackDecimals = 2) {
150
+ const measureMeta = meta?.measures[measure];
151
+ return (value) => formatMeasure(value, measureMeta, fallbackDecimals);
152
+ }
153
+ //#endregion
154
+ //#region src/client/BarChart.tsx
155
+ const MAX_VALUE_LABELS = 30;
156
+ /** Altura de cada categoria no modo horizontal (barras finas, com respiro). */
157
+ const ROW_HEIGHT = 28;
158
+ /**
159
+ * Barras de uma ou mais medidas por uma dimensão. Ex.:
160
+ * `<BarChart dimension="navio" measures={['custoTotal']} orientation="horizontal" sort="desc" limit={10} />`.
161
+ */
162
+ function BarChart({ dimension, measures, orientation = "vertical", stacked = false, sort = "dimension", limit, formatDimension = String, hideEmpty = true, showValues = true, clickToFilter = false, height = 360, queryLabel }) {
163
+ const { meta } = useDashboard();
164
+ const { rows, isLoading, error, inspectProps } = useModelQuery({
165
+ dimensions: [dimension],
166
+ measures,
167
+ ignoreFilters: clickToFilter ? [dimension] : void 0,
168
+ label: queryLabel ?? `BarChart · ${dimension}`
169
+ });
170
+ const filter = useClickFilter(dimension, clickToFilter);
171
+ const formats = measures.map((measure) => measureFormatter(meta, measure, 0));
172
+ const labelOf = (measure) => meta?.measures[measure]?.label ?? measure;
173
+ const first = measures[0];
174
+ let data = rows.filter((row) => !hideEmpty || measures.some((measure) => numberOf(row, measure))).map((row) => {
175
+ const value = row[dimension] ?? "";
176
+ const point = {
177
+ value,
178
+ label: formatDimension(value)
179
+ };
180
+ for (const measure of measures) point[measure] = numberOf(row, measure) ?? 0;
181
+ return point;
182
+ });
183
+ if (sort !== "dimension" && first) data = [...data].sort((a, b) => sort === "desc" ? Number(b[first]) - Number(a[first]) : Number(a[first]) - Number(b[first]));
184
+ if (limit) data = data.slice(0, limit);
185
+ const horizontal = orientation === "horizontal";
186
+ const labels = showValues && data.length <= MAX_VALUE_LABELS;
187
+ const chartHeight = horizontal ? Math.max(height, data.length * ROW_HEIGHT + 24) : height;
188
+ const longest = Math.max(0, ...data.map((point) => point.label.length));
189
+ const state = chartPlaceholder({
190
+ height,
191
+ error,
192
+ isLoading,
193
+ empty: !data.length
194
+ });
195
+ const ChartTooltip = ({ active, payload }) => {
196
+ if (!active || !payload?.length) return null;
197
+ const point = payload[0].payload;
198
+ return /* @__PURE__ */ jsx(TooltipBox, {
199
+ title: point.label,
200
+ children: measures.map((measure, index) => /* @__PURE__ */ jsxs(Box, {
201
+ sx: {
202
+ display: "flex",
203
+ alignItems: "center",
204
+ gap: .75
205
+ },
206
+ children: [
207
+ /* @__PURE__ */ jsx(Box, { sx: {
208
+ width: 8,
209
+ height: 8,
210
+ borderRadius: "50%",
211
+ bgcolor: seriesColor(index)
212
+ } }),
213
+ labelOf(measure),
214
+ ": ",
215
+ formats[index]?.(point[measure])
216
+ ]
217
+ }, measure))
218
+ });
219
+ };
220
+ const handleClick = (index, event) => {
221
+ event.stopPropagation();
222
+ const point = data[index];
223
+ if (point) filter.toggle(point.value, event);
224
+ };
225
+ const radius = (index) => {
226
+ if (stacked && index < measures.length - 1) return [
227
+ 0,
228
+ 0,
229
+ 0,
230
+ 0
231
+ ];
232
+ return horizontal ? [
233
+ 0,
234
+ 4,
235
+ 4,
236
+ 0
237
+ ] : [
238
+ 4,
239
+ 4,
240
+ 0,
241
+ 0
242
+ ];
243
+ };
244
+ return /* @__PURE__ */ jsxs(Box, {
245
+ ...inspectProps,
246
+ sx: { minWidth: 0 },
247
+ children: [/* @__PURE__ */ jsx(Legend, {
248
+ items: measures.length > 1 ? measures.map((measure, index) => ({
249
+ label: labelOf(measure),
250
+ color: seriesColor(index)
251
+ })) : [],
252
+ loading: isLoading
253
+ }), state ?? /* @__PURE__ */ jsx(Box, {
254
+ onClick: filter.clear,
255
+ sx: {
256
+ height,
257
+ overflowY: horizontal ? "auto" : "hidden",
258
+ opacity: isLoading ? .5 : 1,
259
+ transition: "opacity .2s"
260
+ },
261
+ children: /* @__PURE__ */ jsx(Box, {
262
+ sx: { height: chartHeight },
263
+ children: /* @__PURE__ */ jsx(ResponsiveContainer, {
264
+ width: "100%",
265
+ height: "100%",
266
+ children: /* @__PURE__ */ jsxs(BarChart$1, {
267
+ data,
268
+ layout: horizontal ? "vertical" : "horizontal",
269
+ barGap: 2,
270
+ barCategoryGap: "20%",
271
+ margin: horizontal ? {
272
+ top: 4,
273
+ right: labels ? 96 : 16,
274
+ left: 8,
275
+ bottom: 4
276
+ } : {
277
+ top: labels ? 24 : 8,
278
+ right: 8,
279
+ left: 8,
280
+ bottom: 0
281
+ },
282
+ children: [
283
+ /* @__PURE__ */ jsx(CartesianGrid, {
284
+ stroke: "#eee",
285
+ vertical: horizontal,
286
+ horizontal: !horizontal
287
+ }),
288
+ horizontal ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(XAxis, {
289
+ type: "number",
290
+ hide: labels,
291
+ tick: {
292
+ fontSize: 12,
293
+ fill: "#555"
294
+ }
295
+ }), /* @__PURE__ */ jsx(YAxis, {
296
+ type: "category",
297
+ dataKey: "label",
298
+ interval: 0,
299
+ width: Math.min(220, longest * 7 + 12),
300
+ tick: {
301
+ fontSize: 12,
302
+ fill: "#555"
303
+ },
304
+ tickLine: false,
305
+ axisLine: false
306
+ })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(XAxis, {
307
+ dataKey: "label",
308
+ interval: data.length > MAX_VALUE_LABELS ? "preserveStartEnd" : 0,
309
+ tick: {
310
+ fontSize: 12,
311
+ fill: "#555"
312
+ },
313
+ tickLine: false,
314
+ axisLine: { stroke: "#ccc" }
315
+ }), /* @__PURE__ */ jsx(YAxis, {
316
+ hide: labels,
317
+ tick: {
318
+ fontSize: 12,
319
+ fill: "#555"
320
+ },
321
+ tickFormatter: (value) => formatNumber(value, 0),
322
+ width: 72
323
+ })] }),
324
+ /* @__PURE__ */ jsx(Tooltip$1, {
325
+ content: /* @__PURE__ */ jsx(ChartTooltip, {}),
326
+ cursor: { fill: "rgba(0,0,0,.04)" }
327
+ }),
328
+ measures.map((measure, index) => /* @__PURE__ */ jsxs(Bar, {
329
+ dataKey: measure,
330
+ name: labelOf(measure),
331
+ fill: seriesColor(index),
332
+ stackId: stacked ? "stack" : void 0,
333
+ radius: radius(index),
334
+ maxBarSize: horizontal ? 18 : 56,
335
+ isAnimationActive: false,
336
+ onClick: clickToFilter ? (_bar, barIndex, event) => handleClick(barIndex, event) : void 0,
337
+ cursor: clickToFilter ? "pointer" : void 0,
338
+ children: [clickToFilter && data.map((point) => /* @__PURE__ */ jsx(Cell, { fillOpacity: filter.isDimmed(point.value) ? .35 : 1 }, String(point.value))), labels && !stacked && /* @__PURE__ */ jsx(LabelList, {
339
+ dataKey: measure,
340
+ position: horizontal ? "right" : "top",
341
+ formatter: (value) => formats[index]?.(value) ?? "",
342
+ style: {
343
+ fontSize: 12,
344
+ fill: "#333",
345
+ fontWeight: 600
346
+ }
347
+ })]
348
+ }, measure))
349
+ ]
350
+ })
351
+ })
352
+ })
353
+ })]
354
+ });
355
+ }
356
+ //#endregion
357
+ //#region src/client/ComboChart.tsx
358
+ /** Mantém a linha na faixa de baixo do gráfico, longe dos rótulos que ficam em cima das barras. */
359
+ const lineDomain = ([min, max]) => {
360
+ const range = max - min || Math.abs(max) || 1;
361
+ return [min - range * .3, max + range * 1.7];
362
+ };
363
+ /**
364
+ * Barras + linha de uma dimensão, consultando o modelo do `DashboardProvider` (com os filtros dele).
365
+ * Ex.: `<ComboChart dimension="mes" bar="custoTotal" line="custoTon" detail="volTotal" />`.
366
+ */
367
+ function ComboChart({ dimension, bar, line, detail, barLabel, lineLabel, detailLabel, formatDimension = String, formatBar, formatLine, formatDetail, hideEmpty = true, height = 360, maxLabels = 24, barColor = "#F58220", lineColor = "#555", clickToFilter = false, queryLabel }) {
368
+ const { meta, filters, setFilter } = useDashboard();
369
+ const fromModel = (measure, decimals, fallback) => {
370
+ const measureMeta = meta?.measures[measure];
371
+ return measureMeta?.format ? (value) => formatMeasure(value, measureMeta, decimals) : fallback;
372
+ };
373
+ formatBar ??= fromModel(bar, 0, (value) => formatCurrency(value, 0));
374
+ formatLine ??= fromModel(line, 2, (value) => formatNumber(value, 2));
375
+ formatDetail ??= detail ? fromModel(detail, 3, (value) => formatNumber(value, 3)) : (value) => formatNumber(value, 3);
376
+ barLabel ??= meta?.measures[bar]?.label ?? bar;
377
+ lineLabel ??= meta?.measures[line]?.label ?? line;
378
+ detailLabel ??= detail && (meta?.measures[detail]?.label ?? detail);
379
+ const { rows, isLoading, error, inspectProps } = useModelQuery({
380
+ dimensions: [dimension],
381
+ measures: detail ? [
382
+ bar,
383
+ line,
384
+ detail
385
+ ] : [bar, line],
386
+ ignoreFilters: clickToFilter ? [dimension] : void 0,
387
+ label: queryLabel ?? `ComboChart · ${dimension}`
388
+ });
389
+ const num = (row, key) => key && row[key] !== null && row[key] !== void 0 ? Number(row[key]) : null;
390
+ const data = rows.filter((row) => !hideEmpty || num(row, bar)).map((row) => ({
391
+ value: row[dimension] ?? "",
392
+ label: formatDimension(row[dimension] ?? ""),
393
+ bar: num(row, bar) ?? 0,
394
+ line: num(row, line),
395
+ detail: num(row, detail)
396
+ }));
397
+ const showLabels = data.length <= maxLabels;
398
+ const selected = clickToFilter ? filters[dimension] ?? [] : [];
399
+ const isDimmed = (point) => selected.length > 0 && !selected.includes(point.value);
400
+ const handleBarClick = (index, event) => {
401
+ event.stopPropagation();
402
+ const point = data[index];
403
+ if (!point) return;
404
+ if (event.ctrlKey || event.metaKey) setFilter(dimension, selected.includes(point.value) ? selected.filter((value) => value !== point.value) : [...selected, point.value]);
405
+ else {
406
+ const onlyThis = selected.length === 1 && selected[0] === point.value;
407
+ setFilter(dimension, onlyThis ? [] : [point.value]);
408
+ }
409
+ };
410
+ const clearFilter = () => {
411
+ if (selected.length) setFilter(dimension, []);
412
+ };
413
+ const BarValueLabel = (props) => {
414
+ const point = data[props.index];
415
+ if (!point?.bar) return null;
416
+ const cx = Number(props.x) + Number(props.width) / 2;
417
+ const lines = [formatBar(point.bar), point.detail === null ? null : formatDetail(point.detail)].filter((text) => !!text);
418
+ const top = Number(props.y) - 6 - lines.length * 15;
419
+ const width = Math.max(...lines.map((text) => text.length)) * 6.8 + 10;
420
+ return /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
421
+ x: cx - width / 2,
422
+ y: top,
423
+ width,
424
+ height: lines.length * 15 + 4,
425
+ rx: 2,
426
+ fill: "#555"
427
+ }), lines.map((text, i) => /* @__PURE__ */ jsx("text", {
428
+ x: cx,
429
+ y: top + 14 + i * 15,
430
+ textAnchor: "middle",
431
+ fill: "#fff",
432
+ fontSize: 12,
433
+ fontWeight: 700,
434
+ children: text
435
+ }, text))] });
436
+ };
437
+ const LineValueLabel = (props) => {
438
+ if (props.value === null || props.value === void 0) return null;
439
+ const text = formatLine(Number(props.value));
440
+ const width = text.length * 7 + 10;
441
+ return /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
442
+ x: props.x - width / 2,
443
+ y: props.y - 9,
444
+ width,
445
+ height: 18,
446
+ rx: 2,
447
+ fill: "#d9d9d9"
448
+ }), /* @__PURE__ */ jsx("text", {
449
+ x: props.x,
450
+ y: props.y + 4,
451
+ textAnchor: "middle",
452
+ fill: "#222",
453
+ fontSize: 12,
454
+ fontWeight: 700,
455
+ children: text
456
+ })] });
457
+ };
458
+ const ChartTooltip = ({ active, payload }) => {
459
+ if (!active || !payload?.length) return null;
460
+ const point = payload[0].payload;
461
+ return /* @__PURE__ */ jsxs(Box, {
462
+ sx: {
463
+ bgcolor: "#fff",
464
+ border: "1px solid #ccc",
465
+ p: 1,
466
+ fontSize: 13
467
+ },
468
+ children: [
469
+ /* @__PURE__ */ jsx(Typography, {
470
+ sx: {
471
+ fontWeight: 700,
472
+ fontSize: 13
473
+ },
474
+ children: point.label
475
+ }),
476
+ /* @__PURE__ */ jsxs("div", { children: [
477
+ barLabel,
478
+ ": ",
479
+ formatBar(point.bar)
480
+ ] }),
481
+ point.detail !== null && /* @__PURE__ */ jsxs("div", { children: [
482
+ detailLabel,
483
+ ": ",
484
+ formatDetail(point.detail)
485
+ ] }),
486
+ /* @__PURE__ */ jsxs("div", { children: [
487
+ lineLabel,
488
+ ": ",
489
+ point.line === null ? "" : formatLine(point.line)
490
+ ] })
491
+ ]
492
+ });
493
+ };
494
+ const legend = [{
495
+ label: barLabel,
496
+ color: barColor
497
+ }, {
498
+ label: lineLabel,
499
+ color: lineColor
500
+ }];
501
+ return /* @__PURE__ */ jsxs(Box, {
502
+ ...inspectProps,
503
+ sx: {
504
+ minWidth: 0,
505
+ position: "relative"
506
+ },
507
+ children: [/* @__PURE__ */ jsxs(Box, {
508
+ sx: {
509
+ display: "flex",
510
+ gap: 1.5,
511
+ mb: 1,
512
+ fontSize: 14,
513
+ color: "#555"
514
+ },
515
+ children: [legend.map((item) => /* @__PURE__ */ jsxs(Box, {
516
+ sx: {
517
+ display: "flex",
518
+ alignItems: "center",
519
+ gap: .5
520
+ },
521
+ children: [/* @__PURE__ */ jsx(Box, { sx: {
522
+ width: 9,
523
+ height: 9,
524
+ borderRadius: "50%",
525
+ bgcolor: item.color
526
+ } }), item.label]
527
+ }, item.label)), isLoading && /* @__PURE__ */ jsx(CircularProgress, {
528
+ size: 14,
529
+ sx: { ml: "auto" }
530
+ })]
531
+ }), error ? /* @__PURE__ */ jsx(Box, {
532
+ sx: {
533
+ height,
534
+ display: "flex",
535
+ alignItems: "center",
536
+ justifyContent: "center",
537
+ color: "error.main"
538
+ },
539
+ children: "Erro ao carregar o gráfico"
540
+ }) : !data.length ? /* @__PURE__ */ jsx(Box, {
541
+ sx: {
542
+ height,
543
+ display: "flex",
544
+ alignItems: "center",
545
+ justifyContent: "center",
546
+ color: "#999"
547
+ },
548
+ children: isLoading ? "Carregando…" : "Sem dados para os filtros selecionados"
549
+ }) : /* @__PURE__ */ jsx(Box, {
550
+ onClick: clickToFilter ? clearFilter : void 0,
551
+ sx: {
552
+ height,
553
+ opacity: isLoading ? .5 : 1,
554
+ transition: "opacity .2s"
555
+ },
556
+ children: /* @__PURE__ */ jsx(ResponsiveContainer, {
557
+ width: "100%",
558
+ height: "100%",
559
+ children: /* @__PURE__ */ jsxs(ComposedChart, {
560
+ data,
561
+ margin: {
562
+ top: showLabels ? 48 : 12,
563
+ right: 8,
564
+ left: 8
565
+ },
566
+ children: [
567
+ /* @__PURE__ */ jsx(XAxis, {
568
+ dataKey: "label",
569
+ interval: 0,
570
+ tick: {
571
+ fontSize: 12,
572
+ fill: "#555"
573
+ },
574
+ tickLine: false,
575
+ axisLine: false
576
+ }),
577
+ /* @__PURE__ */ jsx(YAxis, {
578
+ yAxisId: "bar",
579
+ hide: true,
580
+ domain: [0, "auto"]
581
+ }),
582
+ /* @__PURE__ */ jsx(YAxis, {
583
+ yAxisId: "line",
584
+ hide: true,
585
+ domain: lineDomain
586
+ }),
587
+ /* @__PURE__ */ jsx(Tooltip$1, { content: /* @__PURE__ */ jsx(ChartTooltip, {}) }),
588
+ /* @__PURE__ */ jsxs(Bar, {
589
+ yAxisId: "bar",
590
+ dataKey: "bar",
591
+ fill: barColor,
592
+ isAnimationActive: false,
593
+ onClick: clickToFilter ? (_bar, index, event) => handleBarClick(index, event) : void 0,
594
+ cursor: clickToFilter ? "pointer" : void 0,
595
+ children: [clickToFilter && data.map((point) => /* @__PURE__ */ jsx(Cell, { fillOpacity: isDimmed(point) ? .35 : 1 }, String(point.value))), showLabels && /* @__PURE__ */ jsx(LabelList, {
596
+ dataKey: "bar",
597
+ content: /* @__PURE__ */ jsx(BarValueLabel, {})
598
+ })]
599
+ }),
600
+ /* @__PURE__ */ jsx(Line, {
601
+ yAxisId: "line",
602
+ dataKey: "line",
603
+ stroke: lineColor,
604
+ strokeWidth: 2,
605
+ strokeDasharray: "5 4",
606
+ dot: {
607
+ r: 2,
608
+ fill: lineColor
609
+ },
610
+ connectNulls: true,
611
+ isAnimationActive: false,
612
+ children: showLabels && /* @__PURE__ */ jsx(LabelList, {
613
+ dataKey: "line",
614
+ content: /* @__PURE__ */ jsx(LineValueLabel, {})
615
+ })
616
+ })
617
+ ]
618
+ })
619
+ })
620
+ })]
621
+ });
622
+ }
623
+ //#endregion
624
+ //#region src/client/useLayout.ts
625
+ const noLayouts = () => () => {};
626
+ /**
627
+ * Layout em vigor: o do arquivo ou, com o devtools, o rascunho editado na aba Layout. Registra a
628
+ * parte (`charts`/`filters`) para o devtools saber o que a página renderiza.
629
+ */
630
+ function useLayout(layout, part, formatters) {
631
+ const { devtools } = useDashboard();
632
+ const layouts = devtools?.layouts;
633
+ const formatterNames = Object.keys(formatters).sort().join(",");
634
+ useEffect(() => layouts?.register(layout, part, formatterNames ? formatterNames.split(",") : []), [
635
+ layouts,
636
+ layout,
637
+ part,
638
+ formatterNames
639
+ ]);
640
+ useSyncExternalStore(layouts?.subscribe ?? noLayouts, () => layouts?.getSnapshot(), () => void 0);
641
+ const [mounted, setMounted] = useState(false);
642
+ useEffect(() => setMounted(true), []);
643
+ return mounted && layouts?.draftOf(layout) || layout;
644
+ }
645
+ //#endregion
646
+ //#region src/client/DashboardCanvas.tsx
647
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
648
+ const FixedCanvasContext = createContext(false);
649
+ /** Dentro de um `<DashboardCanvas>` com tamanho fixo: o grid não troca de colunas pela tela. */
650
+ const useFixedCanvas = () => useContext(FixedCanvasContext);
651
+ /**
652
+ * Tela de tamanho fixo (`layout.canvas`, ex.: 1920×1080): desenha os filhos nessa largura e escala
653
+ * tudo para caber na largura disponível, mantendo a proporção — numa tela menor o dashboard encolhe
654
+ * em vez de reorganizar os gráficos. Sem `canvas` no layout, só renderiza os filhos (responsivo).
655
+ *
656
+ * ```tsx
657
+ * <DashboardCanvas layout={layout}>
658
+ * <DashboardFilters layout={layout} />
659
+ * <DashboardCharts layout={layout} />
660
+ * </DashboardCanvas>
661
+ * ```
662
+ *
663
+ * A escala é um `transform`: os gráficos continuam medindo o tamanho de projeto (o Recharts compensa
664
+ * a escala no mouse) e os breakpoints do MUI, que olham a janela, ficam desligados no grid.
665
+ */
666
+ function DashboardCanvas({ layout, children }) {
667
+ const { canvas } = useLayout(layout, "canvas", {});
668
+ const outer = useRef(null);
669
+ const inner = useRef(null);
670
+ const [size, setSize] = useState(null);
671
+ useIsomorphicLayoutEffect(() => {
672
+ if (!canvas || !outer.current || !inner.current) return;
673
+ const outerElement = outer.current;
674
+ const innerElement = inner.current;
675
+ const measure = () => {
676
+ const next = {
677
+ available: outerElement.clientWidth,
678
+ content: innerElement.offsetHeight
679
+ };
680
+ setSize((current) => current?.available === next.available && current.content === next.content ? current : next);
681
+ };
682
+ measure();
683
+ const observer = new ResizeObserver(measure);
684
+ observer.observe(outerElement);
685
+ observer.observe(innerElement);
686
+ return () => observer.disconnect();
687
+ }, [canvas]);
688
+ if (!canvas) return /* @__PURE__ */ jsx(Fragment, { children });
689
+ const scale = size ? size.available / canvas.width : 1;
690
+ return /* @__PURE__ */ jsx(FixedCanvasContext.Provider, {
691
+ value: true,
692
+ children: /* @__PURE__ */ jsx(Box, {
693
+ ref: outer,
694
+ sx: {
695
+ width: "100%",
696
+ overflow: "hidden",
697
+ height: size ? size.content * scale : void 0
698
+ },
699
+ children: /* @__PURE__ */ jsx(Box, {
700
+ ref: inner,
701
+ sx: {
702
+ width: canvas.width,
703
+ minHeight: canvas.height,
704
+ transform: `scale(${scale})`,
705
+ transformOrigin: "0 0",
706
+ visibility: size ? void 0 : "hidden"
707
+ },
708
+ children
709
+ })
710
+ })
711
+ });
712
+ }
713
+ //#endregion
714
+ //#region src/client/DataTable.tsx
715
+ const EMPTY = [];
716
+ function compare(a, b) {
717
+ if (typeof a === "number" && typeof b === "number") return a - b;
718
+ return String(a).localeCompare(String(b), "pt-BR", { numeric: true });
719
+ }
720
+ /**
721
+ * Tabela de medidas por dimensões, com total e ordenação pelo cabeçalho. Ex.:
722
+ * `<DataTable dimensions={['navio']} measures={['volTotal', 'custoTotal', 'custoTon']} />`.
723
+ */
724
+ function DataTable({ dimensions = EMPTY, measures, formatDimensions = {}, showTotal = true, sortBy, sortDirection = "desc", clickToFilter = false, height = 400, queryLabel }) {
725
+ const { meta } = useDashboard();
726
+ const name = queryLabel ?? `DataTable · ${dimensions.join(", ") || "total"}`;
727
+ const firstDimension = dimensions[0];
728
+ const ignoreFilters = clickToFilter && firstDimension ? [firstDimension] : void 0;
729
+ const { rows, isLoading, error, inspectProps } = useModelQuery({
730
+ dimensions,
731
+ measures,
732
+ ignoreFilters,
733
+ label: name
734
+ });
735
+ const totals = useModelQuery({
736
+ measures,
737
+ ignoreFilters,
738
+ label: `${name} · total`,
739
+ skip: !showTotal || !dimensions.length
740
+ });
741
+ const filter = useClickFilter(firstDimension, clickToFilter);
742
+ const [sort, setSort] = useState({
743
+ by: sortBy,
744
+ direction: sortDirection
745
+ });
746
+ const [configured, setConfigured] = useState([sortBy, sortDirection]);
747
+ if (configured[0] !== sortBy || configured[1] !== sortDirection) {
748
+ setConfigured([sortBy, sortDirection]);
749
+ setSort({
750
+ by: sortBy,
751
+ direction: sortDirection
752
+ });
753
+ }
754
+ const sorted = sort.by ? [...rows].sort((a, b) => {
755
+ const key = sort.by;
756
+ const [x, y] = [a[key], b[key]];
757
+ if (x === null || x === void 0) return 1;
758
+ if (y === null || y === void 0) return -1;
759
+ return sort.direction === "asc" ? compare(x, y) : compare(y, x);
760
+ }) : rows;
761
+ const formats = Object.fromEntries(measures.map((measure) => [measure, measureFormatter(meta, measure, 2)]));
762
+ const dimensionText = (dimension, value) => value === null || value === void 0 ? "(vazio)" : (formatDimensions[dimension] ?? String)(value);
763
+ const headerOf = (column) => meta?.dimensions[column]?.label ?? meta?.measures[column]?.label ?? column;
764
+ const toggleSort = (column) => setSort((current) => current.by === column ? {
765
+ by: column,
766
+ direction: current.direction === "asc" ? "desc" : "asc"
767
+ } : {
768
+ by: column,
769
+ direction: measures.includes(column) ? "desc" : "asc"
770
+ });
771
+ const totalRow = dimensions.length ? totals.rows[0] : void 0;
772
+ const placeholder = chartPlaceholder({
773
+ height: 160,
774
+ error,
775
+ isLoading,
776
+ empty: !rows.length
777
+ });
778
+ const header = (column, numeric) => /* @__PURE__ */ jsx(TableCell, {
779
+ align: numeric ? "right" : "left",
780
+ sortDirection: sort.by === column ? sort.direction : false,
781
+ sx: {
782
+ fontWeight: 700,
783
+ bgcolor: "#f3f3f3",
784
+ whiteSpace: "nowrap"
785
+ },
786
+ children: /* @__PURE__ */ jsx(TableSortLabel, {
787
+ active: sort.by === column,
788
+ direction: sort.by === column ? sort.direction : numeric ? "desc" : "asc",
789
+ onClick: () => toggleSort(column),
790
+ children: headerOf(column)
791
+ })
792
+ }, column);
793
+ return /* @__PURE__ */ jsxs(Box, {
794
+ ...inspectProps,
795
+ sx: {
796
+ minWidth: 0,
797
+ position: "relative"
798
+ },
799
+ children: [isLoading && /* @__PURE__ */ jsx(CircularProgress, {
800
+ size: 14,
801
+ sx: {
802
+ position: "absolute",
803
+ top: -22,
804
+ right: 0
805
+ }
806
+ }), placeholder ?? /* @__PURE__ */ jsx(Box, {
807
+ sx: {
808
+ maxHeight: height,
809
+ overflow: "auto",
810
+ opacity: isLoading ? .5 : 1,
811
+ transition: "opacity .2s"
812
+ },
813
+ children: /* @__PURE__ */ jsxs(Table, {
814
+ size: "small",
815
+ stickyHeader: true,
816
+ sx: { "& td, & th": { fontSize: 13 } },
817
+ children: [/* @__PURE__ */ jsx(TableHead, { children: /* @__PURE__ */ jsxs(TableRow, { children: [dimensions.map((dimension) => header(dimension, false)), measures.map((measure) => header(measure, true))] }) }), /* @__PURE__ */ jsxs(TableBody, { children: [sorted.map((row) => {
818
+ const value = firstDimension ? row[firstDimension] : null;
819
+ const clickable = clickToFilter && value !== null && value !== void 0;
820
+ return /* @__PURE__ */ jsxs(TableRow, {
821
+ hover: true,
822
+ onClick: clickable ? (event) => filter.toggle(value, event) : void 0,
823
+ selected: clickable && filter.selected.includes(value),
824
+ sx: {
825
+ cursor: clickable ? "pointer" : void 0,
826
+ opacity: clickable && filter.isDimmed(value) ? .5 : 1
827
+ },
828
+ children: [dimensions.map((dimension) => /* @__PURE__ */ jsx(TableCell, { children: dimensionText(dimension, row[dimension]) }, dimension)), measures.map((measure) => /* @__PURE__ */ jsx(TableCell, {
829
+ align: "right",
830
+ sx: { whiteSpace: "nowrap" },
831
+ children: formats[measure]?.(row[measure])
832
+ }, measure))]
833
+ }, dimensions.map((dimension) => String(row[dimension])).join("\0"));
834
+ }), totalRow && /* @__PURE__ */ jsxs(TableRow, {
835
+ sx: { "& td": {
836
+ fontWeight: 700,
837
+ position: "sticky",
838
+ bottom: 0,
839
+ bgcolor: "#f3f3f3",
840
+ borderTop: "2px solid #ddd"
841
+ } },
842
+ children: [dimensions.map((dimension, index) => /* @__PURE__ */ jsx(TableCell, { children: index === 0 ? "Total" : "" }, dimension)), measures.map((measure) => /* @__PURE__ */ jsx(TableCell, {
843
+ align: "right",
844
+ sx: { whiteSpace: "nowrap" },
845
+ children: formats[measure]?.(totalRow[measure])
846
+ }, measure))]
847
+ })] })]
848
+ })
849
+ })]
850
+ });
851
+ }
852
+ //#endregion
853
+ //#region src/client/DonutChart.tsx
854
+ const OTHERS = "Outros";
855
+ /**
856
+ * Rosca de uma medida por uma dimensão, com o total no centro e a legenda ao lado (nome, % e valor).
857
+ * Ex.: `<DonutChart dimension="operacao_status" measure="custoTotal" />`.
858
+ */
859
+ function DonutChart({ dimension, measure, maxSlices = 6, formatDimension = String, clickToFilter = false, height = 300, queryLabel }) {
860
+ const { meta } = useDashboard();
861
+ const { rows, isLoading, error, inspectProps } = useModelQuery({
862
+ dimensions: [dimension],
863
+ measures: [measure],
864
+ ignoreFilters: clickToFilter ? [dimension] : void 0,
865
+ label: queryLabel ?? `DonutChart · ${dimension}`
866
+ });
867
+ const filter = useClickFilter(dimension, clickToFilter);
868
+ const format = measureFormatter(meta, measure, 0);
869
+ const positive = rows.map((row) => ({
870
+ value: row[dimension] ?? "",
871
+ amount: numberOf(row, measure) ?? 0
872
+ })).filter((item) => item.amount > 0).sort((a, b) => b.amount - a.amount);
873
+ const limit = Math.min(Math.max(maxSlices, 2), 8);
874
+ const kept = positive.length > limit ? positive.slice(0, limit - 1) : positive;
875
+ const rest = positive.slice(kept.length);
876
+ const slices = kept.map((item, index) => ({
877
+ key: String(item.value),
878
+ value: item.value,
879
+ label: formatDimension(item.value),
880
+ amount: item.amount,
881
+ color: seriesColor(index)
882
+ }));
883
+ if (rest.length) slices.push({
884
+ key: `\u0000${OTHERS}`,
885
+ value: null,
886
+ label: `${OTHERS} (${rest.length})`,
887
+ amount: rest.reduce((total, item) => total + item.amount, 0),
888
+ color: OTHER_COLOR
889
+ });
890
+ const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
891
+ const percent = (amount) => `${formatNumber(amount / total * 100, 1)}%`;
892
+ const placeholder = chartPlaceholder({
893
+ height,
894
+ error,
895
+ isLoading,
896
+ empty: !slices.length
897
+ });
898
+ const select = (slice, event) => {
899
+ if (slice?.value !== null && slice?.value !== void 0) filter.toggle(slice.value, event);
900
+ };
901
+ const dimmed = (slice) => filter.selected.length > 0 && (slice.value === null || filter.isDimmed(slice.value));
902
+ const SliceTooltip = ({ active, payload }) => {
903
+ if (!active || !payload?.length) return null;
904
+ const slice = payload[0].payload;
905
+ return /* @__PURE__ */ jsxs(TooltipBox, {
906
+ title: slice.label,
907
+ children: [
908
+ format(slice.amount),
909
+ " · ",
910
+ percent(slice.amount)
911
+ ]
912
+ });
913
+ };
914
+ return /* @__PURE__ */ jsx(Box, {
915
+ ...inspectProps,
916
+ sx: {
917
+ minWidth: 0,
918
+ position: "relative"
919
+ },
920
+ children: placeholder ?? /* @__PURE__ */ jsxs(Box, {
921
+ sx: {
922
+ height,
923
+ display: "flex",
924
+ alignItems: "center",
925
+ gap: 2,
926
+ opacity: isLoading ? .5 : 1,
927
+ transition: "opacity .2s"
928
+ },
929
+ children: [/* @__PURE__ */ jsxs(Box, {
930
+ sx: {
931
+ position: "relative",
932
+ height: "100%",
933
+ flex: "0 0 50%",
934
+ minWidth: 140
935
+ },
936
+ children: [/* @__PURE__ */ jsx(ResponsiveContainer, {
937
+ width: "100%",
938
+ height: "100%",
939
+ children: /* @__PURE__ */ jsxs(PieChart, { children: [/* @__PURE__ */ jsx(Tooltip$1, { content: /* @__PURE__ */ jsx(SliceTooltip, {}) }), /* @__PURE__ */ jsx(Pie, {
940
+ data: slices,
941
+ dataKey: "amount",
942
+ nameKey: "label",
943
+ innerRadius: "62%",
944
+ outerRadius: "92%",
945
+ startAngle: 90,
946
+ endAngle: -270,
947
+ stroke: "#fff",
948
+ strokeWidth: 2,
949
+ isAnimationActive: false,
950
+ onClick: (_, index, event) => select(slices[index], event),
951
+ cursor: clickToFilter ? "pointer" : void 0,
952
+ children: slices.map((slice) => /* @__PURE__ */ jsx(Cell, {
953
+ fill: slice.color,
954
+ fillOpacity: dimmed(slice) ? .35 : 1
955
+ }, slice.key))
956
+ })] })
957
+ }), /* @__PURE__ */ jsxs(Box, {
958
+ sx: {
959
+ position: "absolute",
960
+ inset: 0,
961
+ display: "flex",
962
+ flexDirection: "column",
963
+ alignItems: "center",
964
+ justifyContent: "center",
965
+ pointerEvents: "none",
966
+ textAlign: "center"
967
+ },
968
+ children: [/* @__PURE__ */ jsx(Box, {
969
+ sx: {
970
+ fontSize: 12,
971
+ color: "#777"
972
+ },
973
+ children: "Total"
974
+ }), /* @__PURE__ */ jsx(Box, {
975
+ sx: {
976
+ fontSize: 15,
977
+ fontWeight: 700,
978
+ color: "#333"
979
+ },
980
+ children: format(total)
981
+ })]
982
+ })]
983
+ }), /* @__PURE__ */ jsx(Box, {
984
+ component: "ul",
985
+ sx: {
986
+ listStyle: "none",
987
+ m: 0,
988
+ p: 0,
989
+ minWidth: 0,
990
+ flex: 1,
991
+ overflow: "auto",
992
+ maxHeight: "100%"
993
+ },
994
+ children: slices.map((slice) => /* @__PURE__ */ jsxs(Box, {
995
+ component: "li",
996
+ onClick: (event) => select(slice, event),
997
+ sx: {
998
+ display: "grid",
999
+ gridTemplateColumns: "10px minmax(0, 1fr) auto",
1000
+ alignItems: "center",
1001
+ columnGap: .75,
1002
+ py: .5,
1003
+ fontSize: 13,
1004
+ color: "#333",
1005
+ opacity: dimmed(slice) ? .45 : 1,
1006
+ cursor: clickToFilter && slice.value !== null ? "pointer" : void 0
1007
+ },
1008
+ children: [
1009
+ /* @__PURE__ */ jsx(Box, { sx: {
1010
+ width: 10,
1011
+ height: 10,
1012
+ borderRadius: "50%",
1013
+ bgcolor: slice.color
1014
+ } }),
1015
+ /* @__PURE__ */ jsx(Box, {
1016
+ sx: {
1017
+ overflow: "hidden",
1018
+ textOverflow: "ellipsis",
1019
+ whiteSpace: "nowrap"
1020
+ },
1021
+ children: slice.label
1022
+ }),
1023
+ /* @__PURE__ */ jsx(Box, {
1024
+ sx: {
1025
+ fontWeight: 700,
1026
+ textAlign: "right"
1027
+ },
1028
+ children: percent(slice.amount)
1029
+ }),
1030
+ /* @__PURE__ */ jsx(Box, {}),
1031
+ /* @__PURE__ */ jsx(Box, {
1032
+ sx: {
1033
+ gridColumn: "2 / 4",
1034
+ fontSize: 12,
1035
+ color: "#777"
1036
+ },
1037
+ children: format(slice.amount)
1038
+ })
1039
+ ]
1040
+ }, slice.key))
1041
+ })]
1042
+ })
1043
+ });
1044
+ }
1045
+ //#endregion
1046
+ //#region src/client/KpiCard.tsx
1047
+ /**
1048
+ * Card com o total de uma medida (e, opcionalmente, uma segunda medida embaixo). Uma consulta sem
1049
+ * dimensões: razões (custo/t) saem do total, não de uma média. Ex.:
1050
+ * `<KpiCard label="Custo total" measure="custoTotal" secondary="custoTon" />`.
1051
+ */
1052
+ function KpiCard({ measure, label, secondary, secondaryLabel, queryLabel }) {
1053
+ const { meta } = useDashboard();
1054
+ const { rows, isLoading, error, inspectProps } = useModelQuery({
1055
+ measures: secondary ? [measure, secondary] : [measure],
1056
+ label: queryLabel ?? `KpiCard · ${measure}`
1057
+ });
1058
+ const row = rows[0];
1059
+ const value = row ? numberOf(row, measure) : null;
1060
+ const extra = row && secondary ? numberOf(row, secondary) : null;
1061
+ return /* @__PURE__ */ jsxs(Box, {
1062
+ ...inspectProps,
1063
+ sx: {
1064
+ minWidth: 0,
1065
+ position: "relative"
1066
+ },
1067
+ children: [
1068
+ label && /* @__PURE__ */ jsx(Box, {
1069
+ sx: {
1070
+ fontSize: 13,
1071
+ color: "#666",
1072
+ mb: .5
1073
+ },
1074
+ children: label
1075
+ }),
1076
+ isLoading && /* @__PURE__ */ jsx(CircularProgress, {
1077
+ size: 12,
1078
+ sx: {
1079
+ position: "absolute",
1080
+ top: 0,
1081
+ right: 0
1082
+ }
1083
+ }),
1084
+ /* @__PURE__ */ jsx(Box, {
1085
+ sx: {
1086
+ fontSize: 28,
1087
+ fontWeight: 700,
1088
+ lineHeight: 1.2,
1089
+ color: error ? "error.main" : "#333",
1090
+ fontVariantNumeric: "tabular-nums",
1091
+ whiteSpace: "nowrap",
1092
+ overflow: "hidden",
1093
+ textOverflow: "ellipsis",
1094
+ opacity: isLoading ? .5 : 1,
1095
+ transition: "opacity .2s"
1096
+ },
1097
+ children: error ? "Erro" : value === null ? "—" : measureFormatter(meta, measure, 0)(value)
1098
+ }),
1099
+ secondary && /* @__PURE__ */ jsxs(Box, {
1100
+ sx: {
1101
+ fontSize: 13,
1102
+ color: "#666",
1103
+ mt: .25,
1104
+ minHeight: 20
1105
+ },
1106
+ children: [
1107
+ secondaryLabel ?? meta?.measures[secondary]?.label ?? secondary,
1108
+ ":",
1109
+ " ",
1110
+ /* @__PURE__ */ jsx(Box, {
1111
+ component: "span",
1112
+ sx: {
1113
+ fontWeight: 600,
1114
+ color: "#444"
1115
+ },
1116
+ children: extra === null ? "—" : measureFormatter(meta, secondary, 2)(extra)
1117
+ })
1118
+ ]
1119
+ })
1120
+ ]
1121
+ });
1122
+ }
1123
+ //#endregion
1124
+ //#region src/client/DashboardCharts.tsx
1125
+ function DefaultPanel({ title, children }) {
1126
+ return /* @__PURE__ */ jsxs(Paper, {
1127
+ variant: "outlined",
1128
+ sx: {
1129
+ p: 1.5,
1130
+ minWidth: 0,
1131
+ height: "100%"
1132
+ },
1133
+ children: [title && /* @__PURE__ */ jsx(Typography, {
1134
+ sx: {
1135
+ fontWeight: 700,
1136
+ mb: 1
1137
+ },
1138
+ children: title
1139
+ }), children]
1140
+ });
1141
+ }
1142
+ /**
1143
+ * Renderiza os gráficos de um layout declarativo num grid de 12 colunas (no celular, um por linha;
1144
+ * dentro de um `<DashboardCanvas>` de tamanho fixo, sempre as 12). Em desenvolvimento, a aba
1145
+ * Layout do devtools edita esse layout ao vivo (rascunho) e grava o JSON de volta.
1146
+ *
1147
+ * ```tsx
1148
+ * import layout from '@/dashboard/layouts/analiseCusto.json'
1149
+ * <DashboardCharts layout={layout as ChartsLayout} formatters={{ nomeMes }} panel={Panel} />
1150
+ * ```
1151
+ */
1152
+ function DashboardCharts({ layout, formatters = {}, panel: Panel = DefaultPanel, gap = 1.5 }) {
1153
+ const { charts } = useLayout(layout, "charts", formatters);
1154
+ const fixed = useFixedCanvas();
1155
+ const columns = `repeat(12, minmax(0, 1fr))`;
1156
+ return /* @__PURE__ */ jsx(Box, {
1157
+ sx: {
1158
+ display: "grid",
1159
+ gap,
1160
+ gridTemplateColumns: fixed ? columns : {
1161
+ xs: "1fr",
1162
+ lg: columns
1163
+ }
1164
+ },
1165
+ children: charts.map((chart) => /* @__PURE__ */ jsx(Box, {
1166
+ [LAYOUT_ITEM_ATTRIBUTE]: layoutItemKey(layout.id, {
1167
+ kind: "chart",
1168
+ key: chart.id
1169
+ }),
1170
+ sx: {
1171
+ minWidth: 0,
1172
+ gridColumn: fixed ? `span ${chart.span ?? 12}` : { lg: `span ${chart.span ?? 12}` }
1173
+ },
1174
+ children: /* @__PURE__ */ jsx(Panel, {
1175
+ title: chart.title,
1176
+ chart,
1177
+ children: /* @__PURE__ */ jsx(Chart, {
1178
+ chart,
1179
+ formatters
1180
+ })
1181
+ })
1182
+ }, chart.id))
1183
+ });
1184
+ }
1185
+ function Chart({ chart, formatters }) {
1186
+ const queryLabel = `${chart.title || chart.id} · ${chart.type}`;
1187
+ const formatter = (name) => name ? formatters[name] : void 0;
1188
+ switch (chart.type) {
1189
+ case "kpi": {
1190
+ const { id, type, title, span, ...props } = chart;
1191
+ return /* @__PURE__ */ jsx(KpiCard, {
1192
+ ...props,
1193
+ queryLabel
1194
+ });
1195
+ }
1196
+ case "combo": {
1197
+ const { id, type, title, span, formatDimension, ...props } = chart;
1198
+ return /* @__PURE__ */ jsx(ComboChart, {
1199
+ ...props,
1200
+ queryLabel,
1201
+ formatDimension: formatter(formatDimension)
1202
+ });
1203
+ }
1204
+ case "bar": {
1205
+ const { id, type, title, span, formatDimension, ...props } = chart;
1206
+ return /* @__PURE__ */ jsx(BarChart, {
1207
+ ...props,
1208
+ queryLabel,
1209
+ formatDimension: formatter(formatDimension)
1210
+ });
1211
+ }
1212
+ case "donut": {
1213
+ const { id, type, title, span, formatDimension, ...props } = chart;
1214
+ return /* @__PURE__ */ jsx(DonutChart, {
1215
+ ...props,
1216
+ queryLabel,
1217
+ formatDimension: formatter(formatDimension)
1218
+ });
1219
+ }
1220
+ case "table": {
1221
+ const { id, type, title, span, formatDimensions, ...props } = chart;
1222
+ const resolved = {};
1223
+ for (const [dimension, name] of Object.entries(formatDimensions ?? {})) {
1224
+ const format = formatter(name);
1225
+ if (format) resolved[dimension] = format;
1226
+ }
1227
+ return /* @__PURE__ */ jsx(DataTable, {
1228
+ ...props,
1229
+ queryLabel,
1230
+ formatDimensions: resolved
1231
+ });
1232
+ }
1233
+ default: return /* @__PURE__ */ jsxs(Box, {
1234
+ sx: {
1235
+ color: "error.main",
1236
+ p: 2
1237
+ },
1238
+ children: ["Tipo de gráfico desconhecido: ", String(chart.type)]
1239
+ });
1240
+ }
1241
+ }
1242
+ //#endregion
1243
+ //#region src/client/FilterSelect.tsx
1244
+ /**
1245
+ * Filtro de múltipla escolha de uma dimensão. As opções vêm do próprio modelo e respeitam os
1246
+ * DEMAIS filtros (filtro cruzado): com Mês = Janeiro, o filtro de navio só lista quem atracou em
1247
+ * janeiro. Nada selecionado = "Todos".
1248
+ */
1249
+ function FilterSelect({ dimension, label, formatLabel = String, width = 200 }) {
1250
+ const { filters, setFilter, meta } = useDashboard();
1251
+ label ??= meta?.dimensions[dimension]?.label ?? dimension;
1252
+ const { rows, inspectProps } = useModelQuery({
1253
+ dimensions: [dimension],
1254
+ ignoreFilters: [dimension],
1255
+ label: `FilterSelect · ${dimension}`
1256
+ });
1257
+ const selected = filters[dimension] ?? [];
1258
+ const options = rows.map((row) => row[dimension]).filter((value) => value !== null && value !== "");
1259
+ for (const value of selected) if (!options.includes(value)) options.push(value);
1260
+ return /* @__PURE__ */ jsxs(FormControl, {
1261
+ ...inspectProps,
1262
+ size: "small",
1263
+ sx: { width },
1264
+ children: [/* @__PURE__ */ jsx(InputLabel, {
1265
+ shrink: true,
1266
+ children: label
1267
+ }), /* @__PURE__ */ jsx(Select, {
1268
+ multiple: true,
1269
+ displayEmpty: true,
1270
+ value: selected,
1271
+ onChange: (event) => setFilter(dimension, event.target.value),
1272
+ input: /* @__PURE__ */ jsx(OutlinedInput, {
1273
+ notched: true,
1274
+ label
1275
+ }),
1276
+ renderValue: (values) => values.length ? values.map(formatLabel).join(", ") : "Todos",
1277
+ MenuProps: { slotProps: { paper: { sx: { maxHeight: 360 } } } },
1278
+ sx: { bgcolor: "#fff" },
1279
+ children: options.map((option) => /* @__PURE__ */ jsxs(MenuItem, {
1280
+ value: option,
1281
+ dense: true,
1282
+ children: [/* @__PURE__ */ jsx(Checkbox, {
1283
+ size: "small",
1284
+ checked: selected.includes(option),
1285
+ sx: {
1286
+ p: .5,
1287
+ mr: 1
1288
+ }
1289
+ }), /* @__PURE__ */ jsx(ListItemText, { primary: formatLabel(option) })]
1290
+ }, option))
1291
+ })]
1292
+ });
1293
+ }
1294
+ //#endregion
1295
+ //#region src/client/DashboardFilters.tsx
1296
+ /**
1297
+ * Filtros (slicers) declarados em `layout.filters`, na ordem. Em desenvolvimento, a aba Layout do
1298
+ * devtools adiciona, remove, reordena e edita os filtros ao vivo.
1299
+ */
1300
+ function DashboardFilters({ layout, formatters = {}, gap = 2 }) {
1301
+ const { filters = [] } = useLayout(layout, "filters", formatters);
1302
+ return /* @__PURE__ */ jsx(Box, {
1303
+ sx: {
1304
+ display: "flex",
1305
+ flexWrap: "wrap",
1306
+ gap
1307
+ },
1308
+ children: filters.map((filter) => /* @__PURE__ */ jsx(Box, {
1309
+ [LAYOUT_ITEM_ATTRIBUTE]: layoutItemKey(layout.id, {
1310
+ kind: "filter",
1311
+ key: filter.dimension
1312
+ }),
1313
+ children: /* @__PURE__ */ jsx(FilterSelect, {
1314
+ dimension: filter.dimension,
1315
+ label: filter.label,
1316
+ width: filter.width,
1317
+ formatLabel: filter.formatLabel ? formatters[filter.formatLabel] : void 0
1318
+ })
1319
+ }, filter.dimension))
1320
+ });
1321
+ }
1322
+ //#endregion
1323
+ export { BarChart, ComboChart, DashboardCanvas, DashboardCharts, DashboardFilters, DashboardProvider, DataTable, DonutChart, FilterSelect, INSPECT_ATTRIBUTE, KpiCard, ModelDictionary, OTHER_COLOR, SERIES_COLORS, formatCurrency, formatMeasure, formatNumber, measureFormatter, useDashboard, useModelQuery };
1324
+
1325
+ //# sourceMappingURL=index.js.map