@12-apps/ui 1.5.2 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
  3. package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
  4. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
  5. package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
  6. package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
  7. package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
  8. package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
  9. package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
  10. package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
  11. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
  12. package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
  13. package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
  14. package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
  15. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
  16. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
  17. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
  18. package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
  20. package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
  21. package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
  22. package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
  23. package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
  24. package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
  25. package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
  26. package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
  27. package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
  28. package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
  30. package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
  31. package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
  32. package/src/components/data-display/DataViews/data-views-types.ts +88 -9
  33. package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
  34. package/src/components/data-display/DataViews/index.ts +14 -1
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
  36. package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
  38. package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
  39. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
  40. package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
  41. package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
  42. package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
  43. package/src/components/utility/InstallPrompt/index.ts +8 -0
  44. package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import FilterAltOffRoundedIcon from "@mui/icons-material/FilterAltOffRounded";
4
+ import InboxRoundedIcon from "@mui/icons-material/InboxRounded";
5
+
6
+ import { Button } from "../../form/Button";
7
+ import { Box } from "../../../mui/Box";
8
+ import { Text } from "../../typography/Text";
9
+
10
+ /**
11
+ * TWO EMPTY STATES, NOT ONE.
12
+ *
13
+ * "Nenhum pagamento registrado" under an active filter is a lie, and it is the
14
+ * expensive kind: the operator reads it as "this store has no payments" and
15
+ * goes looking for the bug somewhere else. The page's own empty copy answers
16
+ * "there is nothing here yet"; it cannot answer "nothing matches what you
17
+ * typed", because it does not know what was typed.
18
+ *
19
+ * So the grid renders the FILTERED variant itself — it is the only party that
20
+ * knows a filter is applied — and defers to the host's `emptyState` only when
21
+ * the list is genuinely empty. The filtered one carries the way out, which is
22
+ * the whole point: the fix is one click, and it is right there.
23
+ */
24
+ export function DataViewsEmpty({
25
+ filtered,
26
+ onClearFilters,
27
+ emptyState,
28
+ testIdPrefix,
29
+ }: {
30
+ /** Is anything narrowing the list right now (search, pills or ranges)? */
31
+ filtered: boolean;
32
+ onClearFilters: () => void;
33
+ /** The page's own copy for a genuinely empty list. */
34
+ emptyState?: React.ReactNode;
35
+ testIdPrefix: string;
36
+ }): React.JSX.Element {
37
+ const Icon = filtered ? FilterAltOffRoundedIcon : InboxRoundedIcon;
38
+ return (
39
+ <Box
40
+ data-testid={`${testIdPrefix}-empty${filtered ? "-filtered" : ""}`}
41
+ sx={{
42
+ display: "flex",
43
+ flexDirection: "column",
44
+ alignItems: "center",
45
+ textAlign: "center",
46
+ py: 8,
47
+ px: 2,
48
+ }}
49
+ >
50
+ <Box
51
+ sx={{
52
+ display: "inline-flex",
53
+ p: 2,
54
+ mb: 1.5,
55
+ borderRadius: "50%",
56
+ bgcolor: "action.hover",
57
+ color: "text.disabled",
58
+ }}
59
+ >
60
+ <Icon />
61
+ </Box>
62
+ {filtered ? (
63
+ <>
64
+ <Text variant="body" as="p">
65
+ <Box component="span" sx={{ fontWeight: 600 }}>
66
+ Nenhum resultado para esses filtros
67
+ </Box>
68
+ </Text>
69
+ <Text variant="caption" as="p">
70
+ <Box component="span" sx={{ display: "block", mt: 0.5, mb: 2, color: "text.secondary", maxWidth: 380 }}>
71
+ Ajuste ou remova os filtros.
72
+ </Box>
73
+ </Text>
74
+ <Button
75
+ variant="outline"
76
+ size="sm"
77
+ color="secondary"
78
+ onClick={onClearFilters}
79
+ dataTestId={`${testIdPrefix}-empty-clear`}
80
+ >
81
+ Limpar filtros
82
+ </Button>
83
+ </>
84
+ ) : (
85
+ // The page's own words for "nothing here yet" — it knows what the
86
+ // entity is and how one comes to exist; this component does not.
87
+ emptyState
88
+ )}
89
+ </Box>
90
+ );
91
+ }
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
4
+ import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
5
+ import { CircularProgress, Popover } from "@mui/material";
6
+ import { useState } from "react";
7
+
8
+ import { Button } from "../../form/Button";
9
+ import { Box } from "../../../mui/Box";
10
+ import { Text } from "../../typography/Text";
11
+
12
+ import type { DataViewQuery } from "./data-views-types";
13
+
14
+ /**
15
+ * EXPORT — the current QUERY, never the rendered page.
16
+ *
17
+ * Exporting what happens to be on screen is the single most common bug in an
18
+ * admin table: the operator filtered 214 records, sees 25, exports, and gets
19
+ * 25 — with nothing anywhere saying so. So this control does not touch the
20
+ * loaded rows at all. It hands the host the same `DataViewQuery` the grid is
21
+ * showing, unpaginated, and the HOST re-queries the backend — the same
22
+ * division as everywhere else here: the component emits, the host fetches.
23
+ *
24
+ * The menu states the number it is about to export before the click, so the
25
+ * two possible answers (the selection, or the whole filtered set) are never a
26
+ * guess.
27
+ */
28
+
29
+ /** What a host can be asked to produce. */
30
+ export type DataViewExportFormat = "xlsx" | "csv" | "json";
31
+
32
+ /** The request handed to the host when the operator picks a format. */
33
+ export interface DataViewExportRequest {
34
+ format: DataViewExportFormat;
35
+ /**
36
+ * The live query with `page: 1` and `pageSize` set to the whole matched
37
+ * total — i.e. "everything this filter selects", not this page.
38
+ */
39
+ query: DataViewQuery;
40
+ /**
41
+ * The selected row ids, when the operator has a selection. The host filters
42
+ * the re-queried rows to these; absent means the whole filtered set.
43
+ */
44
+ selectedIds?: Array<string | number>;
45
+ /** The visible columns, in the operator's current order. */
46
+ columns: { id: string; label: string }[];
47
+ }
48
+
49
+ /** Injected export. Absent ⇒ no Exportar control, exactly like `renderCard`. */
50
+ export interface DataViewExport {
51
+ /** Runs the export. Awaited, so the trigger can show it is working. */
52
+ onExport: (request: DataViewExportRequest) => Promise<void> | void;
53
+ /** Formats to offer, in menu order. Defaults to all three. */
54
+ formats?: DataViewExportFormat[];
55
+ }
56
+
57
+ /** One line per format: what it is, and when to reach for it. */
58
+ const FORMAT_COPY: Record<DataViewExportFormat, { label: string; hint: string }> = {
59
+ xlsx: { label: "Excel (.xlsx)", hint: "Abre direto, com as colunas dimensionadas" },
60
+ csv: { label: "CSV (.csv)", hint: "Para importar em outro sistema" },
61
+ json: { label: "JSON (.json)", hint: "Para integrações" },
62
+ };
63
+
64
+ const ALL_FORMATS: DataViewExportFormat[] = ["xlsx", "csv", "json"];
65
+
66
+ interface ExportMenuProps {
67
+ config: DataViewExport;
68
+ /** The live query — copied and unpaginated before it reaches the host. */
69
+ query: DataViewQuery;
70
+ /** The unpaginated total, so the label can state what "tudo" means. */
71
+ totalCount: number;
72
+ selectedIds: Array<string | number>;
73
+ columns: { id: string; label: string }[];
74
+ testIdPrefix: string;
75
+ /** Render icon-only (measured upstream). */
76
+ compact?: boolean;
77
+ }
78
+
79
+ /** "Exportando 214 itens filtrados" / "3 itens selecionados" — never a guess. */
80
+ function scopeLabel(selected: number, total: number): string {
81
+ if (selected > 0) {
82
+ return `${selected} ${selected === 1 ? "item selecionado" : "itens selecionados"}`;
83
+ }
84
+ return `${total} ${total === 1 ? "item filtrado" : "itens filtrados"}`;
85
+ }
86
+
87
+ /** The trigger. Shows a spinner while the host's export is still running. */
88
+ function ExportTrigger({
89
+ busy,
90
+ onOpen,
91
+ testIdPrefix,
92
+ compact,
93
+ }: {
94
+ busy: boolean;
95
+ onOpen: (anchor: HTMLElement) => void;
96
+ testIdPrefix: string;
97
+ /** Icon only — step 2 of the toolbar's degradation ladder. */
98
+ compact?: boolean;
99
+ }): React.JSX.Element {
100
+ return (
101
+ <Button
102
+ variant="outline"
103
+ size="sm"
104
+ color="neutral"
105
+ disabled={busy}
106
+ onClick={(event) => onOpen(event.currentTarget as HTMLElement)}
107
+ dataTestId={`${testIdPrefix}-export-trigger`}
108
+ aria-label="Exportar"
109
+ // The label is gone, so the tooltip is the only thing naming the control.
110
+ title={compact ? "Exportar" : undefined}
111
+ >
112
+ <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
113
+ {busy ? <CircularProgress size={14} /> : <DownloadRoundedIcon fontSize="small" />}
114
+ {!compact && (
115
+ <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
116
+ Exportar
117
+ </Box>
118
+ )}
119
+ <KeyboardArrowDownRoundedIcon fontSize="small" />
120
+ </Box>
121
+ </Button>
122
+ );
123
+ }
124
+
125
+ /** One format row: what it produces, and when to reach for it. */
126
+ function FormatRow({
127
+ format,
128
+ onPick,
129
+ testIdPrefix,
130
+ }: {
131
+ format: DataViewExportFormat;
132
+ onPick: () => void;
133
+ testIdPrefix: string;
134
+ }): React.JSX.Element {
135
+ return (
136
+ <Box
137
+ component="button"
138
+ type="button"
139
+ onClick={onPick}
140
+ data-testid={`${testIdPrefix}-export-${format}`}
141
+ sx={{
142
+ display: "flex",
143
+ flexDirection: "column",
144
+ alignItems: "flex-start",
145
+ width: "100%",
146
+ px: 1.5,
147
+ py: 1,
148
+ border: 0,
149
+ background: "none",
150
+ cursor: "pointer",
151
+ font: "inherit",
152
+ textAlign: "left",
153
+ "&:hover": { bgcolor: "action.hover" },
154
+ }}
155
+ >
156
+ <Box component="span" sx={{ fontSize: "0.8125rem" }}>
157
+ {FORMAT_COPY[format].label}
158
+ </Box>
159
+ <Box component="span" sx={{ fontSize: "0.6875rem", color: "text.disabled" }}>
160
+ {FORMAT_COPY[format].hint}
161
+ </Box>
162
+ </Box>
163
+ );
164
+ }
165
+
166
+ /** The panel: what is about to be exported, then the formats. */
167
+ function ExportPanel({
168
+ formats,
169
+ selectedCount,
170
+ totalCount,
171
+ columnCount,
172
+ onPick,
173
+ testIdPrefix,
174
+ }: {
175
+ formats: DataViewExportFormat[];
176
+ selectedCount: number;
177
+ totalCount: number;
178
+ columnCount: number;
179
+ onPick: (format: DataViewExportFormat) => void;
180
+ testIdPrefix: string;
181
+ }): React.JSX.Element {
182
+ return (
183
+ <Box data-testid={`${testIdPrefix}-export-panel`}>
184
+ <Box sx={{ px: 1.5, pt: 1, pb: 1, borderBottom: 1, borderColor: "divider" }}>
185
+ <Text variant="caption" as="p">
186
+ <Box component="span" data-testid={`${testIdPrefix}-export-scope`}>
187
+ Exportando {scopeLabel(selectedCount, totalCount)}
188
+ </Box>
189
+ </Text>
190
+ <Text variant="caption" as="p">
191
+ <Box component="span" sx={{ color: "text.disabled" }}>
192
+ {columnCount} colunas visíveis, na ordem atual
193
+ </Box>
194
+ </Text>
195
+ </Box>
196
+ {formats.map((format) => (
197
+ <FormatRow
198
+ key={format}
199
+ format={format}
200
+ onPick={() => onPick(format)}
201
+ testIdPrefix={testIdPrefix}
202
+ />
203
+ ))}
204
+ </Box>
205
+ );
206
+ }
207
+
208
+ /** The toolbar's Exportar control. Renders nothing when no host wired it. */
209
+ export function DataViewsExportMenu({
210
+ config,
211
+ query,
212
+ totalCount,
213
+ selectedIds,
214
+ columns,
215
+ testIdPrefix,
216
+ compact,
217
+ }: ExportMenuProps): React.JSX.Element {
218
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
219
+ const [busy, setBusy] = useState(false);
220
+
221
+ const run = async (format: DataViewExportFormat): Promise<void> => {
222
+ setAnchor(null);
223
+ setBusy(true);
224
+ try {
225
+ await config.onExport({
226
+ format,
227
+ // UNPAGINATED: page 1 of a page as large as the whole result. The host
228
+ // re-queries with this; nothing here reads the loaded rows.
229
+ query: { ...query, page: 1, pageSize: Math.max(1, totalCount) },
230
+ selectedIds: selectedIds.length > 0 ? [...selectedIds] : undefined,
231
+ columns,
232
+ });
233
+ } finally {
234
+ setBusy(false);
235
+ }
236
+ };
237
+
238
+ return (
239
+ <>
240
+ <ExportTrigger busy={busy} onOpen={setAnchor} testIdPrefix={testIdPrefix} compact={compact} />
241
+ <Popover
242
+ open={Boolean(anchor)}
243
+ anchorEl={anchor}
244
+ onClose={() => setAnchor(null)}
245
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
246
+ transformOrigin={{ vertical: "top", horizontal: "right" }}
247
+ slotProps={{ paper: { sx: { width: 260, maxWidth: "calc(100vw - 32px)" } } }}
248
+ >
249
+ <ExportPanel
250
+ formats={config.formats ?? ALL_FORMATS}
251
+ selectedCount={selectedIds.length}
252
+ totalCount={totalCount}
253
+ columnCount={columns.length}
254
+ onPick={(format) => void run(format)}
255
+ testIdPrefix={testIdPrefix}
256
+ />
257
+ </Popover>
258
+ </>
259
+ );
260
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { Box } from "../../../mui/Box";
4
+
5
+ /**
6
+ * Miniature previews of what a layout or a density setting DOES.
7
+ *
8
+ * "Baixa / Média / Alta" tells an operator nothing until they try it, and
9
+ * "Grade" vs "Lista" is only obvious to whoever named them. A glyph that draws
10
+ * three tight rows, or three loose ones, or a grid of blocks, answers the
11
+ * question before the click — so the picker is read once instead of cycled
12
+ * through.
13
+ *
14
+ * Deliberately CSS boxes rather than icons: the point is to show spacing and
15
+ * proportion, which is exactly what an icon font flattens away.
16
+ */
17
+
18
+ /** The glyph's own footprint, so every tile in a picker lines up. */
19
+ const FRAME = { width: 32, height: 24, display: "flex" } as const;
20
+
21
+ /** Active tiles use the accent; the rest stay muted so the choice reads at a glance. */
22
+ function barColor(active: boolean): string {
23
+ return active ? "primary.main" : "action.disabled";
24
+ }
25
+
26
+ /** Three rows at a given gap — the density preview for the table and list. */
27
+ export function RowsGlyph({ gap, active }: { gap: number; active: boolean }): React.JSX.Element {
28
+ return (
29
+ <Box sx={{ ...FRAME, flexDirection: "column", justifyContent: "center", gap: `${gap}px` }}>
30
+ {[0, 1, 2].map((index) => (
31
+ <Box key={index} sx={{ height: 2, width: "100%", borderRadius: 1, bgcolor: barColor(active) }} />
32
+ ))}
33
+ </Box>
34
+ );
35
+ }
36
+
37
+ /** A dense block grid — the table layout. */
38
+ export function TableGlyph({ active }: { active: boolean }): React.JSX.Element {
39
+ return (
40
+ <Box sx={{ ...FRAME, display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "2px" }}>
41
+ {Array.from({ length: 9 }).map((_, index) => (
42
+ <Box
43
+ key={index}
44
+ sx={{ borderRadius: 0.5, bgcolor: barColor(active), opacity: index < 3 ? 1 : 0.55 }}
45
+ />
46
+ ))}
47
+ </Box>
48
+ );
49
+ }
50
+
51
+ /** Full-width rows each carrying a leading mark — the list layout. */
52
+ export function ListGlyph({ active }: { active: boolean }): React.JSX.Element {
53
+ return (
54
+ <Box sx={{ ...FRAME, flexDirection: "column", justifyContent: "center", gap: "4px" }}>
55
+ {[0, 1, 2].map((index) => (
56
+ <Box
57
+ key={index}
58
+ sx={{
59
+ height: 5,
60
+ width: "100%",
61
+ borderRadius: 0.5,
62
+ bgcolor: barColor(active),
63
+ opacity: 0.7,
64
+ display: "flex",
65
+ alignItems: "center",
66
+ }}
67
+ >
68
+ <Box sx={{ ml: "2px", height: 3, width: 3, borderRadius: "50%", bgcolor: "background.paper" }} />
69
+ </Box>
70
+ ))}
71
+ </Box>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * A grid of blocks — the cards layout, and ALSO the cards density preview: `n`
77
+ * is how many fit per row, so "Muitos / Médio / Poucos" is shown rather than
78
+ * described.
79
+ */
80
+ export function GridGlyph({ n = 3, active }: { n?: number; active: boolean }): React.JSX.Element {
81
+ return (
82
+ <Box sx={{ ...FRAME, display: "grid", gridTemplateColumns: `repeat(${n}, 1fr)`, gap: "2px" }}>
83
+ {Array.from({ length: n * 2 }).map((_, index) => (
84
+ <Box key={index} sx={{ borderRadius: 0.5, bgcolor: barColor(active) }} />
85
+ ))}
86
+ </Box>
87
+ );
88
+ }
89
+
90
+ /** Three columns of decreasing height — the board. */
91
+ export function BoardGlyph({ active }: { active: boolean }): React.JSX.Element {
92
+ return (
93
+ <Box sx={{ ...FRAME, gap: "2px" }}>
94
+ {[3, 2, 1].map((count, column) => (
95
+ <Box key={column} sx={{ flex: 1, display: "flex", flexDirection: "column", gap: "2px" }}>
96
+ {Array.from({ length: count }).map((_, index) => (
97
+ <Box key={index} sx={{ height: 5, borderRadius: 0.5, bgcolor: barColor(active) }} />
98
+ ))}
99
+ </Box>
100
+ ))}
101
+ </Box>
102
+ );
103
+ }