@12-apps/ui 6.0.2 → 6.2.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.
- package/components-catalog.md +8 -0
- package/dist/data-display/CardKit.js +266 -0
- package/dist/data-display/CardKit.js.map +1 -0
- package/dist/types/components/data-display/CardKit/CardKebab.d.ts +23 -0
- package/dist/types/components/data-display/CardKit/CardKebab.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/CardKit.types.d.ts +55 -0
- package/dist/types/components/data-display/CardKit/CardKit.types.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/card-actions-context.d.ts +51 -0
- package/dist/types/components/data-display/CardKit/card-actions-context.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/index.d.ts +15 -0
- package/dist/types/components/data-display/CardKit/index.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/list-card-parts.d.ts +60 -0
- package/dist/types/components/data-display/CardKit/list-card-parts.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/row-actions-to-menu.d.ts +17 -0
- package/dist/types/components/data-display/CardKit/row-actions-to-menu.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/use-remove-confirm.d.ts +62 -0
- package/dist/types/components/data-display/CardKit/use-remove-confirm.d.ts.map +1 -0
- package/dist/types/components/data-display/CardKit/use-row-confirm.d.ts +57 -0
- package/dist/types/components/data-display/CardKit/use-row-confirm.d.ts.map +1 -0
- package/dist/types/utils/export-rows.d.ts +31 -0
- package/dist/types/utils/export-rows.d.ts.map +1 -0
- package/dist/types/utils/index.d.ts +1 -0
- package/dist/types/utils/index.d.ts.map +1 -1
- package/dist/utils.js +50 -0
- package/dist/utils.js.map +1 -1
- package/package.json +8 -4
- package/src/components/data-display/CardKit/CardKebab.tsx +43 -0
- package/src/components/data-display/CardKit/CardKit.md +87 -0
- package/src/components/data-display/CardKit/CardKit.types.ts +60 -0
- package/src/components/data-display/CardKit/card-actions-context.tsx +98 -0
- package/src/components/data-display/CardKit/index.ts +19 -0
- package/src/components/data-display/CardKit/list-card-parts.tsx +163 -0
- package/src/components/data-display/CardKit/row-actions-to-menu.ts +29 -0
- package/src/components/data-display/CardKit/use-remove-confirm.tsx +106 -0
- package/src/components/data-display/CardKit/use-row-confirm.tsx +91 -0
- package/src/utils/export-rows.ts +95 -0
- package/src/utils/index.ts +1 -0
- package/dist/types/components/data-display/DataViews/pedidos.server.d.ts +0 -42
- package/dist/types/components/data-display/DataViews/pedidos.server.d.ts.map +0 -1
- package/src/components/data-display/DataViews/pedidos.server.ts +0 -118
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { JSX, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Box } from '../../../mui/Box';
|
|
6
|
+
|
|
7
|
+
import type { CardFactProps, CardLedgerLine } from './CardKit.types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* THE SHARED FURNITURE OF AN EXPANDED LIST-CARD BODY.
|
|
11
|
+
*
|
|
12
|
+
* `BaseListCard` says nothing about what goes inside a row's expanded area — it
|
|
13
|
+
* spans the rails and stops, because the design belongs to the consumer. Right
|
|
14
|
+
* for the card; wrong as the place where every entity in an admin app invents
|
|
15
|
+
* its own two-column layout, its own label/value pair and its own ledger.
|
|
16
|
+
*
|
|
17
|
+
* So the four shapes that actually repeat live here, once. Nothing here knows a
|
|
18
|
+
* domain: they take `ReactNode` and lay it out.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The expanded body's two-column split: detail on the left, a ledger right. */
|
|
22
|
+
export function DetailColumns({ left, right }: { left: ReactNode; right: ReactNode }): JSX.Element {
|
|
23
|
+
return (
|
|
24
|
+
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3 }}>
|
|
25
|
+
<Box sx={{ minWidth: 0 }}>{left}</Box>
|
|
26
|
+
<Box sx={{ minWidth: 0 }}>{right}</Box>
|
|
27
|
+
</Box>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One labelled fact inside an expanded body.
|
|
33
|
+
*
|
|
34
|
+
* Horizontal, not stacked: the body is already a wide area with room for a
|
|
35
|
+
* label beside its value, and stacking them there would ape the summary rails
|
|
36
|
+
* above without their alignment.
|
|
37
|
+
*/
|
|
38
|
+
export function Fact({ label, value }: CardFactProps): JSX.Element {
|
|
39
|
+
return (
|
|
40
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, fontSize: 13, py: 0.25 }}>
|
|
41
|
+
<Box component="span" sx={{ color: 'text.secondary', flexShrink: 0 }}>
|
|
42
|
+
{label}
|
|
43
|
+
</Box>
|
|
44
|
+
<Box component="span" sx={{ minWidth: 0, textAlign: 'right', overflowWrap: 'anywhere' }}>
|
|
45
|
+
{value}
|
|
46
|
+
</Box>
|
|
47
|
+
</Box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A money ledger: rows of label/amount, then a ruled total.
|
|
53
|
+
*
|
|
54
|
+
* The total is separated by a RULE rather than by weight alone, because a
|
|
55
|
+
* subtotal and a total in one column with only bold between them is the classic
|
|
56
|
+
* misread — the eye lands on whichever number is larger.
|
|
57
|
+
*
|
|
58
|
+
* The figures arrive pre-formatted. This is a layout, and a component that
|
|
59
|
+
* formatted money would be choosing a locale on the consumer's behalf.
|
|
60
|
+
*/
|
|
61
|
+
export function Ledger({
|
|
62
|
+
lines,
|
|
63
|
+
total,
|
|
64
|
+
}: {
|
|
65
|
+
lines: CardLedgerLine[];
|
|
66
|
+
total: { label: string; value: ReactNode };
|
|
67
|
+
}): JSX.Element {
|
|
68
|
+
return (
|
|
69
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
|
70
|
+
{lines.map((line) => (
|
|
71
|
+
<Box
|
|
72
|
+
key={line.label}
|
|
73
|
+
sx={{
|
|
74
|
+
display: 'flex',
|
|
75
|
+
justifyContent: 'space-between',
|
|
76
|
+
fontSize: 13,
|
|
77
|
+
color: line.tone === 'info' ? 'info.main' : 'text.secondary',
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<Box component="span">{line.label}</Box>
|
|
81
|
+
<Box component="span" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
82
|
+
{line.value}
|
|
83
|
+
</Box>
|
|
84
|
+
</Box>
|
|
85
|
+
))}
|
|
86
|
+
<Box
|
|
87
|
+
sx={{
|
|
88
|
+
display: 'flex',
|
|
89
|
+
justifyContent: 'space-between',
|
|
90
|
+
fontWeight: 700,
|
|
91
|
+
borderTop: 1,
|
|
92
|
+
borderColor: 'divider',
|
|
93
|
+
pt: 0.5,
|
|
94
|
+
mt: 0.5,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<Box component="span">{total.label}</Box>
|
|
98
|
+
<Box component="span" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
99
|
+
{total.value}
|
|
100
|
+
</Box>
|
|
101
|
+
</Box>
|
|
102
|
+
</Box>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A heading inside the expanded body, for a section that needs naming. */
|
|
107
|
+
export function BodyHeading({ children }: { children: ReactNode }): JSX.Element {
|
|
108
|
+
return (
|
|
109
|
+
<Box
|
|
110
|
+
sx={{
|
|
111
|
+
fontSize: 11,
|
|
112
|
+
fontWeight: 700,
|
|
113
|
+
letterSpacing: 0.4,
|
|
114
|
+
textTransform: 'uppercase',
|
|
115
|
+
color: 'text.disabled',
|
|
116
|
+
mb: 0.5,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A wrapping run of small items — permission names, role names, target ids.
|
|
126
|
+
*
|
|
127
|
+
* The whole reason several entities earn a list card at all: a `string[]` has no
|
|
128
|
+
* honest table cell. Truncated to "3 items" it says nothing; printed in full it
|
|
129
|
+
* destroys the column. Here it simply wraps.
|
|
130
|
+
*/
|
|
131
|
+
export function TagList({
|
|
132
|
+
items,
|
|
133
|
+
empty,
|
|
134
|
+
}: {
|
|
135
|
+
items: readonly string[];
|
|
136
|
+
/** What to show for an empty run — the consumer's sentence, no default. */
|
|
137
|
+
empty: string;
|
|
138
|
+
}): JSX.Element {
|
|
139
|
+
if (items.length === 0) {
|
|
140
|
+
return <Box sx={{ fontSize: 13, color: 'text.disabled' }}>{empty}</Box>;
|
|
141
|
+
}
|
|
142
|
+
return (
|
|
143
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
144
|
+
{items.map((item) => (
|
|
145
|
+
<Box
|
|
146
|
+
key={item}
|
|
147
|
+
component="span"
|
|
148
|
+
sx={{
|
|
149
|
+
fontSize: 12,
|
|
150
|
+
px: 0.75,
|
|
151
|
+
py: 0.25,
|
|
152
|
+
borderRadius: 1,
|
|
153
|
+
bgcolor: 'action.hover',
|
|
154
|
+
color: 'text.secondary',
|
|
155
|
+
fontFamily: 'monospace',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{item}
|
|
159
|
+
</Box>
|
|
160
|
+
))}
|
|
161
|
+
</Box>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DropdownMenuItem } from '../../navigation/DropdownMenu';
|
|
2
|
+
import type { RowAction } from '../DataViews';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adapt a list of {@link RowAction}s into single-row kebab items.
|
|
6
|
+
*
|
|
7
|
+
* Applies each action's `isVisible` guard, resolves its per-row `rowLabel`
|
|
8
|
+
* override, carries its colour, and fires `onSelect([row])` on click.
|
|
9
|
+
*
|
|
10
|
+
* The point is that ONE definition drives both surfaces. An entity that already
|
|
11
|
+
* declares its per-row actions as `RowAction[]` — because the table's multi-
|
|
12
|
+
* select menu needs them — gets the card's kebab from the same array through
|
|
13
|
+
* `renderRowMenu` / `renderCard`, with no second list to keep in step. Two
|
|
14
|
+
* lists is how a "Duplicar" that appears in the table and not on the card
|
|
15
|
+
* happens, and nothing reports it.
|
|
16
|
+
*/
|
|
17
|
+
export function rowActionsToMenuItems<T extends Record<string, unknown>>(
|
|
18
|
+
actions: RowAction<T>[],
|
|
19
|
+
row: T,
|
|
20
|
+
): DropdownMenuItem[] {
|
|
21
|
+
return actions
|
|
22
|
+
.filter((action) => action.isVisible?.(row) ?? true)
|
|
23
|
+
.map((action) => ({
|
|
24
|
+
id: action.id,
|
|
25
|
+
label: action.rowLabel?.(row) ?? action.label,
|
|
26
|
+
color: action.color,
|
|
27
|
+
onClick: () => void action.onSelect([row]),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { JSX } from 'react';
|
|
4
|
+
|
|
5
|
+
import { ConfirmActionDialog, useConfirmAction } from '../../feedback/ConfirmAction';
|
|
6
|
+
|
|
7
|
+
import { useCardActions } from './card-actions-context';
|
|
8
|
+
|
|
9
|
+
/** The `{ ok, error }` envelope a write action answers with. */
|
|
10
|
+
export interface CardWriteResult {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RemoveConfirmOptions {
|
|
16
|
+
/** The destructive write. Called ONLY after the operator confirms. */
|
|
17
|
+
write: () => Promise<CardWriteResult>;
|
|
18
|
+
/** The popup's question. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** The row being removed, echoed into the popup so it names a thing. */
|
|
21
|
+
entityName: string;
|
|
22
|
+
/**
|
|
23
|
+
* What happens, and whether it can be walked back. Say which — the difference
|
|
24
|
+
* between a soft delete a recycle bin recovers and one nothing recovers is
|
|
25
|
+
* the whole content of the decision being asked for.
|
|
26
|
+
*/
|
|
27
|
+
description: string;
|
|
28
|
+
/** The confirm button's VERB. Required, never defaulted. */
|
|
29
|
+
confirmText: string;
|
|
30
|
+
/** Used when the write fails without a message of its own. */
|
|
31
|
+
fallbackError: string;
|
|
32
|
+
dataTestId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RemoveConfirm {
|
|
36
|
+
/** Hand this to the menu item's `onClick`: it opens the popup, nothing more. */
|
|
37
|
+
request: () => void;
|
|
38
|
+
/** Render alongside the trigger. */
|
|
39
|
+
dialog: JSX.Element;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The confirm-before-removing wiring every card/row menu repeats, in one place.
|
|
44
|
+
*
|
|
45
|
+
* It closes the three gaps a hand-rolled version keeps re-opening: the write
|
|
46
|
+
* leaves ONLY on confirm; a refusal is surfaced twice — inside the popup, where
|
|
47
|
+
* the operator is still deciding, and in the shared snackbar behind it — and a
|
|
48
|
+
* failed attempt leaves the popup OPEN to be retried instead of vanishing over
|
|
49
|
+
* a delete that never happened.
|
|
50
|
+
*
|
|
51
|
+
* The difference from {@link useRowConfirm} is which surface it serves: this one
|
|
52
|
+
* is for a SELF-CONTAINED menu that already sits inside a
|
|
53
|
+
* `CardActionsProvider`, so it reads the refresh and the error channel from
|
|
54
|
+
* context and answers the `{ ok, error }` envelope those actions use.
|
|
55
|
+
* `useRowConfirm` is for a grid whose actions are declared as `RowAction[]` and
|
|
56
|
+
* act on a selection.
|
|
57
|
+
*
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const remove = useRemoveConfirm({
|
|
60
|
+
* write: () => deleteCategory({ tenantSlug, id: row.id }),
|
|
61
|
+
* title: copy.deleteTitle,
|
|
62
|
+
* entityName: row.name,
|
|
63
|
+
* description: copy.deleteDescription,
|
|
64
|
+
* confirmText: copy.delete,
|
|
65
|
+
* fallbackError: copy.deleteFailed,
|
|
66
|
+
* dataTestId: 'category-delete-confirm',
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function useRemoveConfirm({
|
|
71
|
+
write,
|
|
72
|
+
title,
|
|
73
|
+
entityName,
|
|
74
|
+
description,
|
|
75
|
+
confirmText,
|
|
76
|
+
fallbackError,
|
|
77
|
+
dataTestId,
|
|
78
|
+
}: RemoveConfirmOptions): RemoveConfirm {
|
|
79
|
+
const { onRefresh, notifyError } = useCardActions();
|
|
80
|
+
|
|
81
|
+
const state = useConfirmAction(async () => {
|
|
82
|
+
const result = await write();
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
const message = result.error ?? fallbackError;
|
|
85
|
+
notifyError(message);
|
|
86
|
+
// Rejecting is what keeps the popup open carrying `message`; the snackbar
|
|
87
|
+
// above is what remains once the operator dismisses it.
|
|
88
|
+
throw new Error(message);
|
|
89
|
+
}
|
|
90
|
+
onRefresh();
|
|
91
|
+
}, fallbackError);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
request: state.request,
|
|
95
|
+
dialog: (
|
|
96
|
+
<ConfirmActionDialog
|
|
97
|
+
state={state}
|
|
98
|
+
title={title}
|
|
99
|
+
entityName={entityName}
|
|
100
|
+
description={description}
|
|
101
|
+
confirmText={confirmText}
|
|
102
|
+
dataTestId={dataTestId}
|
|
103
|
+
/>
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type JSX } from 'react';
|
|
4
|
+
|
|
5
|
+
import { ConfirmActionDialog, useConfirmAction } from '../../feedback/ConfirmAction';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How the popup should read for the rows about to be acted on.
|
|
9
|
+
*
|
|
10
|
+
* Built from the SELECTION rather than from a single row, because the same
|
|
11
|
+
* guard covers a bulk action: "Delete 4 categories?" and "Delete the category
|
|
12
|
+
* Drinks?" are the same question asked of a different-sized selection — and an
|
|
13
|
+
* operator who multi-selected by mistake is exactly who this popup is for.
|
|
14
|
+
*/
|
|
15
|
+
export interface RowConfirmCopy {
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
/** Echoed into the popup; omit for a multi-row selection the title covers. */
|
|
19
|
+
entityName?: string;
|
|
20
|
+
/** The VERB. Required, never defaulted — see {@link useRowConfirm}. */
|
|
21
|
+
confirmText: string;
|
|
22
|
+
/** Override where the word "cancel" would mean the ACTION itself. */
|
|
23
|
+
cancelText?: string;
|
|
24
|
+
/** Require typing this before the confirm enables — for the unrecoverable few. */
|
|
25
|
+
typeToConfirm?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RowConfirmOptions<T> {
|
|
29
|
+
/**
|
|
30
|
+
* The destructive write, over the whole selection. Called ONLY after the
|
|
31
|
+
* operator confirms; throw (or reject) to keep the popup open carrying the
|
|
32
|
+
* message.
|
|
33
|
+
*/
|
|
34
|
+
write: (rows: T[]) => Promise<unknown>;
|
|
35
|
+
/** The wording, given the selection. */
|
|
36
|
+
describe: (rows: T[]) => RowConfirmCopy;
|
|
37
|
+
dataTestId: string;
|
|
38
|
+
/** Shown when `write` fails without a message of its own. */
|
|
39
|
+
errorText?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RowConfirm<T> {
|
|
43
|
+
/** Hand this a grid action's `rows`: it opens the popup and writes nothing. */
|
|
44
|
+
request: (rows: T[]) => void;
|
|
45
|
+
/** Render next to the grid. */
|
|
46
|
+
dialog: JSX.Element | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Confirmation for a grid's `RowAction` — the per-row kebab entry and the
|
|
51
|
+
* multi-select actions menu both, since they run the same handler.
|
|
52
|
+
*
|
|
53
|
+
* The SELECTION is held while the popup is open, so the write sees exactly the
|
|
54
|
+
* rows the operator was looking at when they were asked. Re-reading the grid's
|
|
55
|
+
* live selection at confirm time is the bug this shape avoids: the list can
|
|
56
|
+
* refresh underneath an open dialog.
|
|
57
|
+
*
|
|
58
|
+
* `confirmText` is required and comes from `describe`, with no default. A verb
|
|
59
|
+
* defaulted in the origin's language reads as finished to the next adopter
|
|
60
|
+
* right up until an operator sees it — and `ConfirmOptions` already refuses to
|
|
61
|
+
* default one for the same reason.
|
|
62
|
+
*/
|
|
63
|
+
export function useRowConfirm<T>({
|
|
64
|
+
write,
|
|
65
|
+
describe,
|
|
66
|
+
dataTestId,
|
|
67
|
+
errorText,
|
|
68
|
+
}: RowConfirmOptions<T>): RowConfirm<T> {
|
|
69
|
+
const [target, setTarget] = useState<T[] | null>(null);
|
|
70
|
+
|
|
71
|
+
const state = useConfirmAction(async () => {
|
|
72
|
+
if (!target) return;
|
|
73
|
+
await write(target);
|
|
74
|
+
setTarget(null);
|
|
75
|
+
}, errorText);
|
|
76
|
+
|
|
77
|
+
const request = (rows: T[]): void => {
|
|
78
|
+
// An empty selection has nothing to confirm — asking would be a popup about
|
|
79
|
+
// nothing, and confirming it would write nothing.
|
|
80
|
+
if (rows.length === 0) return;
|
|
81
|
+
setTarget(rows);
|
|
82
|
+
state.request();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
request,
|
|
87
|
+
dialog: target ? (
|
|
88
|
+
<ConfirmActionDialog state={state} {...describe(target)} dataTestId={dataTestId} />
|
|
89
|
+
) : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialise rows and hand the browser a download.
|
|
3
|
+
*
|
|
4
|
+
* The companion of the grid's Export control, which emits a REQUEST and leaves
|
|
5
|
+
* the producing to the host — this is the producing half, for the common case
|
|
6
|
+
* where the host already has the rows and only needs a file out of them.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free on purpose: "Excel" is emitted as CSV, which Excel opens
|
|
9
|
+
* natively. A spreadsheet library would add ~700KB to a bundle for column
|
|
10
|
+
* widths nobody asked for, and the one thing a real `.xlsx` buys — types that
|
|
11
|
+
* survive the round trip — is not something a CSV export of an admin list is
|
|
12
|
+
* being asked for.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type ExportFormat = 'csv' | 'json';
|
|
16
|
+
|
|
17
|
+
/** One column of the exported file: its header, and how to read a row. */
|
|
18
|
+
export interface ExportColumn<T> {
|
|
19
|
+
/** Column header, used as the CSV column and the JSON key. */
|
|
20
|
+
header: string;
|
|
21
|
+
/** Extract the cell value for a row. */
|
|
22
|
+
value: (row: T) => string | number | boolean | null | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeCsvCell(value: string): string {
|
|
26
|
+
// Quote when the cell contains a comma, a quote or a newline; double any
|
|
27
|
+
// embedded quote. Without this a name with a comma silently shifts every
|
|
28
|
+
// column after it by one, which reads as corrupt data rather than as a bug.
|
|
29
|
+
if (/[",\n\r]/.test(value)) {
|
|
30
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toCsv<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {
|
|
36
|
+
const head = columns.map((column) => escapeCsvCell(column.header)).join(',');
|
|
37
|
+
const body = rows
|
|
38
|
+
.map((row) =>
|
|
39
|
+
columns
|
|
40
|
+
.map((column) => {
|
|
41
|
+
const cell = column.value(row);
|
|
42
|
+
return escapeCsvCell(cell == null ? '' : String(cell));
|
|
43
|
+
})
|
|
44
|
+
.join(','),
|
|
45
|
+
)
|
|
46
|
+
.join('\n');
|
|
47
|
+
return `${head}\n${body}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toJson<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {
|
|
51
|
+
const records = rows.map((row) => {
|
|
52
|
+
const record: Record<string, string | number | boolean | null> = {};
|
|
53
|
+
for (const column of columns) {
|
|
54
|
+
const cell = column.value(row);
|
|
55
|
+
record[column.header] = cell == null ? null : (cell as string | number | boolean);
|
|
56
|
+
}
|
|
57
|
+
return record;
|
|
58
|
+
});
|
|
59
|
+
return JSON.stringify(records, null, 2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function triggerDownload(content: string, filename: string, mimeType: string): void {
|
|
63
|
+
// A no-op without a document, so a module that imports this can still be
|
|
64
|
+
// loaded by a test runner or a server render without guarding the import.
|
|
65
|
+
if (typeof window === 'undefined') return;
|
|
66
|
+
const blob = new Blob([content], { type: mimeType });
|
|
67
|
+
const url = URL.createObjectURL(blob);
|
|
68
|
+
const anchor = document.createElement('a');
|
|
69
|
+
anchor.href = url;
|
|
70
|
+
anchor.download = filename;
|
|
71
|
+
document.body.appendChild(anchor);
|
|
72
|
+
anchor.click();
|
|
73
|
+
anchor.remove();
|
|
74
|
+
URL.revokeObjectURL(url);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Serialise `rows` using `columns` and download them as `<baseName>.<ext>`.
|
|
79
|
+
*
|
|
80
|
+
* @param format - `csv` (also what the Excel option produces) or `json`.
|
|
81
|
+
* @param baseName - File name without extension; the consumer's word for the
|
|
82
|
+
* collection, so it is never derived from a type name here.
|
|
83
|
+
*/
|
|
84
|
+
export function exportRows<T>(
|
|
85
|
+
format: ExportFormat,
|
|
86
|
+
rows: readonly T[],
|
|
87
|
+
columns: readonly ExportColumn<T>[],
|
|
88
|
+
baseName: string,
|
|
89
|
+
): void {
|
|
90
|
+
if (format === 'json') {
|
|
91
|
+
triggerDownload(toJson(rows, columns), `${baseName}.json`, 'application/json');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
triggerDownload(toCsv(rows, columns), `${baseName}.csv`, 'text/csv;charset=utf-8');
|
|
95
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { DataViewQuery } from "./data-views-types";
|
|
2
|
-
/**
|
|
3
|
-
* THE "SERVER" FOR THE PEDIDOS STORY.
|
|
4
|
-
*
|
|
5
|
-
* Server mode means the grid does not filter, sort or paginate — it emits the
|
|
6
|
-
* query and renders exactly the page it is handed back. A story that wires
|
|
7
|
-
* `onQueryChange` to a no-op spy therefore looks broken in a specific and
|
|
8
|
-
* misleading way: the pills apply, the counter never moves, and every row
|
|
9
|
-
* stays. The controls are working; there is simply nobody on the other end.
|
|
10
|
-
*
|
|
11
|
-
* So this is the other end. It is deliberately fenced into its own module and
|
|
12
|
-
* written the way the real endpoint is: it is the ONLY code allowed to narrow
|
|
13
|
-
* the dataset, and the story never reaches past `data`.
|
|
14
|
-
*/
|
|
15
|
-
/** The subset of a row this fake backend knows how to query. */
|
|
16
|
-
export interface ServerRow extends Record<string, unknown> {
|
|
17
|
-
pedido: string;
|
|
18
|
-
cliente: string;
|
|
19
|
-
metodo: string;
|
|
20
|
-
pagamento: string;
|
|
21
|
-
situacao: string;
|
|
22
|
-
dataIso: string;
|
|
23
|
-
valor: number;
|
|
24
|
-
}
|
|
25
|
-
export interface ServerPage<T> {
|
|
26
|
-
data: T[];
|
|
27
|
-
totalCount: number;
|
|
28
|
-
/**
|
|
29
|
-
* Per-scope totals over search + pills + ranges, IGNORING the active scope —
|
|
30
|
-
* computed with it applied, every inactive tab reads zero.
|
|
31
|
-
*/
|
|
32
|
-
scopeCounts: Record<string, number>;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Answer one query: narrow, count, scope, sort, then cut the page.
|
|
36
|
-
*
|
|
37
|
-
* `scopeCounts` is computed BEFORE the scope is applied, which is the whole
|
|
38
|
-
* reason it cannot be done in the browser — a count taken from the loaded page
|
|
39
|
-
* is wrong the moment there is a second page.
|
|
40
|
-
*/
|
|
41
|
-
export declare function queryPedidos(rows: ServerRow[], query: DataViewQuery): ServerPage<ServerRow>;
|
|
42
|
-
//# sourceMappingURL=pedidos.server.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"pedidos.server.d.ts","sourceRoot":"","sources":["../../../../../src/components/data-display/DataViews/pedidos.server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,oBAAoB,CAAC;AAEpE;;;;;;;;;;;;GAYG;AAEH,gEAAgE;AAChE,MAAM,WAAW,SAAU,SAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,IAAI,EAAE,CAAC,EAAE,CAAC;IACV,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAgDD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CA2B3F"}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import type { DataViewQuery, RangeValue } from "./data-views-types";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* THE "SERVER" FOR THE PEDIDOS STORY.
|
|
5
|
-
*
|
|
6
|
-
* Server mode means the grid does not filter, sort or paginate — it emits the
|
|
7
|
-
* query and renders exactly the page it is handed back. A story that wires
|
|
8
|
-
* `onQueryChange` to a no-op spy therefore looks broken in a specific and
|
|
9
|
-
* misleading way: the pills apply, the counter never moves, and every row
|
|
10
|
-
* stays. The controls are working; there is simply nobody on the other end.
|
|
11
|
-
*
|
|
12
|
-
* So this is the other end. It is deliberately fenced into its own module and
|
|
13
|
-
* written the way the real endpoint is: it is the ONLY code allowed to narrow
|
|
14
|
-
* the dataset, and the story never reaches past `data`.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** The subset of a row this fake backend knows how to query. */
|
|
18
|
-
export interface ServerRow extends Record<string, unknown> {
|
|
19
|
-
pedido: string;
|
|
20
|
-
cliente: string;
|
|
21
|
-
metodo: string;
|
|
22
|
-
pagamento: string;
|
|
23
|
-
situacao: string;
|
|
24
|
-
dataIso: string;
|
|
25
|
-
valor: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ServerPage<T> {
|
|
29
|
-
data: T[];
|
|
30
|
-
totalCount: number;
|
|
31
|
-
/**
|
|
32
|
-
* Per-scope totals over search + pills + ranges, IGNORING the active scope —
|
|
33
|
-
* computed with it applied, every inactive tab reads zero.
|
|
34
|
-
*/
|
|
35
|
-
scopeCounts: Record<string, number>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** A row matches a pill field when ANY selected value matches. */
|
|
39
|
-
function matchesPills(row: ServerRow, pills: Record<string, string[]>): boolean {
|
|
40
|
-
return Object.entries(pills).every(([fieldId, values]) => {
|
|
41
|
-
if (!values || values.length === 0) return true;
|
|
42
|
-
return values.includes(String(row[fieldId] ?? ""));
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Ranges. A DAY bound is compared as an ISO STRING, never coerced to a
|
|
48
|
-
* timestamp: turning an `até` of 2026-07-15 into an instant lands on that day's
|
|
49
|
-
* midnight and silently drops the final day (FUT-668). Both ends are inclusive.
|
|
50
|
-
*/
|
|
51
|
-
function matchesRanges(row: ServerRow, ranges: Record<string, RangeValue>): boolean {
|
|
52
|
-
return Object.entries(ranges).every(([fieldId, range]) => {
|
|
53
|
-
if (!range || (range.min == null && range.max == null)) return true;
|
|
54
|
-
const value = fieldId === "data" ? row.dataIso : row.valor;
|
|
55
|
-
if (value == null) return false;
|
|
56
|
-
if (range.min != null && value < range.min) return false;
|
|
57
|
-
if (range.max != null && value > range.max) return false;
|
|
58
|
-
return true;
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function matchesSearch(row: ServerRow, search: string): boolean {
|
|
63
|
-
const term = search.trim().toLowerCase();
|
|
64
|
-
if (!term) return true;
|
|
65
|
-
return [row.pedido, row.cliente, row.metodo].some((field) =>
|
|
66
|
-
String(field).toLowerCase().includes(term),
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The scopes this story declares, as predicates over a row's `situacao`. */
|
|
71
|
-
const SCOPES: Record<string, (row: ServerRow) => boolean> = {
|
|
72
|
-
todos: () => true,
|
|
73
|
-
"Em aberto": (row) => row.situacao === "Em aberto",
|
|
74
|
-
Cancelado: (row) => row.situacao === "Cancelado",
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
function compare(a: ServerRow, b: ServerRow, id: string): number {
|
|
78
|
-
const left = a[id];
|
|
79
|
-
const right = b[id];
|
|
80
|
-
if (typeof left === "number" && typeof right === "number") return left - right;
|
|
81
|
-
return String(left ?? "").localeCompare(String(right ?? ""), "pt-BR");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Answer one query: narrow, count, scope, sort, then cut the page.
|
|
86
|
-
*
|
|
87
|
-
* `scopeCounts` is computed BEFORE the scope is applied, which is the whole
|
|
88
|
-
* reason it cannot be done in the browser — a count taken from the loaded page
|
|
89
|
-
* is wrong the moment there is a second page.
|
|
90
|
-
*/
|
|
91
|
-
export function queryPedidos(rows: ServerRow[], query: DataViewQuery): ServerPage<ServerRow> {
|
|
92
|
-
const narrowed = rows.filter(
|
|
93
|
-
(row) =>
|
|
94
|
-
matchesSearch(row, query.search) &&
|
|
95
|
-
matchesPills(row, query.pills) &&
|
|
96
|
-
matchesRanges(row, query.ranges ?? {}),
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
const scopeCounts = Object.fromEntries(
|
|
100
|
-
Object.entries(SCOPES).map(([id, predicate]) => [id, narrowed.filter(predicate).length]),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const predicate = query.scope ? SCOPES[query.scope] : undefined;
|
|
104
|
-
const scoped = predicate ? narrowed.filter(predicate) : narrowed;
|
|
105
|
-
|
|
106
|
-
const sorted = [...scoped];
|
|
107
|
-
const sort = query.sortBy?.[0];
|
|
108
|
-
if (sort) {
|
|
109
|
-
sorted.sort((a, b) => (sort.dir === "asc" ? compare(a, b, sort.id) : compare(b, a, sort.id)));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const start = (query.page - 1) * query.pageSize;
|
|
113
|
-
return {
|
|
114
|
-
data: sorted.slice(start, start + query.pageSize),
|
|
115
|
-
totalCount: scoped.length,
|
|
116
|
-
scopeCounts,
|
|
117
|
-
};
|
|
118
|
-
}
|