@bluemarble/bi 0.6.6 → 0.8.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.
@@ -1,7 +1,7 @@
1
1
  import { c as VALUE_FORMATS, f as validateModel, g as parseColumnRef, h as modelEdges, m as measureHome, o as AGGREGATIONS, s as AGGREGATION_LABEL, t as canonicalJson, u as isAggregate, v as referencedTables } from "./codegen-LoQzFAle.js";
2
2
  import { n as diffRows } from "./compare-DloZhg1T.js";
3
- import { C as RULE_OPERATORS, E as VALUE_RULE_OPERATORS, S as NUMERIC_OPERATORS, T as UNARY_OPERATORS, a as FILTER_FIELDS, b as COLUMN_ALIGNS, c as chartColumns, f as filterReferences, g as percentColumn, h as layoutModelWarnings, i as CARD_FIELDS, l as chartOfType, n as CANVAS_PRESETS, o as cardsGap, r as CARDS_FIELDS, t as BUILTIN_CHART_TYPES, u as chartReferences, v as tableColumns, w as RULE_OPERATOR_LABEL, x as COLUMN_VERTICAL_ALIGNS, y as validateLayout } from "./layout-CrZ00tB0.js";
4
- import { C as parseLayoutItemKey, S as layoutItemKey, b as errorMessage, f as SERIES_COLORS, n as INSPECT_ATTRIBUTE, o as formatMeasure, t as ModelDictionary, x as LAYOUT_ITEM_ATTRIBUTE, y as useDashboard } from "./ModelDictionary-DUGFHBo-.js";
3
+ import { C as DEFAULT_SCALE_MIN_COLOR, D as UNARY_OPERATORS, E as RULE_OPERATOR_LABEL, O as VALUE_RULE_OPERATORS, S as COLUMN_VERTICAL_ALIGNS, T as RULE_OPERATORS, a as FILTER_FIELDS, b as COLOR_SCALE_TARGETS, c as chartColumns, f as filterReferences, g as percentColumn, h as layoutModelWarnings, i as CARD_FIELDS, l as chartOfType, n as CANVAS_PRESETS, o as cardsGap, r as CARDS_FIELDS, t as BUILTIN_CHART_TYPES, u as chartReferences, v as tableColumns, w as NUMERIC_OPERATORS, x as COLUMN_ALIGNS, y as validateLayout } from "./layout-BS3nO6OT.js";
4
+ import { C as useDashboard, D as parseLayoutItemKey, E as layoutItemKey, T as LAYOUT_ITEM_ATTRIBUTE, g as SERIES_COLORS, n as INSPECT_ATTRIBUTE, s as formatMeasure, t as ModelDictionary, w as errorMessage } from "./ModelDictionary-DZiYK1mV.js";
5
5
  import { Alert, Autocomplete, Box, Button, Checkbox, Chip, CircularProgress, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControlLabel, IconButton, List, ListItemButton, ListSubheader, Menu, MenuItem, MenuList, Paper, Popover, Popper, Portal, Switch, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Tabs, TextField, ThemeProvider, ToggleButton, ToggleButtonGroup, Tooltip, Typography, createTheme } from "@mui/material";
6
6
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
7
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -2182,6 +2182,11 @@ function FieldsForm({ fields, values, onSet: set, onSetMany, model, formatters,
2182
2182
  model,
2183
2183
  onChange: (rules) => set(key, rules.length ? rules : void 0)
2184
2184
  });
2185
+ case "colorScales": return /* @__PURE__ */ jsx(ColorScalesInput, {
2186
+ scales: value ?? [],
2187
+ columnOptions: columnOptionsOf(columns.filter((name) => model.dimensions[name]?.type !== "string")),
2188
+ onChange: (scales) => set(key, scales.length ? scales : void 0)
2189
+ });
2185
2190
  case "formatters": {
2186
2191
  const map = value ?? {};
2187
2192
  if (!chosenDimensions.length) return /* @__PURE__ */ jsx(Typography, {
@@ -2254,7 +2259,7 @@ function FieldsForm({ fields, values, onSet: set, onSetMany, model, formatters,
2254
2259
  ...pairedFormatters
2255
2260
  ].filter(Boolean));
2256
2261
  const sections = [...new Set(entries.filter(([key]) => !hidden.has(key)).map(([, spec]) => spec.section))];
2257
- const fullRow = (spec) => spec.kind === "dimensions" || spec.kind === "measures" || spec.kind === "formatters" || spec.kind === "formatRules" || spec.kind === "colors" || spec.kind === "valueRule";
2262
+ const fullRow = (spec) => spec.kind === "dimensions" || spec.kind === "measures" || spec.kind === "formatters" || spec.kind === "formatRules" || spec.kind === "colorScales" || spec.kind === "colors" || spec.kind === "valueRule";
2258
2263
  const gridColumn = (spec, key) => fullRow(spec) ? "1 / -1" : key === "title" && formColumns > 1 ? "span 2" : void 0;
2259
2264
  return /* @__PURE__ */ jsx(Fragment, { children: sections.map((section) => {
2260
2265
  const inSection = entries.filter(([, spec]) => spec.section === section);
@@ -2590,6 +2595,202 @@ function FormatRulesInput({ rules, fieldOptions, columnOptions, model, onChange
2590
2595
  })]
2591
2596
  });
2592
2597
  }
2598
+ const COLOR_SCALE_TARGET_LABEL = {
2599
+ background: "o fundo",
2600
+ text: "o texto"
2601
+ };
2602
+ const DEFAULT_SCALE_COLOR = "#2e7d32";
2603
+ /**
2604
+ * Escalas de cor (gradiente), uma por cartão: "pinta <o fundo | o texto> de <coluna> de <cor
2605
+ * mínima> (em <mínimo>) a <cor máxima> (em <máximo>)". Mínimo/máximo vazios = da própria coluna.
2606
+ */
2607
+ function ColorScalesInput({ scales, columnOptions, onChange }) {
2608
+ const update = (index, change) => onChange(scales.map((scale, i) => i === index ? change(scale) : scale));
2609
+ const columns = columnOptions.map((option) => option.name);
2610
+ const parseNumber = (text) => {
2611
+ const number = Number(text.trim().replace(",", "."));
2612
+ return text.trim() && Number.isFinite(number) ? number : void 0;
2613
+ };
2614
+ return /* @__PURE__ */ jsxs(Box, {
2615
+ sx: {
2616
+ display: "flex",
2617
+ flexDirection: "column",
2618
+ gap: .75,
2619
+ maxWidth: 760
2620
+ },
2621
+ children: [scales.map((scale, index) => {
2622
+ const minColor = scale.minColor ?? "#ffffff";
2623
+ const inverted = scale.min !== void 0 && scale.max !== void 0 && scale.min >= scale.max;
2624
+ return /* @__PURE__ */ jsxs(Box, {
2625
+ sx: {
2626
+ border: 1,
2627
+ borderColor: "divider",
2628
+ borderRadius: 1,
2629
+ p: .75,
2630
+ display: "flex",
2631
+ flexDirection: "column",
2632
+ gap: .5,
2633
+ "&:hover .row-actions": { visibility: "visible" }
2634
+ },
2635
+ children: [
2636
+ /* @__PURE__ */ jsxs(Box, {
2637
+ sx: {
2638
+ display: "flex",
2639
+ gap: .5,
2640
+ alignItems: "center",
2641
+ flexWrap: "wrap"
2642
+ },
2643
+ children: [
2644
+ /* @__PURE__ */ jsxs(Typography, {
2645
+ sx: {
2646
+ fontSize: 12,
2647
+ color: "text.secondary"
2648
+ },
2649
+ children: [index + 1, ". Pinta"]
2650
+ }),
2651
+ /* @__PURE__ */ jsx(TextField, {
2652
+ select: true,
2653
+ size: "small",
2654
+ value: scale.target ?? "background",
2655
+ onChange: (e) => update(index, (current) => withField(current, "target", e.target.value === "background" ? void 0 : e.target.value)),
2656
+ sx: {
2657
+ ...inputSx$1,
2658
+ flex: "1 1 100px"
2659
+ },
2660
+ children: COLOR_SCALE_TARGETS.map((target) => /* @__PURE__ */ jsx(MenuItem, {
2661
+ value: target,
2662
+ children: COLOR_SCALE_TARGET_LABEL[target]
2663
+ }, target))
2664
+ }),
2665
+ /* @__PURE__ */ jsx(Typography, {
2666
+ sx: {
2667
+ fontSize: 12,
2668
+ color: "text.secondary"
2669
+ },
2670
+ children: "de"
2671
+ }),
2672
+ /* @__PURE__ */ jsx(Box, {
2673
+ sx: { flex: "2 1 140px" },
2674
+ children: /* @__PURE__ */ jsx(NameSelect, {
2675
+ value: scale.column,
2676
+ options: columnOptions,
2677
+ required: true,
2678
+ onChange: (column) => update(index, (current) => ({
2679
+ ...current,
2680
+ column
2681
+ }))
2682
+ })
2683
+ }),
2684
+ /* @__PURE__ */ jsx(Box, {
2685
+ title: "Prévia do gradiente",
2686
+ sx: {
2687
+ flex: "1 1 80px",
2688
+ height: 20,
2689
+ border: 1,
2690
+ borderColor: "divider",
2691
+ borderRadius: .5,
2692
+ background: `linear-gradient(to right, ${minColor}, ${scale.color})`
2693
+ }
2694
+ }),
2695
+ /* @__PURE__ */ jsxs(Box, {
2696
+ className: "row-actions",
2697
+ sx: {
2698
+ display: "flex",
2699
+ visibility: "hidden"
2700
+ },
2701
+ children: [
2702
+ /* @__PURE__ */ jsx(SmallButton, {
2703
+ title: "Subir",
2704
+ disabled: index === 0,
2705
+ onClick: () => onChange(moveTo(scales, index, index - 1)),
2706
+ children: "↑"
2707
+ }),
2708
+ /* @__PURE__ */ jsx(SmallButton, {
2709
+ title: "Descer",
2710
+ disabled: index === scales.length - 1,
2711
+ onClick: () => onChange(moveTo(scales, index, index + 2)),
2712
+ children: "↓"
2713
+ }),
2714
+ /* @__PURE__ */ jsx(SmallButton, {
2715
+ title: "Remover",
2716
+ onClick: () => onChange(scales.filter((_, i) => i !== index)),
2717
+ children: "×"
2718
+ })
2719
+ ]
2720
+ })
2721
+ ]
2722
+ }),
2723
+ scale.column && !columns.includes(scale.column) && /* @__PURE__ */ jsxs(Typography, {
2724
+ sx: {
2725
+ fontSize: 11,
2726
+ color: "warning.main"
2727
+ },
2728
+ children: [
2729
+ "\"",
2730
+ scale.column,
2731
+ "\" não é coluna numérica do gráfico: nada é pintado"
2732
+ ]
2733
+ }),
2734
+ /* @__PURE__ */ jsxs(Box, {
2735
+ sx: {
2736
+ display: "grid",
2737
+ gap: .5,
2738
+ gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",
2739
+ alignItems: "end"
2740
+ },
2741
+ children: [
2742
+ field("Cor mínima", /* @__PURE__ */ jsx(ColorInput, {
2743
+ value: scale.minColor,
2744
+ fallback: DEFAULT_SCALE_MIN_COLOR,
2745
+ onCommit: (color) => update(index, (current) => withField(current, "minColor", color === "#ffffff" ? void 0 : color))
2746
+ })),
2747
+ field("Mínimo", /* @__PURE__ */ jsx(CommitText, {
2748
+ value: scale.min === void 0 ? "" : String(scale.min),
2749
+ placeholder: "menor da coluna",
2750
+ error: inverted,
2751
+ onCommit: (text) => update(index, (current) => withField(current, "min", parseNumber(text)))
2752
+ })),
2753
+ field("Cor máxima", /* @__PURE__ */ jsx(ColorInput, {
2754
+ value: scale.color,
2755
+ fallback: DEFAULT_SCALE_COLOR,
2756
+ onCommit: (color) => update(index, (current) => ({
2757
+ ...current,
2758
+ color: color ?? DEFAULT_SCALE_COLOR
2759
+ }))
2760
+ })),
2761
+ field("Máximo", /* @__PURE__ */ jsx(CommitText, {
2762
+ value: scale.max === void 0 ? "" : String(scale.max),
2763
+ placeholder: "maior da coluna",
2764
+ error: inverted,
2765
+ onCommit: (text) => update(index, (current) => withField(current, "max", parseNumber(text)))
2766
+ }))
2767
+ ]
2768
+ }),
2769
+ inverted && /* @__PURE__ */ jsx(Typography, {
2770
+ sx: {
2771
+ fontSize: 11,
2772
+ color: "error.main"
2773
+ },
2774
+ children: "O mínimo precisa ser menor que o máximo"
2775
+ })
2776
+ ]
2777
+ }, index);
2778
+ }), /* @__PURE__ */ jsx(Button, {
2779
+ size: "small",
2780
+ variant: "outlined",
2781
+ disabled: !columnOptions.length,
2782
+ onClick: () => onChange([...scales, {
2783
+ column: columns.find((name) => !scales.some((scale) => scale.column === name)) ?? columns[0] ?? "",
2784
+ color: DEFAULT_SCALE_COLOR
2785
+ }]),
2786
+ sx: {
2787
+ alignSelf: "flex-start",
2788
+ textTransform: "none"
2789
+ },
2790
+ children: "+ escala"
2791
+ })]
2792
+ });
2793
+ }
2593
2794
  /** Campo de texto que só manda o valor no Enter ou ao sair (número parcial não vira valor). */
2594
2795
  function CommitText({ value, placeholder, error, onCommit }) {
2595
2796
  const [typing, setTyping] = useState(null);
@@ -3036,7 +3237,7 @@ function ColumnOptions({ kind, name, model, label, onLabel, format, onFormat, fo
3036
3237
  value: formatter,
3037
3238
  options: formatters.map((option) => ({ name: option })),
3038
3239
  onChange: onFormatter
3039
- }), "Formatters passados ao <DashboardCharts formatters={{ ... }}> da página"),
3240
+ }), "Built-in (to_month_name, to_date, ...) e os passados ao <DashboardCharts formatters={{ ... }}> da página"),
3040
3241
  format !== void 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
3041
3242
  /* @__PURE__ */ jsxs(Box, {
3042
3243
  sx: {
@@ -3620,126 +3821,629 @@ function freeName(prefix, taken) {
3620
3821
  for (let index = 2;; index++) if (!taken.includes(`${prefix}${index}`)) return `${prefix}${index}`;
3621
3822
  }
3622
3823
  //#endregion
3623
- //#region src/devtools/SqlEditor.tsx
3624
- /**
3625
- * Campo de SQL com destaque de sintaxe e autocomplete de tabelas e colunas. As colunas vêm do
3626
- * `information_schema` (tabelas/views do banco) e das colunas de cada tabela do modelo (lidas do banco).
3627
- * O ⤢ abre o mesmo SQL num editor grande.
3628
- *
3629
- * O destaque é um `<pre>` colorido por baixo do `<textarea>` de texto transparente: mesma fonte,
3630
- * mesmo recuo e mesma quebra de linha, com a rolagem sincronizada. O cursor, a seleção e o
3631
- * autocomplete continuam sendo do textarea.
3632
- */
3633
- const PAD = {
3634
- top: 8.5,
3635
- right: 30,
3636
- bottom: 8.5,
3637
- left: 12
3638
- };
3639
- const LINE_HEIGHT = "18px";
3640
- const SqlCompletionsContext = createContext({
3641
- tables: [],
3642
- modelColumns: {}
3643
- });
3644
- const KEYWORDS = new Set("WHERE ON LEFT RIGHT INNER OUTER JOIN GROUP ORDER LIMIT USING AND OR AS SET HAVING UNION".split(" "));
3645
- /** `alias -> tabela` a partir dos `FROM x a` / `JOIN x AS a` do texto. */
3646
- function aliases(sql) {
3647
- const map = /* @__PURE__ */ new Map();
3648
- for (const match of sql.matchAll(/\b(?:FROM|JOIN)\s+`?(\w+)`?(?:\s+(?:AS\s+)?`?(\w+)`?)?/gi)) {
3649
- const [, table, alias] = match;
3650
- map.set(table.toLowerCase(), table);
3651
- if (alias && !KEYWORDS.has(alias.toUpperCase())) map.set(alias.toLowerCase(), table);
3652
- }
3653
- return map;
3824
+ //#region src/devtools/QueryBuilder.tsx
3825
+ const RESERVED = new Set("select from where group order by limit key keys index table desc asc and or not in is null like as on join case when then else end interval range rows".split(" "));
3826
+ const quote = (name) => /^[A-Za-z_]\w*$/.test(name) && !RESERVED.has(name.toLowerCase()) ? name : `\`${name}\``;
3827
+ const unquote = (name) => name.trim().replace(/^`(.*)`$/, "$1");
3828
+ function buildSelect(table, columns) {
3829
+ return `SELECT\n${columns.length ? columns.map((column) => ` ${quote(column)}`).join(",\n") : " *"}\nFROM ${quote(table)}`;
3654
3830
  }
3655
- function suggest(sql, caret, completions) {
3656
- const before = sql.slice(0, caret);
3657
- const tables = new Map(completions.tables.map((table) => [table.name.toLowerCase(), table]));
3658
- const columnsOf = (table) => tables.get(table.toLowerCase())?.columns.map((column) => ({
3659
- text: column.name,
3660
- detail: column.type
3661
- })) ?? [];
3662
- let prefix;
3663
- let candidates;
3664
- const qualified = /([A-Za-z_]\w*)\.(\w*)$/.exec(before);
3665
- if (qualified) {
3666
- const [, qualifier, typed] = qualified;
3667
- prefix = typed;
3668
- if (completions.modelColumns[qualifier]) candidates = completions.modelColumns[qualifier].map((text) => ({
3669
- text,
3670
- detail: qualifier
3671
- }));
3672
- else candidates = columnsOf(aliases(sql).get(qualifier.toLowerCase()) ?? qualifier);
3673
- } else {
3674
- const word = /[A-Za-z_]\w*$/.exec(before);
3675
- if (!word || word[0].length < 2) return null;
3676
- prefix = word[0];
3677
- const referenced = [...new Set(aliases(sql).values())];
3678
- candidates = [
3679
- ...Object.keys(completions.modelColumns).map((text) => ({
3680
- text,
3681
- detail: "tabela do modelo"
3682
- })),
3683
- ...completions.tables.map((table) => ({
3684
- text: table.name,
3685
- detail: table.type
3686
- })),
3687
- ...referenced.flatMap(columnsOf)
3688
- ];
3689
- }
3690
- const lower = prefix.toLowerCase();
3691
- const seen = /* @__PURE__ */ new Set();
3692
- const items = candidates.filter((item) => {
3693
- const text = item.text.toLowerCase();
3694
- if (seen.has(text) || text === lower || !text.includes(lower)) return false;
3695
- seen.add(text);
3696
- return true;
3697
- }).sort((a, b) => Number(!a.text.toLowerCase().startsWith(lower)) - Number(!b.text.toLowerCase().startsWith(lower)) || a.text.localeCompare(b.text)).slice(0, 8);
3698
- return items.length ? {
3699
- from: caret - prefix.length,
3700
- items
3701
- } : null;
3831
+ /** Tabela e colunas de um `SELECT a, b FROM t` (sem WHERE, JOIN etc.); senão `null`. */
3832
+ function parseSelect(sql) {
3833
+ const match = /^\s*SELECT\s+([\s\S]+?)\s+FROM\s+(`[^`]+`|\w+)\s*;?\s*$/i.exec(sql);
3834
+ if (!match) return null;
3835
+ const columns = match[1].trim() === "*" ? [] : match[1].split(",").map(unquote);
3836
+ if (columns.some((column) => !/^[^\s`().]+$/.test(column))) return null;
3837
+ return {
3838
+ table: unquote(match[2]),
3839
+ columns
3840
+ };
3702
3841
  }
3703
- function SqlEditor({ value, onChange, label, placeholder = "SQL", minRows, maxRows = 6, error, warning, expandable = true }) {
3704
- const completions = useContext(SqlCompletionsContext);
3705
- const anchor = useRef(null);
3706
- const input = useRef(null);
3707
- const highlight = useRef(null);
3708
- const [expanded, setExpanded] = useState(false);
3709
- const [state, setState] = useState(null);
3710
- const accept = (item) => {
3711
- const element = input.current;
3712
- if (!element || !state) return;
3713
- const caret = element.selectionStart;
3714
- onChange(value.slice(0, state.from) + item.text + value.slice(caret));
3715
- setState(null);
3716
- const position = state.from + item.text.length;
3717
- requestAnimationFrame(() => {
3718
- element.focus();
3719
- element.setSelectionRange(position, position);
3720
- });
3842
+ function QueryBuilder({ open, onClose, tables, sql, onApply }) {
3843
+ return /* @__PURE__ */ jsx(Dialog, {
3844
+ open,
3845
+ onClose,
3846
+ maxWidth: "md",
3847
+ fullWidth: true,
3848
+ children: open && /* @__PURE__ */ jsx(Builder, {
3849
+ tables,
3850
+ sql,
3851
+ onClose,
3852
+ onApply
3853
+ })
3854
+ });
3855
+ }
3856
+ function Builder({ tables, sql, onClose, onApply }) {
3857
+ const [initial] = useState(() => {
3858
+ const parsed = parseSelect(sql);
3859
+ const table = parsed && tables.find((item) => item.name === parsed.table);
3860
+ if (!parsed || !table) return {
3861
+ table: null,
3862
+ columns: []
3863
+ };
3864
+ const known = new Set(table.columns.map((column) => column.name));
3865
+ return {
3866
+ table,
3867
+ columns: parsed.columns.filter((column) => known.has(column))
3868
+ };
3869
+ });
3870
+ const [table, setTable] = useState(initial.table);
3871
+ const [selected, setSelected] = useState(initial.columns);
3872
+ const [search, setSearch] = useState("");
3873
+ const visible = useMemo(() => {
3874
+ const lower = search.trim().toLowerCase();
3875
+ return (table?.columns ?? []).filter((column) => column.name.toLowerCase().includes(lower));
3876
+ }, [table, search]);
3877
+ const ordered = (table?.columns ?? []).map((column) => column.name).filter((name) => selected.includes(name));
3878
+ const result = table ? buildSelect(table.name, ordered) : "";
3879
+ const allVisible = visible.length > 0 && visible.every((column) => selected.includes(column.name));
3880
+ const toggle = (name) => setSelected((current) => current.includes(name) ? current.filter((item) => item !== name) : [...current, name]);
3881
+ const toggleVisible = () => {
3882
+ const names = visible.map((column) => column.name);
3883
+ setSelected((current) => allVisible ? current.filter((name) => !names.includes(name)) : [.../* @__PURE__ */ new Set([...current, ...names])]);
3721
3884
  };
3722
3885
  return /* @__PURE__ */ jsxs(Fragment, { children: [
3723
- /* @__PURE__ */ jsx(TextField, {
3724
- ref: anchor,
3725
- inputRef: input,
3726
- size: "small",
3727
- fullWidth: true,
3728
- multiline: true,
3729
- label,
3730
- minRows,
3731
- maxRows,
3732
- value,
3733
- placeholder,
3734
- error: !value.trim() || !!error,
3735
- helperText: error ?? warning,
3736
- onChange: (event) => {
3737
- onChange(event.target.value);
3738
- const found = suggest(event.target.value, event.target.selectionStart ?? 0, completions);
3739
- setState(found && {
3740
- ...found,
3741
- index: 0
3742
- });
3886
+ /* @__PURE__ */ jsx(DialogTitle, {
3887
+ sx: { fontSize: 14 },
3888
+ children: "Criar query"
3889
+ }),
3890
+ /* @__PURE__ */ jsxs(DialogContent, {
3891
+ sx: {
3892
+ display: "flex",
3893
+ flexDirection: "column",
3894
+ gap: 1.5
3895
+ },
3896
+ children: [
3897
+ /* @__PURE__ */ jsx(Autocomplete, {
3898
+ size: "small",
3899
+ options: tables,
3900
+ value: table,
3901
+ onChange: (_, value) => {
3902
+ setTable(value);
3903
+ setSelected([]);
3904
+ setSearch("");
3905
+ },
3906
+ getOptionLabel: (option) => option.name,
3907
+ isOptionEqualToValue: (option, value) => option.name === value.name,
3908
+ noOptionsText: tables.length ? "Nenhuma tabela" : "Carregando as tabelas do banco…",
3909
+ renderOption: ({ key, ...props }, option) => /* @__PURE__ */ jsxs(Box, {
3910
+ component: "li",
3911
+ ...props,
3912
+ sx: {
3913
+ gap: 2,
3914
+ fontFamily: MONO,
3915
+ fontSize: 12
3916
+ },
3917
+ children: [option.name, /* @__PURE__ */ jsxs(Box, {
3918
+ component: "span",
3919
+ sx: {
3920
+ ml: "auto",
3921
+ color: "text.secondary",
3922
+ fontSize: 11
3923
+ },
3924
+ children: [
3925
+ option.type,
3926
+ " · ",
3927
+ option.columns.length,
3928
+ " colunas"
3929
+ ]
3930
+ })]
3931
+ }, key),
3932
+ renderInput: (params) => /* @__PURE__ */ jsx(TextField, {
3933
+ ...params,
3934
+ label: "tabela",
3935
+ sx: { mt: 1 }
3936
+ })
3937
+ }),
3938
+ table && /* @__PURE__ */ jsxs(Box, {
3939
+ sx: {
3940
+ display: "flex",
3941
+ flexDirection: "column",
3942
+ gap: .5
3943
+ },
3944
+ children: [/* @__PURE__ */ jsxs(Box, {
3945
+ sx: {
3946
+ display: "flex",
3947
+ alignItems: "center",
3948
+ gap: 1
3949
+ },
3950
+ children: [
3951
+ /* @__PURE__ */ jsx(FormControlLabel, {
3952
+ control: /* @__PURE__ */ jsx(Checkbox, {
3953
+ size: "small",
3954
+ checked: allVisible,
3955
+ indeterminate: !allVisible && visible.some((c) => selected.includes(c.name)),
3956
+ onChange: toggleVisible
3957
+ }),
3958
+ label: /* @__PURE__ */ jsx(Typography, {
3959
+ sx: { fontSize: 12 },
3960
+ children: "Todas"
3961
+ })
3962
+ }),
3963
+ /* @__PURE__ */ jsxs(Typography, {
3964
+ sx: {
3965
+ fontSize: 12,
3966
+ color: "text.secondary"
3967
+ },
3968
+ children: [
3969
+ selected.length,
3970
+ " de ",
3971
+ table.columns.length,
3972
+ " marcadas",
3973
+ !selected.length && " (nenhuma = *)"
3974
+ ]
3975
+ }),
3976
+ /* @__PURE__ */ jsx(TextField, {
3977
+ size: "small",
3978
+ placeholder: "filtrar colunas",
3979
+ value: search,
3980
+ onChange: (event) => setSearch(event.target.value),
3981
+ sx: {
3982
+ ml: "auto",
3983
+ width: 200
3984
+ }
3985
+ })
3986
+ ]
3987
+ }), /* @__PURE__ */ jsx(Box, {
3988
+ sx: {
3989
+ maxHeight: 280,
3990
+ overflow: "auto",
3991
+ border: 1,
3992
+ borderColor: "divider",
3993
+ borderRadius: 1,
3994
+ px: 1,
3995
+ display: "grid",
3996
+ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))"
3997
+ },
3998
+ children: visible.map((column) => /* @__PURE__ */ jsx(FormControlLabel, {
3999
+ control: /* @__PURE__ */ jsx(Checkbox, {
4000
+ size: "small",
4001
+ checked: selected.includes(column.name),
4002
+ onChange: () => toggle(column.name)
4003
+ }),
4004
+ label: /* @__PURE__ */ jsxs(Box, {
4005
+ component: "span",
4006
+ sx: {
4007
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4008
+ fontSize: 12
4009
+ },
4010
+ children: [
4011
+ column.name,
4012
+ " ",
4013
+ /* @__PURE__ */ jsx(Box, {
4014
+ component: "span",
4015
+ sx: {
4016
+ color: "text.secondary",
4017
+ fontSize: 11
4018
+ },
4019
+ children: column.type
4020
+ })
4021
+ ]
4022
+ }),
4023
+ sx: {
4024
+ m: 0,
4025
+ minWidth: 0
4026
+ }
4027
+ }, column.name))
4028
+ })]
4029
+ }),
4030
+ result && /* @__PURE__ */ jsx(Box, {
4031
+ component: "pre",
4032
+ sx: {
4033
+ m: 0,
4034
+ p: 1,
4035
+ border: 1,
4036
+ borderColor: "divider",
4037
+ borderRadius: 1,
4038
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4039
+ fontSize: 12,
4040
+ maxHeight: 200,
4041
+ overflow: "auto"
4042
+ },
4043
+ children: /* @__PURE__ */ jsx(HighlightedSql, { code: result })
4044
+ })
4045
+ ]
4046
+ }),
4047
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
4048
+ onClick: onClose,
4049
+ children: "Cancelar"
4050
+ }), /* @__PURE__ */ jsx(Button, {
4051
+ variant: "contained",
4052
+ disabled: !table,
4053
+ onClick: () => {
4054
+ onApply(result);
4055
+ onClose();
4056
+ },
4057
+ title: "Substitui o SQL atual",
4058
+ children: "Usar no SQL"
4059
+ })] })
4060
+ ] });
4061
+ }
4062
+ //#endregion
4063
+ //#region src/devtools/queryTemplates.ts
4064
+ /**
4065
+ * `WITH` da origem: a tabela do banco entra pelo nome; o SQL de uma tabela do modelo vira o CTE
4066
+ * `origem`, antes dos CTEs do template.
4067
+ */
4068
+ function withSource(source) {
4069
+ if (source.kind === "database") return {
4070
+ ctes: [],
4071
+ from: quote(source.table)
4072
+ };
4073
+ return {
4074
+ ctes: [`origem AS (\n${source.sql.trim().replace(/;+\s*$/, "").replace(/^/gm, " ")}\n)`],
4075
+ from: "origem"
4076
+ };
4077
+ }
4078
+ /**
4079
+ * Um dia por linha, do menor ao maior valor da coluna, com ano e mês. É a tabela "Calendário" do
4080
+ * Power BI: relacione a coluna de data da fato com `dia`.
4081
+ *
4082
+ * Não é um `WITH RECURSIVE` de um dia por iteração: o MySQL (`cte_max_recursion_depth`) e o MariaDB
4083
+ * (`max_recursive_iterations`) param em 1000 iterações, e o MariaDB corta em silêncio — o calendário
4084
+ * acabaria ~2,7 anos depois do início. A recursão só gera 0–999 e o dia sai de `mil * 1000 + n`.
4085
+ */
4086
+ function calendarSql(source, column) {
4087
+ const { ctes, from } = withSource(source);
4088
+ const col = quote(column);
4089
+ return [
4090
+ `WITH RECURSIVE ${[
4091
+ ...ctes,
4092
+ [
4093
+ "limites AS (",
4094
+ ` SELECT DATE(MIN(${col})) AS inicio, DATE(MAX(${col})) AS fim`,
4095
+ ` FROM ${from}`,
4096
+ ")"
4097
+ ],
4098
+ [
4099
+ "numeros AS (",
4100
+ " SELECT 0 AS n",
4101
+ " UNION ALL",
4102
+ " SELECT n + 1 FROM numeros WHERE n < 999",
4103
+ ")"
4104
+ ],
4105
+ [
4106
+ "dias AS (",
4107
+ " SELECT inicio + INTERVAL (mil.n * 1000 + numeros.n) DAY AS dia",
4108
+ " FROM limites, numeros mil, numeros",
4109
+ " WHERE mil.n <= DATEDIFF(fim, inicio) DIV 1000",
4110
+ " AND mil.n * 1000 + numeros.n <= DATEDIFF(fim, inicio)",
4111
+ ")"
4112
+ ]
4113
+ ].map((part) => Array.isArray(part) ? part.join("\n") : part).join(",\n")}`,
4114
+ "SELECT",
4115
+ " dia,",
4116
+ " YEAR(dia) AS ano,",
4117
+ " MONTH(dia) AS mes",
4118
+ "FROM dias"
4119
+ ].join("\n");
4120
+ }
4121
+ const QUERY_TEMPLATES = [{
4122
+ id: "calendar",
4123
+ label: "Calendário",
4124
+ description: "Um dia por linha (com ano e mês) entre a menor e a maior data da coluna. Relacione a data da fato com `dia`.",
4125
+ columnLabel: "coluna de data",
4126
+ preferredTypes: /date|time/i,
4127
+ build: calendarSql
4128
+ }];
4129
+ //#endregion
4130
+ //#region src/devtools/QueryTemplates.tsx
4131
+ function QueryTemplates({ open, onClose, ...props }) {
4132
+ return /* @__PURE__ */ jsx(Dialog, {
4133
+ open,
4134
+ onClose,
4135
+ maxWidth: "md",
4136
+ fullWidth: true,
4137
+ children: open && /* @__PURE__ */ jsx(Templates, {
4138
+ onClose,
4139
+ ...props
4140
+ })
4141
+ });
4142
+ }
4143
+ function Templates({ tables, modelTables, onClose, onApply }) {
4144
+ const [templateId, setTemplateId] = useState(QUERY_TEMPLATES[0].id);
4145
+ const [source, setSource] = useState(null);
4146
+ const [column, setColumn] = useState(null);
4147
+ const template = QUERY_TEMPLATES.find((item) => item.id === templateId) ?? QUERY_TEMPLATES[0];
4148
+ const sources = useMemo(() => [...Object.entries(modelTables).map(([name, table]) => ({
4149
+ key: `model:${name}`,
4150
+ name,
4151
+ group: "Tabelas do modelo",
4152
+ columns: table.columns.map((column) => ({ name: column })),
4153
+ source: {
4154
+ kind: "model",
4155
+ sql: table.sql
4156
+ }
4157
+ })), ...tables.map((table) => ({
4158
+ key: `database:${table.name}`,
4159
+ name: table.name,
4160
+ group: "Tabelas do banco",
4161
+ columns: table.columns,
4162
+ source: {
4163
+ kind: "database",
4164
+ table: table.name
4165
+ }
4166
+ }))], [tables, modelTables]);
4167
+ const columns = useMemo(() => {
4168
+ const list = source?.columns ?? [];
4169
+ const preferred = (type) => !!type && !!template.preferredTypes?.test(type);
4170
+ return [...list.filter((c) => preferred(c.type)), ...list.filter((c) => !preferred(c.type))];
4171
+ }, [source, template]);
4172
+ const result = source && column ? template.build(source.source, column) : "";
4173
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
4174
+ /* @__PURE__ */ jsx(DialogTitle, {
4175
+ sx: { fontSize: 14 },
4176
+ children: "Importar template"
4177
+ }),
4178
+ /* @__PURE__ */ jsxs(DialogContent, {
4179
+ sx: {
4180
+ display: "flex",
4181
+ flexDirection: "column",
4182
+ gap: 1.5
4183
+ },
4184
+ children: [
4185
+ /* @__PURE__ */ jsx(TextField, {
4186
+ select: true,
4187
+ size: "small",
4188
+ label: "template",
4189
+ value: template.id,
4190
+ onChange: (event) => setTemplateId(event.target.value),
4191
+ helperText: template.description,
4192
+ sx: { mt: 1 },
4193
+ children: QUERY_TEMPLATES.map((item) => /* @__PURE__ */ jsx(MenuItem, {
4194
+ value: item.id,
4195
+ children: item.label
4196
+ }, item.id))
4197
+ }),
4198
+ /* @__PURE__ */ jsx(Autocomplete, {
4199
+ size: "small",
4200
+ options: sources,
4201
+ value: source,
4202
+ onChange: (_, value) => {
4203
+ setSource(value);
4204
+ setColumn(null);
4205
+ },
4206
+ groupBy: (option) => option.group,
4207
+ getOptionLabel: (option) => option.name,
4208
+ isOptionEqualToValue: (option, value) => option.key === value.key,
4209
+ noOptionsText: "Nenhuma tabela",
4210
+ renderOption: ({ key, ...props }, option) => /* @__PURE__ */ jsxs(Box, {
4211
+ component: "li",
4212
+ ...props,
4213
+ sx: {
4214
+ gap: 2,
4215
+ fontFamily: MONO,
4216
+ fontSize: 12
4217
+ },
4218
+ children: [option.name, /* @__PURE__ */ jsxs(Box, {
4219
+ component: "span",
4220
+ sx: {
4221
+ ml: "auto",
4222
+ color: "text.secondary",
4223
+ fontSize: 11
4224
+ },
4225
+ children: [option.columns.length, " colunas"]
4226
+ })]
4227
+ }, key),
4228
+ renderInput: (params) => /* @__PURE__ */ jsx(TextField, {
4229
+ ...params,
4230
+ label: "tabela"
4231
+ })
4232
+ }),
4233
+ source && /* @__PURE__ */ jsx(Autocomplete, {
4234
+ size: "small",
4235
+ options: columns.map((item) => item.name),
4236
+ value: column,
4237
+ onChange: (_, value) => setColumn(value),
4238
+ noOptionsText: source.source.kind === "model" ? "Sem colunas: valide o SQL dessa tabela" : "Nenhuma coluna",
4239
+ renderOption: ({ key, ...props }, name) => /* @__PURE__ */ jsxs(Box, {
4240
+ component: "li",
4241
+ ...props,
4242
+ sx: {
4243
+ gap: 2,
4244
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4245
+ fontSize: 12
4246
+ },
4247
+ children: [name, /* @__PURE__ */ jsx(Box, {
4248
+ component: "span",
4249
+ sx: {
4250
+ ml: "auto",
4251
+ color: "text.secondary",
4252
+ fontSize: 11
4253
+ },
4254
+ children: columns.find((item) => item.name === name)?.type
4255
+ })]
4256
+ }, key),
4257
+ renderInput: (params) => /* @__PURE__ */ jsx(TextField, {
4258
+ ...params,
4259
+ label: template.columnLabel
4260
+ })
4261
+ }),
4262
+ result ? /* @__PURE__ */ jsx(Box, {
4263
+ component: "pre",
4264
+ sx: {
4265
+ m: 0,
4266
+ p: 1,
4267
+ border: 1,
4268
+ borderColor: "divider",
4269
+ borderRadius: 1,
4270
+ fontFamily: MONO,
4271
+ fontSize: 12,
4272
+ maxHeight: 280,
4273
+ overflow: "auto"
4274
+ },
4275
+ children: /* @__PURE__ */ jsx(HighlightedSql, { code: result })
4276
+ }) : /* @__PURE__ */ jsx(Typography, {
4277
+ sx: {
4278
+ fontSize: 12,
4279
+ color: "text.secondary"
4280
+ },
4281
+ children: "Escolha a tabela e a coluna para ver o SQL."
4282
+ })
4283
+ ]
4284
+ }),
4285
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
4286
+ onClick: onClose,
4287
+ children: "Cancelar"
4288
+ }), /* @__PURE__ */ jsx(Button, {
4289
+ variant: "contained",
4290
+ disabled: !result,
4291
+ onClick: () => {
4292
+ onApply(result);
4293
+ onClose();
4294
+ },
4295
+ title: "Substitui o SQL atual",
4296
+ children: "Usar no SQL"
4297
+ })] })
4298
+ ] });
4299
+ }
4300
+ //#endregion
4301
+ //#region src/devtools/SqlEditor.tsx
4302
+ /**
4303
+ * Campo de SQL com destaque de sintaxe e autocomplete de tabelas e colunas. As colunas vêm do
4304
+ * `information_schema` (tabelas/views do banco) e das colunas de cada tabela do modelo (lidas do banco).
4305
+ * O ⤢ abre o mesmo SQL num editor grande; com `queryTools`, ele traz "Criar query" (escolher tabela e
4306
+ * colunas do banco), "Importar template" (calendário etc.) e "Validar SQL" (executa no banco e
4307
+ * mostra uma amostra).
4308
+ *
4309
+ * O destaque é um `<pre>` colorido por baixo do `<textarea>` de texto transparente: mesma fonte,
4310
+ * mesmo recuo e mesma quebra de linha, com a rolagem sincronizada. O cursor, a seleção e o
4311
+ * autocomplete continuam sendo do textarea.
4312
+ */
4313
+ const PAD = {
4314
+ top: 8.5,
4315
+ right: 30,
4316
+ bottom: 8.5,
4317
+ left: 12
4318
+ };
4319
+ const LINE_HEIGHT = "18px";
4320
+ const SqlCompletionsContext = createContext({
4321
+ tables: [],
4322
+ modelColumns: {}
4323
+ });
4324
+ const KEYWORDS = new Set("WHERE ON LEFT RIGHT INNER OUTER JOIN GROUP ORDER LIMIT USING AND OR AS SET HAVING UNION".split(" "));
4325
+ /** `alias -> tabela` a partir dos `FROM x a` / `JOIN x AS a` do texto. */
4326
+ function aliases(sql) {
4327
+ const map = /* @__PURE__ */ new Map();
4328
+ for (const match of sql.matchAll(/\b(?:FROM|JOIN)\s+`?(\w+)`?(?:\s+(?:AS\s+)?`?(\w+)`?)?/gi)) {
4329
+ const [, table, alias] = match;
4330
+ map.set(table.toLowerCase(), table);
4331
+ if (alias && !KEYWORDS.has(alias.toUpperCase())) map.set(alias.toLowerCase(), table);
4332
+ }
4333
+ return map;
4334
+ }
4335
+ function suggest(sql, caret, completions) {
4336
+ const before = sql.slice(0, caret);
4337
+ const tables = new Map(completions.tables.map((table) => [table.name.toLowerCase(), table]));
4338
+ const columnsOf = (table) => tables.get(table.toLowerCase())?.columns.map((column) => ({
4339
+ text: column.name,
4340
+ detail: column.type
4341
+ })) ?? [];
4342
+ let prefix;
4343
+ let candidates;
4344
+ const qualified = /([A-Za-z_]\w*)\.(\w*)$/.exec(before);
4345
+ if (qualified) {
4346
+ const [, qualifier, typed] = qualified;
4347
+ prefix = typed;
4348
+ if (completions.modelColumns[qualifier]) candidates = completions.modelColumns[qualifier].map((text) => ({
4349
+ text,
4350
+ detail: qualifier
4351
+ }));
4352
+ else candidates = columnsOf(aliases(sql).get(qualifier.toLowerCase()) ?? qualifier);
4353
+ } else {
4354
+ const word = /[A-Za-z_]\w*$/.exec(before);
4355
+ if (!word || word[0].length < 2) return null;
4356
+ prefix = word[0];
4357
+ const referenced = [...new Set(aliases(sql).values())];
4358
+ candidates = [
4359
+ ...Object.keys(completions.modelColumns).map((text) => ({
4360
+ text,
4361
+ detail: "tabela do modelo"
4362
+ })),
4363
+ ...completions.tables.map((table) => ({
4364
+ text: table.name,
4365
+ detail: table.type
4366
+ })),
4367
+ ...referenced.flatMap(columnsOf)
4368
+ ];
4369
+ }
4370
+ const lower = prefix.toLowerCase();
4371
+ const seen = /* @__PURE__ */ new Set();
4372
+ const items = candidates.filter((item) => {
4373
+ const text = item.text.toLowerCase();
4374
+ if (seen.has(text) || text === lower || !text.includes(lower)) return false;
4375
+ seen.add(text);
4376
+ return true;
4377
+ }).sort((a, b) => Number(!a.text.toLowerCase().startsWith(lower)) - Number(!b.text.toLowerCase().startsWith(lower)) || a.text.localeCompare(b.text)).slice(0, 8);
4378
+ return items.length ? {
4379
+ from: caret - prefix.length,
4380
+ items
4381
+ } : null;
4382
+ }
4383
+ function SqlEditor({ value, onChange, label, placeholder = "SQL", minRows, maxRows = 6, error, warning, expandable = true, queryTools = false }) {
4384
+ const completions = useContext(SqlCompletionsContext);
4385
+ const anchor = useRef(null);
4386
+ const input = useRef(null);
4387
+ const highlight = useRef(null);
4388
+ const [expanded, setExpanded] = useState(false);
4389
+ const [building, setBuilding] = useState(false);
4390
+ const [importing, setImporting] = useState(false);
4391
+ const [test, setTest] = useState(null);
4392
+ const [testing, setTesting] = useState(false);
4393
+ const currentTest = test?.sql === value ? test : null;
4394
+ const testSql = completions.testSql;
4395
+ const runTest = async () => {
4396
+ if (!testSql) return;
4397
+ const sql = value;
4398
+ setTesting(true);
4399
+ try {
4400
+ setTest({
4401
+ sql,
4402
+ result: await testSql(sql)
4403
+ });
4404
+ } catch (error) {
4405
+ setTest({
4406
+ sql,
4407
+ error: errorMessage(error)
4408
+ });
4409
+ } finally {
4410
+ setTesting(false);
4411
+ }
4412
+ };
4413
+ const [state, setState] = useState(null);
4414
+ const accept = (item) => {
4415
+ const element = input.current;
4416
+ if (!element || !state) return;
4417
+ const caret = element.selectionStart;
4418
+ onChange(value.slice(0, state.from) + item.text + value.slice(caret));
4419
+ setState(null);
4420
+ const position = state.from + item.text.length;
4421
+ requestAnimationFrame(() => {
4422
+ element.focus();
4423
+ element.setSelectionRange(position, position);
4424
+ });
4425
+ };
4426
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
4427
+ /* @__PURE__ */ jsx(TextField, {
4428
+ ref: anchor,
4429
+ inputRef: input,
4430
+ size: "small",
4431
+ fullWidth: true,
4432
+ multiline: true,
4433
+ label,
4434
+ minRows,
4435
+ maxRows,
4436
+ value,
4437
+ placeholder,
4438
+ error: !value.trim() || !!error,
4439
+ helperText: error ?? warning,
4440
+ onChange: (event) => {
4441
+ onChange(event.target.value);
4442
+ const found = suggest(event.target.value, event.target.selectionStart ?? 0, completions);
4443
+ setState(found && {
4444
+ ...found,
4445
+ index: 0
4446
+ });
3743
4447
  },
3744
4448
  onKeyDown: (event) => {
3745
4449
  if (!state) return;
@@ -3845,25 +4549,90 @@ function SqlEditor({ value, onChange, label, placeholder = "SQL", minRows, maxRo
3845
4549
  sx: { fontSize: 14 },
3846
4550
  children: label ?? "SQL"
3847
4551
  }),
3848
- /* @__PURE__ */ jsx(DialogContent, { children: /* @__PURE__ */ jsx(Box, {
3849
- sx: { pt: 1 },
3850
- children: /* @__PURE__ */ jsx(SqlEditor, {
3851
- value,
3852
- onChange,
3853
- placeholder,
3854
- minRows: 20,
3855
- maxRows: 36,
3856
- error,
3857
- warning,
3858
- expandable: false
4552
+ /* @__PURE__ */ jsxs(DialogContent, { children: [
4553
+ /* @__PURE__ */ jsx(Box, {
4554
+ sx: { pt: 1 },
4555
+ children: /* @__PURE__ */ jsx(SqlEditor, {
4556
+ value,
4557
+ onChange,
4558
+ placeholder,
4559
+ minRows: 20,
4560
+ maxRows: 36,
4561
+ error,
4562
+ warning,
4563
+ expandable: false
4564
+ })
4565
+ }),
4566
+ currentTest?.error && /* @__PURE__ */ jsx(Alert, {
4567
+ severity: "error",
4568
+ sx: {
4569
+ mt: 1,
4570
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4571
+ fontSize: 12
4572
+ },
4573
+ children: currentTest.error
4574
+ }),
4575
+ currentTest?.result && /* @__PURE__ */ jsxs(Box, {
4576
+ sx: {
4577
+ mt: 1,
4578
+ display: "flex",
4579
+ flexDirection: "column",
4580
+ gap: 1
4581
+ },
4582
+ children: [/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Chip, {
4583
+ color: "success",
4584
+ size: "small",
4585
+ label: `SQL válido · ${currentTest.result.columns.length} colunas · ${currentTest.result.durationMs} ms`
4586
+ }) }), /* @__PURE__ */ jsx(ResultTable, {
4587
+ rows: currentTest.result.rows,
4588
+ maxHeight: 240
4589
+ })]
3859
4590
  })
3860
- }) }),
3861
- /* @__PURE__ */ jsx(DialogActions, { children: /* @__PURE__ */ jsx(Button, {
3862
- onClick: () => setExpanded(false),
3863
- children: "Fechar"
3864
- }) })
4591
+ ] }),
4592
+ /* @__PURE__ */ jsxs(DialogActions, { children: [
4593
+ queryTools && /* @__PURE__ */ jsxs(Fragment, { children: [
4594
+ /* @__PURE__ */ jsx(Button, {
4595
+ onClick: () => setBuilding(true),
4596
+ title: "Escolher a tabela e as colunas",
4597
+ children: "Criar query"
4598
+ }),
4599
+ /* @__PURE__ */ jsx(Button, {
4600
+ onClick: () => setImporting(true),
4601
+ title: "Calendário e outras queries prontas",
4602
+ children: "Importar template"
4603
+ }),
4604
+ testSql && /* @__PURE__ */ jsx(Button, {
4605
+ disabled: !value.trim() || testing,
4606
+ onClick: runTest,
4607
+ title: "Executa o SQL no banco e mostra as primeiras linhas",
4608
+ children: testing ? "Validando…" : "Validar SQL"
4609
+ })
4610
+ ] }),
4611
+ /* @__PURE__ */ jsx(Box, { sx: { flex: 1 } }),
4612
+ /* @__PURE__ */ jsx(Button, {
4613
+ onClick: () => setExpanded(false),
4614
+ children: "Fechar"
4615
+ })
4616
+ ] })
3865
4617
  ]
3866
4618
  }),
4619
+ queryTools && /* @__PURE__ */ jsx(QueryBuilder, {
4620
+ open: building,
4621
+ onClose: () => setBuilding(false),
4622
+ tables: completions.tables,
4623
+ sql: value,
4624
+ onApply: onChange
4625
+ }),
4626
+ queryTools && /* @__PURE__ */ jsx(QueryTemplates, {
4627
+ open: importing,
4628
+ onClose: () => setImporting(false),
4629
+ tables: completions.tables,
4630
+ modelTables: Object.fromEntries(Object.entries(completions.modelSql ?? {}).map(([name, sql]) => [name, {
4631
+ sql,
4632
+ columns: completions.modelColumns[name] ?? []
4633
+ }])),
4634
+ onApply: onChange
4635
+ }),
3867
4636
  /* @__PURE__ */ jsx(Popper, {
3868
4637
  open: !!state,
3869
4638
  anchorEl: anchor.current,
@@ -4323,6 +5092,28 @@ function DimensionSource({ row, editor, columns, error, warning, onPatch }) {
4323
5092
  }
4324
5093
  //#endregion
4325
5094
  //#region src/devtools/ModelDiagram.tsx
5095
+ const TARGET_NAME = {
5096
+ table: "tabela",
5097
+ dimension: "dimensão",
5098
+ measure: "medida"
5099
+ };
5100
+ /** Põe uma medida calculada (fórmula) nova, com nome livre, no fim; devolve o `uid` dela. */
5101
+ function addFormulaTo(setEditor) {
5102
+ const uid = newUid();
5103
+ setEditor((current) => {
5104
+ const taken = [...current.dimensions, ...current.measures].map((item) => item.name);
5105
+ const measure = emptyMeasure({
5106
+ uid,
5107
+ type: "formula",
5108
+ name: freeName("novaMedida", taken)
5109
+ });
5110
+ return {
5111
+ ...current,
5112
+ measures: [...current.measures, measure]
5113
+ };
5114
+ });
5115
+ return uid;
5116
+ }
4326
5117
  const CARD_WIDTH = 230;
4327
5118
  const HEADER = 34;
4328
5119
  const ROW = 22;
@@ -4489,6 +5280,12 @@ function ModelDiagram({ editor, setEditor, columns, errors, warnings, loadingCol
4489
5280
  });
4490
5281
  const [selection, setSelection] = useState(null);
4491
5282
  const [panel, setPanel] = useState(loadPanel);
5283
+ const [renaming, setRenaming] = useState(null);
5284
+ const [menu, setMenu] = useState(null);
5285
+ const closeMenu = () => setMenu((current) => current && {
5286
+ ...current,
5287
+ open: false
5288
+ });
4492
5289
  useEffect(() => {
4493
5290
  try {
4494
5291
  localStorage.setItem(PANEL_KEY, JSON.stringify(panel));
@@ -4609,15 +5406,16 @@ function ModelDiagram({ editor, setEditor, columns, errors, warnings, loadingCol
4609
5406
  const rect = canvas.current?.getBoundingClientRect();
4610
5407
  return toWorld((rect?.left ?? 0) + (rect?.width ?? 600) / 2 - CARD_WIDTH / 2, (rect?.top ?? 0) + (rect?.height ?? 400) / 3);
4611
5408
  };
4612
- const addTable = () => {
5409
+ /** `at`: canto do card (o ponto do botão direito); sem ele, perto do centro da tela. */
5410
+ const addTable = (at) => {
4613
5411
  const uid = newUid();
4614
5412
  const name = freeName("novaTabela", editor.tables.map((table) => table.name));
4615
- const center = visibleCenter();
5413
+ const position = at ?? visibleCenter();
4616
5414
  setEditor((current) => ({
4617
5415
  ...current,
4618
5416
  positions: {
4619
5417
  ...current.positions,
4620
- [uid]: center
5418
+ [uid]: position
4621
5419
  },
4622
5420
  tables: [...current.tables, {
4623
5421
  uid,
@@ -4633,6 +5431,20 @@ function ModelDiagram({ editor, setEditor, columns, errors, warnings, loadingCol
4633
5431
  uid
4634
5432
  });
4635
5433
  };
5434
+ const organize = () => {
5435
+ const layout = autoLayout(nodes, sizes);
5436
+ setPositions((current) => Object.fromEntries(Object.entries(layout).map(([id, point]) => [id, {
5437
+ ...current[id],
5438
+ ...point
5439
+ }])));
5440
+ fitToScreen(layout);
5441
+ };
5442
+ const addFormula = () => {
5443
+ setSelection({
5444
+ kind: "measure",
5445
+ uid: addFormulaTo(setEditor)
5446
+ });
5447
+ };
4636
5448
  const removeTable = (uid) => {
4637
5449
  setEditor((current) => ({
4638
5450
  ...current,
@@ -4641,6 +5453,19 @@ function ModelDiagram({ editor, setEditor, columns, errors, warnings, loadingCol
4641
5453
  }));
4642
5454
  setSelection(null);
4643
5455
  };
5456
+ const remove = (target) => {
5457
+ if (target.kind === "table") return removeTable(target.uid);
5458
+ const list = {
5459
+ relationship: "relationships",
5460
+ dimension: "dimensions",
5461
+ measure: "measures"
5462
+ }[target.kind];
5463
+ setEditor((current) => ({
5464
+ ...current,
5465
+ [list]: current[list].filter((row) => row.uid !== target.uid)
5466
+ }));
5467
+ setSelection((current) => current?.uid === target.uid ? null : current);
5468
+ };
4644
5469
  const commitRename = (uid, name) => setEditor((current) => renameTable(current, uid, name));
4645
5470
  const connect = (from, fromColumn, to, toColumn) => {
4646
5471
  if (from.uid === to.uid) return;
@@ -4701,20 +5526,7 @@ function ModelDiagram({ editor, setEditor, columns, errors, warnings, loadingCol
4701
5526
  });
4702
5527
  };
4703
5528
  const removeSelection = () => {
4704
- if (!selection) return;
4705
- if (selection.kind === "table") removeTable(selection.uid);
4706
- else {
4707
- const list = {
4708
- relationship: "relationships",
4709
- dimension: "dimensions",
4710
- measure: "measures"
4711
- }[selection.kind];
4712
- setEditor((current) => ({
4713
- ...current,
4714
- [list]: current[list].filter((row) => row.uid !== selection.uid)
4715
- }));
4716
- setSelection(null);
4717
- }
5529
+ if (selection) remove(selection);
4718
5530
  };
4719
5531
  const startPan = (event) => {
4720
5532
  if (event.button !== 0) return;
@@ -4902,305 +5714,392 @@ function ModelDiagram({ editor, setEditor, columns, errors, warnings, loadingCol
4902
5714
  height: "100%",
4903
5715
  minHeight: 0
4904
5716
  },
4905
- children: [/* @__PURE__ */ jsxs(Box, {
4906
- ref: canvas,
4907
- tabIndex: -1,
4908
- onMouseDown: startPan,
4909
- onMouseDownCapture: () => canvas.current?.focus({ preventScroll: true }),
4910
- onKeyDown: (event) => {
4911
- if ((event.key === "Delete" || event.key === "Backspace") && event.target === canvas.current) removeSelection();
4912
- },
4913
- onWheel,
4914
- sx: {
4915
- outline: "none",
4916
- flex: 1,
4917
- position: "relative",
4918
- overflow: "hidden",
4919
- cursor: "grab",
4920
- bgcolor: "#15171b",
4921
- backgroundImage: "radial-gradient(rgba(255,255,255,.07) 1px, transparent 1px)",
4922
- backgroundSize: `${20 * view.zoom}px ${20 * view.zoom}px`,
4923
- backgroundPosition: `${view.x}px ${view.y}px`
4924
- },
4925
- children: [
4926
- /* @__PURE__ */ jsxs(Box, {
4927
- sx: {
4928
- position: "absolute",
4929
- left: 0,
4930
- top: 0,
4931
- transformOrigin: "0 0",
4932
- transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`
4933
- },
4934
- children: [/* @__PURE__ */ jsxs("svg", {
4935
- width: 1,
4936
- height: 1,
4937
- style: {
5717
+ children: [
5718
+ /* @__PURE__ */ jsxs(Box, {
5719
+ ref: canvas,
5720
+ tabIndex: -1,
5721
+ onMouseDown: startPan,
5722
+ onMouseDownCapture: () => canvas.current?.focus({ preventScroll: true }),
5723
+ onKeyDown: (event) => {
5724
+ if ((event.key === "Delete" || event.key === "Backspace") && event.target === canvas.current) removeSelection();
5725
+ },
5726
+ onWheel,
5727
+ onContextMenu: (event) => {
5728
+ event.preventDefault();
5729
+ setMenu({
5730
+ open: true,
5731
+ x: event.clientX,
5732
+ y: event.clientY,
5733
+ target: null
5734
+ });
5735
+ },
5736
+ sx: {
5737
+ outline: "none",
5738
+ flex: 1,
5739
+ position: "relative",
5740
+ overflow: "hidden",
5741
+ cursor: "grab",
5742
+ bgcolor: "#15171b",
5743
+ backgroundImage: "radial-gradient(rgba(255,255,255,.07) 1px, transparent 1px)",
5744
+ backgroundSize: `${20 * view.zoom}px ${20 * view.zoom}px`,
5745
+ backgroundPosition: `${view.x}px ${view.y}px`
5746
+ },
5747
+ children: [
5748
+ /* @__PURE__ */ jsxs(Box, {
5749
+ sx: {
5750
+ position: "absolute",
5751
+ left: 0,
5752
+ top: 0,
5753
+ transformOrigin: "0 0",
5754
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`
5755
+ },
5756
+ children: [/* @__PURE__ */ jsxs("svg", {
5757
+ width: 1,
5758
+ height: 1,
5759
+ style: {
5760
+ position: "absolute",
5761
+ overflow: "visible",
5762
+ pointerEvents: "none"
5763
+ },
5764
+ "aria-hidden": true,
5765
+ children: [lines.map((line) => /* @__PURE__ */ jsx(RelationshipLine, {
5766
+ line,
5767
+ hovered: hoveredEdge === line.id,
5768
+ onHover: (hovered) => setHoveredEdge(hovered ? line.id : null)
5769
+ }, line.id)), linkPreview && /* @__PURE__ */ jsx("path", {
5770
+ d: linkPreview,
5771
+ fill: "none",
5772
+ stroke: "#F58220",
5773
+ strokeWidth: 2,
5774
+ strokeDasharray: "5 4"
5775
+ })]
5776
+ }), nodes.map((node) => /* @__PURE__ */ jsx(TableCard, {
5777
+ node,
5778
+ position: placed(node),
5779
+ size: sizeOf(node),
5780
+ collapsed: !!collapsed[node.id],
5781
+ selected: !!isSelected(node),
5782
+ linking: !!linking && linking.node.id !== node.id,
5783
+ selectedField: selection?.kind === "dimension" || selection?.kind === "measure" ? selection.uid : null,
5784
+ onToggle: () => setCollapsed((current) => ({
5785
+ ...current,
5786
+ [node.id]: !current[node.id]
5787
+ })),
5788
+ onScroll: (top) => setScrolls((current) => ({
5789
+ ...current,
5790
+ [node.id]: top
5791
+ })),
5792
+ onHeaderDown: (event) => startMove(event, node),
5793
+ onResizeDown: (event, edges) => startResize(event, node, edges),
5794
+ onResizeReset: () => resetSize(node),
5795
+ onColumnDown: (event, column) => startLink(event, node, column),
5796
+ onFieldClick: (item) => setSelection({
5797
+ kind: item.kind,
5798
+ uid: item.uid
5799
+ }),
5800
+ onAddField: (kind, column) => addField(kind, node.name, column),
5801
+ renaming: renaming === node.uid,
5802
+ onRenameStart: () => setRenaming(node.uid),
5803
+ onRename: (name) => {
5804
+ setRenaming(null);
5805
+ if (name !== null && name !== node.name) commitRename(node.uid, name);
5806
+ },
5807
+ onContextMenu: (event, target) => {
5808
+ event.preventDefault();
5809
+ event.stopPropagation();
5810
+ setMenu({
5811
+ open: true,
5812
+ x: event.clientX,
5813
+ y: event.clientY,
5814
+ target
5815
+ });
5816
+ }
5817
+ }, node.id))]
5818
+ }),
5819
+ /* @__PURE__ */ jsxs(Box, {
5820
+ onMouseDown: (event) => event.stopPropagation(),
5821
+ onContextMenu: (event) => event.stopPropagation(),
5822
+ sx: {
5823
+ position: "absolute",
5824
+ top: 8,
5825
+ left: 8,
5826
+ right: 8,
5827
+ display: "flex",
5828
+ gap: .75,
5829
+ flexWrap: "wrap",
5830
+ alignItems: "center",
5831
+ pointerEvents: "none",
5832
+ "& > *": { pointerEvents: "auto" }
5833
+ },
5834
+ children: [
5835
+ /* @__PURE__ */ jsx(Button, {
5836
+ size: "small",
5837
+ variant: "outlined",
5838
+ onClick: () => addTable(),
5839
+ children: "+ Tabela"
5840
+ }),
5841
+ /* @__PURE__ */ jsx(Button, {
5842
+ size: "small",
5843
+ onClick: onLoadColumns,
5844
+ disabled: loadingColumns,
5845
+ title: "Lê uma linha de cada tabela para listar as colunas",
5846
+ sx: { bgcolor: "#1b1e23" },
5847
+ children: loadingColumns ? "Lendo colunas…" : "Recarregar colunas"
5848
+ }),
5849
+ /* @__PURE__ */ jsxs(Box, {
5850
+ sx: {
5851
+ ml: "auto",
5852
+ display: "flex",
5853
+ gap: .5,
5854
+ alignItems: "center"
5855
+ },
5856
+ children: [
5857
+ /* @__PURE__ */ jsx(Button, {
5858
+ size: "small",
5859
+ onClick: organize,
5860
+ title: "Dimensões em cima, fatos embaixo (troca as posições salvas)",
5861
+ sx: { bgcolor: "#1b1e23" },
5862
+ children: "Organizar"
5863
+ }),
5864
+ /* @__PURE__ */ jsx(Button, {
5865
+ size: "small",
5866
+ onClick: () => fitToScreen(),
5867
+ sx: { bgcolor: "#1b1e23" },
5868
+ children: "Ajustar à tela"
5869
+ }),
5870
+ /* @__PURE__ */ jsx(IconButton, {
5871
+ size: "small",
5872
+ "aria-label": "Diminuir zoom",
5873
+ onClick: () => setView((current) => ({
5874
+ ...current,
5875
+ zoom: Math.max(.3, current.zoom - .1)
5876
+ })),
5877
+ children: "−"
5878
+ }),
5879
+ /* @__PURE__ */ jsxs(Typography, {
5880
+ sx: {
5881
+ fontSize: 11,
5882
+ fontFamily: MONO,
5883
+ width: 36,
5884
+ textAlign: "center"
5885
+ },
5886
+ children: [Math.round(view.zoom * 100), "%"]
5887
+ }),
5888
+ /* @__PURE__ */ jsx(IconButton, {
5889
+ size: "small",
5890
+ "aria-label": "Aumentar zoom",
5891
+ onClick: () => setView((current) => ({
5892
+ ...current,
5893
+ zoom: Math.min(2, current.zoom + .1)
5894
+ })),
5895
+ children: "+"
5896
+ })
5897
+ ]
5898
+ })
5899
+ ]
5900
+ }),
5901
+ !nodes.length && /* @__PURE__ */ jsx(Box, {
5902
+ sx: {
5903
+ position: "absolute",
5904
+ inset: 0,
5905
+ display: "flex",
5906
+ alignItems: "center",
5907
+ justifyContent: "center",
5908
+ pointerEvents: "none",
5909
+ color: "text.secondary",
5910
+ fontSize: 13,
5911
+ textAlign: "center",
5912
+ px: 4
5913
+ },
5914
+ children: "Modelo vazio. Adicione as tabelas dimensão (cadastros: navio, calendário…) e fato (lançamentos: pesagens, custos…) e arraste a coluna da fato até a chave da dimensão para relacioná-las."
5915
+ }),
5916
+ !!nodes.length && /* @__PURE__ */ jsx(Typography, {
5917
+ sx: {
4938
5918
  position: "absolute",
4939
- overflow: "visible",
5919
+ left: 10,
5920
+ bottom: 6,
5921
+ fontSize: 11,
5922
+ color: "text.disabled",
4940
5923
  pointerEvents: "none"
4941
5924
  },
4942
- "aria-hidden": true,
4943
- children: [lines.map((line) => /* @__PURE__ */ jsx(RelationshipLine, {
4944
- line,
4945
- hovered: hoveredEdge === line.id,
4946
- onHover: (hovered) => setHoveredEdge(hovered ? line.id : null)
4947
- }, line.id)), linkPreview && /* @__PURE__ */ jsx("path", {
4948
- d: linkPreview,
4949
- fill: "none",
4950
- stroke: "#F58220",
4951
- strokeWidth: 2,
4952
- strokeDasharray: "5 4"
4953
- })]
4954
- }), nodes.map((node) => /* @__PURE__ */ jsx(TableCard, {
4955
- node,
4956
- position: placed(node),
4957
- size: sizeOf(node),
4958
- collapsed: !!collapsed[node.id],
4959
- selected: !!isSelected(node),
4960
- linking: !!linking && linking.node.id !== node.id,
4961
- selectedField: selection?.kind === "dimension" || selection?.kind === "measure" ? selection.uid : null,
4962
- onToggle: () => setCollapsed((current) => ({
4963
- ...current,
4964
- [node.id]: !current[node.id]
4965
- })),
4966
- onScroll: (top) => setScrolls((current) => ({
4967
- ...current,
4968
- [node.id]: top
4969
- })),
4970
- onHeaderDown: (event) => startMove(event, node),
4971
- onResizeDown: (event, edges) => startResize(event, node, edges),
4972
- onResizeReset: () => resetSize(node),
4973
- onColumnDown: (event, column) => startLink(event, node, column),
4974
- onFieldClick: (item) => setSelection({
4975
- kind: item.kind,
4976
- uid: item.uid
4977
- }),
4978
- onAddField: (kind, column) => addField(kind, node.name, column)
4979
- }, node.id))]
4980
- }),
4981
- /* @__PURE__ */ jsxs(Box, {
4982
- onMouseDown: (event) => event.stopPropagation(),
4983
- sx: {
4984
- position: "absolute",
4985
- top: 8,
4986
- left: 8,
4987
- right: 8,
4988
- display: "flex",
4989
- gap: .75,
4990
- flexWrap: "wrap",
4991
- alignItems: "center",
4992
- pointerEvents: "none",
4993
- "& > *": { pointerEvents: "auto" }
4994
- },
4995
- children: [
4996
- /* @__PURE__ */ jsx(Button, {
4997
- size: "small",
4998
- variant: "outlined",
4999
- onClick: addTable,
5000
- children: "+ Tabela"
5001
- }),
5002
- /* @__PURE__ */ jsx(Button, {
5003
- size: "small",
5004
- onClick: onLoadColumns,
5005
- disabled: loadingColumns,
5006
- title: "Lê uma linha de cada tabela para listar as colunas",
5007
- sx: { bgcolor: "#1b1e23" },
5008
- children: loadingColumns ? "Lendo colunas…" : "Recarregar colunas"
5009
- }),
5010
- /* @__PURE__ */ jsxs(Box, {
5011
- sx: {
5012
- ml: "auto",
5013
- display: "flex",
5014
- gap: .5,
5015
- alignItems: "center"
5016
- },
5017
- children: [
5018
- /* @__PURE__ */ jsx(Button, {
5019
- size: "small",
5020
- onClick: () => {
5021
- const layout = autoLayout(nodes, sizes);
5022
- setPositions((current) => Object.fromEntries(Object.entries(layout).map(([id, point]) => [id, {
5023
- ...current[id],
5024
- ...point
5025
- }])));
5026
- fitToScreen(layout);
5027
- },
5028
- title: "Dimensões em cima, fatos embaixo (troca as posições salvas)",
5029
- sx: { bgcolor: "#1b1e23" },
5030
- children: "Organizar"
5031
- }),
5032
- /* @__PURE__ */ jsx(Button, {
5033
- size: "small",
5034
- onClick: () => fitToScreen(),
5035
- sx: { bgcolor: "#1b1e23" },
5036
- children: "Ajustar à tela"
5037
- }),
5038
- /* @__PURE__ */ jsx(IconButton, {
5039
- size: "small",
5040
- "aria-label": "Diminuir zoom",
5041
- onClick: () => setView((current) => ({
5042
- ...current,
5043
- zoom: Math.max(.3, current.zoom - .1)
5044
- })),
5045
- children: "−"
5046
- }),
5047
- /* @__PURE__ */ jsxs(Typography, {
5048
- sx: {
5049
- fontSize: 11,
5050
- fontFamily: MONO,
5051
- width: 36,
5052
- textAlign: "center"
5053
- },
5054
- children: [Math.round(view.zoom * 100), "%"]
5055
- }),
5056
- /* @__PURE__ */ jsx(IconButton, {
5057
- size: "small",
5058
- "aria-label": "Aumentar zoom",
5059
- onClick: () => setView((current) => ({
5060
- ...current,
5061
- zoom: Math.min(2, current.zoom + .1)
5062
- })),
5063
- children: "+"
5064
- })
5065
- ]
5066
- })
5067
- ]
5068
- }),
5069
- !nodes.length && /* @__PURE__ */ jsx(Box, {
5070
- sx: {
5071
- position: "absolute",
5072
- inset: 0,
5073
- display: "flex",
5074
- alignItems: "center",
5075
- justifyContent: "center",
5076
- pointerEvents: "none",
5077
- color: "text.secondary",
5078
- fontSize: 13,
5079
- textAlign: "center",
5080
- px: 4
5925
+ children: "Arraste uma coluna até a coluna de outra tabela para relacionar · +◇/+Σ na coluna cria dimensão/medida · Duplo clique no nome renomeia · Botão direito: editar/remover (no fundo: nova tabela/medida, recarregar, organizar) · Delete remove · Ctrl+roda: zoom"
5926
+ })
5927
+ ]
5928
+ }),
5929
+ /* @__PURE__ */ jsx(Menu, {
5930
+ open: !!menu?.open,
5931
+ onClose: closeMenu,
5932
+ anchorReference: "anchorPosition",
5933
+ anchorPosition: menu ? {
5934
+ top: menu.y,
5935
+ left: menu.x
5936
+ } : void 0,
5937
+ transitionDuration: {
5938
+ enter: 90,
5939
+ exit: 60
5940
+ },
5941
+ onContextMenu: (event) => event.preventDefault(),
5942
+ children: menu?.target ? [/* @__PURE__ */ jsxs(MenuItem, {
5943
+ dense: true,
5944
+ onClick: () => {
5945
+ setSelection(menu.target);
5946
+ closeMenu();
5081
5947
  },
5082
- children: "Modelo vazio. Adicione as tabelas dimensão (cadastros: navio, calendário…) e fato (lançamentos: pesagens, custos…) e arraste a coluna da fato até a chave da dimensão para relacioná-las."
5083
- }),
5084
- !!nodes.length && /* @__PURE__ */ jsx(Typography, {
5085
- sx: {
5086
- position: "absolute",
5087
- left: 10,
5088
- bottom: 6,
5089
- fontSize: 11,
5090
- color: "text.disabled",
5091
- pointerEvents: "none"
5948
+ children: ["Editar ", TARGET_NAME[menu.target.kind]]
5949
+ }, "edit"), /* @__PURE__ */ jsxs(MenuItem, {
5950
+ dense: true,
5951
+ sx: { color: "error.main" },
5952
+ onClick: () => {
5953
+ if (menu.target) remove(menu.target);
5954
+ closeMenu();
5092
5955
  },
5093
- children: "Arraste uma coluna até a coluna de outra tabela para relacionar · +◇/+Σ na coluna cria dimensão/medida · Delete remove · Ctrl+roda: zoom"
5094
- })
5095
- ]
5096
- }), panel.open ? /* @__PURE__ */ jsxs(Box, {
5097
- sx: {
5098
- width: panel.width,
5099
- flexShrink: 0,
5100
- borderLeft: 1,
5101
- borderColor: "divider",
5102
- position: "relative",
5103
- display: "flex",
5104
- flexDirection: "column",
5105
- minHeight: 0
5106
- },
5107
- children: [
5108
- /* @__PURE__ */ jsx(Box, {
5109
- onMouseDown: startPanelResize,
5110
- title: "Arraste para redimensionar",
5111
- sx: {
5112
- position: "absolute",
5113
- left: -3,
5114
- top: 0,
5115
- bottom: 0,
5116
- width: 6,
5117
- cursor: "ew-resize",
5118
- zIndex: 2,
5119
- "&:hover": {
5120
- bgcolor: "primary.main",
5121
- opacity: .4
5956
+ children: ["Remover ", TARGET_NAME[menu.target.kind]]
5957
+ }, "remove")] : [
5958
+ /* @__PURE__ */ jsx(MenuItem, {
5959
+ dense: true,
5960
+ onClick: () => {
5961
+ if (menu) addTable(toWorld(menu.x, menu.y));
5962
+ closeMenu();
5963
+ },
5964
+ children: "Nova Tabela"
5965
+ }, "table"),
5966
+ /* @__PURE__ */ jsx(MenuItem, {
5967
+ dense: true,
5968
+ onClick: () => {
5969
+ addFormula();
5970
+ closeMenu();
5971
+ },
5972
+ children: "Nova Medida Calculada"
5973
+ }, "formula"),
5974
+ /* @__PURE__ */ jsx(Divider, {}, "divider"),
5975
+ /* @__PURE__ */ jsx(MenuItem, {
5976
+ dense: true,
5977
+ disabled: loadingColumns,
5978
+ onClick: () => {
5979
+ onLoadColumns();
5980
+ closeMenu();
5981
+ },
5982
+ children: "Recarregar colunas"
5983
+ }, "columns"),
5984
+ /* @__PURE__ */ jsx(MenuItem, {
5985
+ dense: true,
5986
+ onClick: () => {
5987
+ organize();
5988
+ closeMenu();
5989
+ },
5990
+ children: "Organizar"
5991
+ }, "organize")
5992
+ ]
5993
+ }),
5994
+ panel.open ? /* @__PURE__ */ jsxs(Box, {
5995
+ sx: {
5996
+ width: panel.width,
5997
+ flexShrink: 0,
5998
+ borderLeft: 1,
5999
+ borderColor: "divider",
6000
+ position: "relative",
6001
+ display: "flex",
6002
+ flexDirection: "column",
6003
+ minHeight: 0
6004
+ },
6005
+ children: [
6006
+ /* @__PURE__ */ jsx(Box, {
6007
+ onMouseDown: startPanelResize,
6008
+ title: "Arraste para redimensionar",
6009
+ sx: {
6010
+ position: "absolute",
6011
+ left: -3,
6012
+ top: 0,
6013
+ bottom: 0,
6014
+ width: 6,
6015
+ cursor: "ew-resize",
6016
+ zIndex: 2,
6017
+ "&:hover": {
6018
+ bgcolor: "primary.main",
6019
+ opacity: .4
6020
+ }
5122
6021
  }
5123
- }
5124
- }),
5125
- /* @__PURE__ */ jsxs(Box, {
5126
- sx: {
5127
- display: "flex",
5128
- alignItems: "center",
5129
- px: 1.5,
5130
- py: .5,
5131
- borderBottom: 1,
5132
- borderColor: "divider"
5133
- },
5134
- children: [/* @__PURE__ */ jsx(Typography, {
6022
+ }),
6023
+ /* @__PURE__ */ jsxs(Box, {
5135
6024
  sx: {
5136
- fontSize: 12,
5137
- fontWeight: 700,
5138
- color: "text.secondary",
5139
- flex: 1
6025
+ display: "flex",
6026
+ alignItems: "center",
6027
+ px: 1.5,
6028
+ py: .5,
6029
+ borderBottom: 1,
6030
+ borderColor: "divider"
5140
6031
  },
5141
- children: "Propriedades"
5142
- }), /* @__PURE__ */ jsx(IconButton, {
5143
- size: "small",
5144
- "aria-label": "Recolher propriedades",
5145
- title: "Recolher",
5146
- onClick: () => setPanel((current) => ({
5147
- ...current,
5148
- open: false
5149
- })),
5150
- children: "⟩"
5151
- })]
5152
- }),
5153
- /* @__PURE__ */ jsx(Box, {
5154
- sx: {
5155
- overflow: "auto",
5156
- flex: 1,
5157
- p: 1.5
5158
- },
5159
- children: /* @__PURE__ */ jsx(Properties, {
5160
- editor,
5161
- selection,
5162
- columns,
5163
- errors,
5164
- warnings,
5165
- usage,
5166
- onSelect: setSelection,
5167
- onTry,
5168
- setEditor,
5169
- patchTable,
5170
- patchRelationship,
5171
- patchDimension,
5172
- patchMeasure,
5173
- removeTable,
5174
- commitRename,
5175
- addField,
5176
- unplaced
6032
+ children: [/* @__PURE__ */ jsx(Typography, {
6033
+ sx: {
6034
+ fontSize: 12,
6035
+ fontWeight: 700,
6036
+ color: "text.secondary",
6037
+ flex: 1
6038
+ },
6039
+ children: "Propriedades"
6040
+ }), /* @__PURE__ */ jsx(IconButton, {
6041
+ size: "small",
6042
+ "aria-label": "Recolher propriedades",
6043
+ title: "Recolher",
6044
+ onClick: () => setPanel((current) => ({
6045
+ ...current,
6046
+ open: false
6047
+ })),
6048
+ children: "⟩"
6049
+ })]
6050
+ }),
6051
+ /* @__PURE__ */ jsx(Box, {
6052
+ sx: {
6053
+ overflow: "auto",
6054
+ flex: 1,
6055
+ p: 1.5
6056
+ },
6057
+ children: /* @__PURE__ */ jsx(Properties, {
6058
+ editor,
6059
+ selection,
6060
+ columns,
6061
+ errors,
6062
+ warnings,
6063
+ usage,
6064
+ onSelect: setSelection,
6065
+ onTry,
6066
+ setEditor,
6067
+ patchTable,
6068
+ patchRelationship,
6069
+ patchDimension,
6070
+ patchMeasure,
6071
+ removeTable,
6072
+ commitRename,
6073
+ addField,
6074
+ unplaced
6075
+ })
5177
6076
  })
5178
- })
5179
- ]
5180
- }) : /* @__PURE__ */ jsx(Box, {
5181
- component: "button",
5182
- type: "button",
5183
- title: "Mostrar as propriedades",
5184
- onClick: () => setPanel((current) => ({
5185
- ...current,
5186
- open: true
5187
- })),
5188
- sx: {
5189
- width: 28,
5190
- flexShrink: 0,
5191
- border: 0,
5192
- borderLeft: 1,
5193
- borderColor: "divider",
5194
- bgcolor: "background.paper",
5195
- color: "text.secondary",
5196
- cursor: "pointer",
5197
- writingMode: "vertical-rl",
5198
- fontSize: 12,
5199
- py: 1,
5200
- "&:hover": { color: "primary.main" }
5201
- },
5202
- children: "⟨ Propriedades"
5203
- })]
6077
+ ]
6078
+ }) : /* @__PURE__ */ jsx(Box, {
6079
+ component: "button",
6080
+ type: "button",
6081
+ title: "Mostrar as propriedades",
6082
+ onClick: () => setPanel((current) => ({
6083
+ ...current,
6084
+ open: true
6085
+ })),
6086
+ sx: {
6087
+ width: 28,
6088
+ flexShrink: 0,
6089
+ border: 0,
6090
+ borderLeft: 1,
6091
+ borderColor: "divider",
6092
+ bgcolor: "background.paper",
6093
+ color: "text.secondary",
6094
+ cursor: "pointer",
6095
+ writingMode: "vertical-rl",
6096
+ fontSize: 12,
6097
+ py: 1,
6098
+ "&:hover": { color: "primary.main" }
6099
+ },
6100
+ children: "⟨ Propriedades"
6101
+ })
6102
+ ]
5204
6103
  });
5205
6104
  }
5206
6105
  /** Curva com tangentes horizontais (saída e chegada pelas laterais dos cards). */
@@ -5269,11 +6168,15 @@ function RelationshipLine({ line, hovered, onHover }) {
5269
6168
  ]
5270
6169
  });
5271
6170
  }
5272
- function TableCard({ node, position, size, collapsed, selected, linking, selectedField, onToggle, onScroll, onHeaderDown, onResizeDown, onResizeReset, onColumnDown, onFieldClick, onAddField }) {
6171
+ function TableCard({ node, position, size, collapsed, selected, linking, selectedField, onToggle, onScroll, onHeaderDown, onResizeDown, onResizeReset, onColumnDown, onFieldClick, onAddField, renaming, onRenameStart, onRename, onContextMenu }) {
5273
6172
  const style = ROLE_STYLE[node.role];
5274
6173
  const columnActions = ["dimension", "measure"];
5275
6174
  return /* @__PURE__ */ jsxs(Box, {
5276
6175
  onMouseDown: (event) => event.stopPropagation(),
6176
+ onContextMenu: (event) => onContextMenu(event, {
6177
+ kind: "table",
6178
+ uid: node.uid
6179
+ }),
5277
6180
  sx: {
5278
6181
  position: "absolute",
5279
6182
  left: position.x,
@@ -5314,7 +6217,12 @@ function TableCard({ node, position, size, collapsed, selected, linking, selecte
5314
6217
  },
5315
6218
  children: "▦"
5316
6219
  }),
5317
- /* @__PURE__ */ jsx(Typography, {
6220
+ renaming ? /* @__PURE__ */ jsx(TableNameInput, {
6221
+ value: node.name,
6222
+ onDone: onRename
6223
+ }) : /* @__PURE__ */ jsx(Typography, {
6224
+ onDoubleClick: onRenameStart,
6225
+ title: "Duplo clique para renomear",
5318
6226
  sx: {
5319
6227
  fontWeight: 700,
5320
6228
  fontSize: 13,
@@ -5442,6 +6350,10 @@ function TableCard({ node, position, size, collapsed, selected, linking, selecte
5442
6350
  }, `c:${item.name}`) : /* @__PURE__ */ jsxs(Box, {
5443
6351
  onMouseDown: (event) => event.stopPropagation(),
5444
6352
  onClick: () => onFieldClick(item),
6353
+ onContextMenu: (event) => onContextMenu(event, {
6354
+ kind: item.kind,
6355
+ uid: item.uid
6356
+ }),
5445
6357
  sx: {
5446
6358
  height: ROW,
5447
6359
  display: "flex",
@@ -5537,23 +6449,60 @@ const resizeHandleSx = {
5537
6449
  position: "absolute",
5538
6450
  zIndex: 1
5539
6451
  };
5540
- function PropertyBlock({ title, children }) {
5541
- return /* @__PURE__ */ jsxs(Box, {
5542
- sx: {
5543
- display: "flex",
5544
- flexDirection: "column",
5545
- gap: 1,
5546
- mb: 2
6452
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
6453
+ /** Nome da tabela editado no cabeçalho do card: Enter/sair confirma, Esc cancela. */
6454
+ function TableNameInput({ value, onDone }) {
6455
+ const [text, setText] = useState(value);
6456
+ const valid = IDENTIFIER.test(text);
6457
+ const done = useRef(false);
6458
+ const finish = (name) => {
6459
+ if (done.current) return;
6460
+ done.current = true;
6461
+ onDone(name);
6462
+ };
6463
+ return /* @__PURE__ */ jsx(Box, {
6464
+ component: "input",
6465
+ autoFocus: true,
6466
+ value: text,
6467
+ "aria-label": "Nome da tabela",
6468
+ onFocus: (event) => event.currentTarget.select(),
6469
+ onChange: (event) => setText(event.target.value),
6470
+ onMouseDown: (event) => event.stopPropagation(),
6471
+ onKeyDown: (event) => {
6472
+ event.stopPropagation();
6473
+ if (event.key === "Enter") finish(valid ? text : null);
6474
+ if (event.key === "Escape") finish(null);
5547
6475
  },
5548
- children: [/* @__PURE__ */ jsx(Typography, {
5549
- variant: "overline",
6476
+ onBlur: () => finish(valid ? text : null),
6477
+ sx: {
6478
+ flex: 1,
6479
+ minWidth: 0,
6480
+ font: "inherit",
6481
+ fontSize: 13,
6482
+ fontWeight: 700,
6483
+ color: "text.primary",
6484
+ bgcolor: "#1b1e23",
6485
+ border: "1px solid",
6486
+ borderColor: valid ? "primary.main" : "error.main",
6487
+ borderRadius: .5,
6488
+ px: .5,
6489
+ outline: "none"
6490
+ }
6491
+ });
6492
+ }
6493
+ /** Bloco do painel; com `collapseKey`, o título recolhe/expande (lembrado no navegador). */
6494
+ function PropertyBlock({ title, collapseKey, children }) {
6495
+ return /* @__PURE__ */ jsx(Section, {
6496
+ title,
6497
+ collapseKey,
6498
+ children: /* @__PURE__ */ jsx(Box, {
5550
6499
  sx: {
5551
- color: "text.secondary",
5552
- fontWeight: 700,
5553
- lineHeight: 1.5
6500
+ display: "flex",
6501
+ flexDirection: "column",
6502
+ gap: 1
5554
6503
  },
5555
- children: title
5556
- }), children]
6504
+ children
6505
+ })
5557
6506
  });
5558
6507
  }
5559
6508
  function ColumnSelect({ label, value, options, onChange }) {
@@ -5617,29 +6566,23 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5617
6566
  return /* @__PURE__ */ jsxs(Fragment, { children: [
5618
6567
  /* @__PURE__ */ jsxs(PropertyBlock, {
5619
6568
  title: "Tabela",
5620
- children: [
5621
- /* @__PURE__ */ jsx(RenameField, {
5622
- value: table.name,
5623
- onCommit: (name) => commitRename(table.uid, name)
5624
- }, table.uid),
5625
- /* @__PURE__ */ jsx(ColumnSelect, {
5626
- label: "chave (key)",
5627
- value: table.key,
5628
- options: columnsOf(table.uid).length ? ["", ...columnsOf(table.uid)] : [],
5629
- onChange: (key) => patchTable(table.uid, { key })
5630
- }),
5631
- /* @__PURE__ */ jsx(SqlEditor, {
5632
- label: "sql",
5633
- minRows: 3,
5634
- maxRows: 12,
5635
- value: table.sql,
5636
- error: errors[`table:${table.name}`],
5637
- onChange: (sql) => patchTable(table.uid, { sql })
5638
- })
5639
- ]
6569
+ collapseKey: "diagram:table",
6570
+ children: [/* @__PURE__ */ jsx(RenameField, {
6571
+ value: table.name,
6572
+ onCommit: (name) => commitRename(table.uid, name)
6573
+ }, table.uid), /* @__PURE__ */ jsx(SqlEditor, {
6574
+ label: "sql",
6575
+ minRows: 3,
6576
+ maxRows: 12,
6577
+ value: table.sql,
6578
+ error: errors[`table:${table.name}`],
6579
+ onChange: (sql) => patchTable(table.uid, { sql }),
6580
+ queryTools: true
6581
+ })]
5640
6582
  }),
5641
6583
  /* @__PURE__ */ jsx(PropertyBlock, {
5642
6584
  title: "Dimensões (marque as colunas)",
6585
+ collapseKey: "diagram:dimensions",
5643
6586
  children: /* @__PURE__ */ jsx(DimensionPicker, {
5644
6587
  editor,
5645
6588
  setEditor,
@@ -5653,6 +6596,7 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5653
6596
  }),
5654
6597
  /* @__PURE__ */ jsx(PropertyBlock, {
5655
6598
  title: "Dicionário",
6599
+ collapseKey: "diagram:dictionary",
5656
6600
  children: /* @__PURE__ */ jsx(DictionaryFields, {
5657
6601
  row: table,
5658
6602
  onPatch: (changes) => patchTable(table.uid, changes)
@@ -5839,6 +6783,7 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5839
6783
  }),
5840
6784
  /* @__PURE__ */ jsx(PropertyBlock, {
5841
6785
  title: "Dicionário",
6786
+ collapseKey: "diagram:dictionary",
5842
6787
  children: /* @__PURE__ */ jsx(DictionaryFields, {
5843
6788
  row,
5844
6789
  onPatch: (changes) => patchDimension(row.uid, changes)
@@ -5891,6 +6836,7 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5891
6836
  }),
5892
6837
  /* @__PURE__ */ jsx(PropertyBlock, {
5893
6838
  title: "Dicionário",
6839
+ collapseKey: "diagram:dictionary",
5894
6840
  children: /* @__PURE__ */ jsx(DictionaryFields, {
5895
6841
  row,
5896
6842
  onPatch: (changes) => patchMeasure(row.uid, changes)
@@ -5917,6 +6863,7 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5917
6863
  return /* @__PURE__ */ jsxs(Fragment, { children: [
5918
6864
  /* @__PURE__ */ jsx(PropertyBlock, {
5919
6865
  title: "Modelo",
6866
+ collapseKey: "diagram:model",
5920
6867
  children: /* @__PURE__ */ jsx(Typography, {
5921
6868
  sx: {
5922
6869
  fontSize: 12,
@@ -5927,6 +6874,7 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5927
6874
  }),
5928
6875
  /* @__PURE__ */ jsxs(PropertyBlock, {
5929
6876
  title: "Relacionamentos",
6877
+ collapseKey: "diagram:relationships",
5930
6878
  children: [!editor.relationships.length && /* @__PURE__ */ jsx(Typography, {
5931
6879
  sx: {
5932
6880
  fontSize: 12,
@@ -5995,6 +6943,7 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
5995
6943
  }),
5996
6944
  /* @__PURE__ */ jsxs(PropertyBlock, {
5997
6945
  title: "Medidas calculadas",
6946
+ collapseKey: "diagram:formulas",
5998
6947
  children: [formulas.map((row) => /* @__PURE__ */ jsxs(Button, {
5999
6948
  size: "small",
6000
6949
  onClick: () => onSelect({
@@ -6012,22 +6961,10 @@ function Properties({ editor, selection, columns, errors, warnings, usage, onSel
6012
6961
  ]
6013
6962
  }, row.uid)), /* @__PURE__ */ jsx(Button, {
6014
6963
  size: "small",
6015
- onClick: () => {
6016
- const uid = newUid();
6017
- const taken = [...editor.dimensions, ...editor.measures].map((item) => item.name);
6018
- setEditor((current) => ({
6019
- ...current,
6020
- measures: [...current.measures, emptyMeasure({
6021
- uid,
6022
- type: "formula",
6023
- name: freeName("novaMedida", taken)
6024
- })]
6025
- }));
6026
- onSelect({
6027
- kind: "measure",
6028
- uid
6029
- });
6030
- },
6964
+ onClick: () => onSelect({
6965
+ kind: "measure",
6966
+ uid: addFormulaTo(setEditor)
6967
+ }),
6031
6968
  children: "+ Medida calculada"
6032
6969
  })]
6033
6970
  }),
@@ -6050,7 +6987,7 @@ function RenameField({ value, onCommit }) {
6050
6987
  value: text,
6051
6988
  onChange: setText,
6052
6989
  onBlur: () => {
6053
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text)) onCommit(text);
6990
+ if (IDENTIFIER.test(text)) onCommit(text);
6054
6991
  else setText(value);
6055
6992
  }
6056
6993
  });
@@ -6228,11 +7165,22 @@ function ModelPanel({ devtools, original, capabilities, tables, onTry, onSaved }
6228
7165
  });
6229
7166
  const completions = useMemo(() => ({
6230
7167
  tables,
6231
- modelColumns: Object.fromEntries(editor.tables.map((row) => [row.name, columns?.tables[row.name] ?? []]))
7168
+ modelColumns: Object.fromEntries(editor.tables.map((row) => [row.name, columns?.tables[row.name] ?? []])),
7169
+ modelSql: Object.fromEntries(editor.tables.map((row) => [row.name, row.sql])),
7170
+ testSql: async (sql) => {
7171
+ const result = await fetcher({
7172
+ model: modelName,
7173
+ devtools: { testSql: sql }
7174
+ });
7175
+ if (!result.devtools?.sqlTest) throw new Error("O servidor não devolveu o teste do SQL");
7176
+ return result.devtools.sqlTest;
7177
+ }
6232
7178
  }), [
6233
7179
  tables,
6234
7180
  columns,
6235
- editor.tables
7181
+ editor.tables,
7182
+ fetcher,
7183
+ modelName
6236
7184
  ]);
6237
7185
  const validationErrors = validation ? Object.entries(validation.errors) : [];
6238
7186
  return /* @__PURE__ */ jsxs(SqlCompletionsContext.Provider, {
@@ -8381,4 +9329,4 @@ function ToggleButton$1({ draft, loading, problems, inspecting, onOpen }) {
8381
9329
  //#endregion
8382
9330
  export { DashboardDevtools as default };
8383
9331
 
8384
- //# sourceMappingURL=DashboardDevtools-I00Iz0Z8.js.map
9332
+ //# sourceMappingURL=DashboardDevtools-wk4vMfhy.js.map