@sia-ui/cli 0.2.1 → 0.3.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/LICENSE +21 -21
- package/dist/index.js +2 -0
- package/dist/registry/package/components/Alert/index.tsx +7 -0
- package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
- package/dist/registry/package/components/AppShell/index.tsx +413 -3
- package/dist/registry/package/components/AppShell/styles.css +259 -7
- package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
- package/dist/registry/package/components/AsyncState/index.tsx +7 -0
- package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
- package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
- package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
- package/dist/registry/package/components/Avatar/index.tsx +333 -12
- package/dist/registry/package/components/Avatar/styles.css +236 -5
- package/dist/registry/package/components/Badge/index.tsx +7 -0
- package/dist/registry/package/components/Button/index.tsx +7 -0
- package/dist/registry/package/components/Calendar/index.tsx +11 -0
- package/dist/registry/package/components/Card/index.tsx +8 -0
- package/dist/registry/package/components/Checkbox/index.tsx +8 -0
- package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
- package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
- package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
- package/dist/registry/package/components/Container/index.tsx +7 -0
- package/dist/registry/package/components/Countdown/index.tsx +7 -0
- package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
- package/dist/registry/package/components/CrudPage/index.tsx +546 -5
- package/dist/registry/package/components/CrudPage/styles.css +25 -1
- package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
- package/dist/registry/package/components/DataTable/index.tsx +548 -200
- package/dist/registry/package/components/DataTable/query.ts +68 -0
- package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
- package/dist/registry/package/components/DataTable/styles.css +285 -73
- package/dist/registry/package/components/DataTable/types.ts +194 -0
- package/dist/registry/package/components/DataTable/values.ts +28 -0
- package/dist/registry/package/components/DatePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
- package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
- package/dist/registry/package/components/Descriptions/index.tsx +7 -0
- package/dist/registry/package/components/Divider/index.tsx +7 -0
- package/dist/registry/package/components/Drawer/index.tsx +8 -0
- package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
- package/dist/registry/package/components/EmptyState/index.tsx +7 -0
- package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
- package/dist/registry/package/components/ErrorState/index.tsx +8 -0
- package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Field/index.tsx +12 -0
- package/dist/registry/package/components/FileUpload/index.tsx +7 -0
- package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
- package/dist/registry/package/components/HoverCard/index.tsx +14 -37
- package/dist/registry/package/components/IconButton/index.tsx +8 -0
- package/dist/registry/package/components/Icons/index.tsx +15 -0
- package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
- package/dist/registry/package/components/Input/index.tsx +7 -0
- package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
- package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
- package/dist/registry/package/components/Label/index.tsx +7 -0
- package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
- package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Modal/index.tsx +8 -0
- package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
- package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
- package/dist/registry/package/components/OtpInput/index.tsx +7 -0
- package/dist/registry/package/components/Overlay/index.tsx +34 -24
- package/dist/registry/package/components/PageHeader/index.tsx +7 -0
- package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
- package/dist/registry/package/components/Popover/index.tsx +7 -0
- package/dist/registry/package/components/QrCode/index.tsx +7 -0
- package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
- package/dist/registry/package/components/Rating/index.tsx +6 -0
- package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
- package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
- package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
- package/dist/registry/package/components/Resource/index.tsx +514 -0
- package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
- package/dist/registry/package/components/SearchInput/index.tsx +8 -0
- package/dist/registry/package/components/Select/index.tsx +11 -0
- package/dist/registry/package/components/Sidebar/index.tsx +489 -113
- package/dist/registry/package/components/Sidebar/styles.css +96 -4
- package/dist/registry/package/components/Skeleton/index.tsx +7 -0
- package/dist/registry/package/components/Spinner/index.tsx +7 -0
- package/dist/registry/package/components/StatCard/index.tsx +7 -0
- package/dist/registry/package/components/Statistic/index.tsx +7 -0
- package/dist/registry/package/components/Switch/index.tsx +7 -0
- package/dist/registry/package/components/Tabs/index.tsx +7 -0
- package/dist/registry/package/components/TagsInput/index.tsx +7 -0
- package/dist/registry/package/components/Textarea/index.tsx +7 -0
- package/dist/registry/package/components/TimePicker/index.tsx +7 -0
- package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
- package/dist/registry/package/components/Timeline/index.tsx +7 -0
- package/dist/registry/package/components/Timer/index.tsx +7 -0
- package/dist/registry/package/components/Tour/index.tsx +7 -0
- package/dist/registry/package/components/Transfer/index.tsx +7 -0
- package/dist/registry/package/components/Tree/index.tsx +7 -0
- package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
- package/dist/registry/package/components/Typography/index.tsx +8 -0
- package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
- package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
- package/dist/registry/package/components/YearPicker/index.tsx +7 -0
- package/dist/registry/registry.json +69 -14
- package/package.json +14 -14
|
@@ -1,9 +1,550 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { useMemo, useState, type Key, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import type { FormValidator, PermissionRule } from "@sia-ui/headless";
|
|
2
4
|
import { PageHeader } from "../PageHeader";
|
|
3
|
-
import {
|
|
5
|
+
import { Button } from "../Button";
|
|
4
6
|
import { Pagination } from "../Pagination";
|
|
5
|
-
import {
|
|
7
|
+
import { DataTable } from "../DataTable";
|
|
8
|
+
import { EyeIcon, PlusIcon, TrashIcon } from "../Icons";
|
|
9
|
+
import type {
|
|
10
|
+
DataTableColumn,
|
|
11
|
+
DataTableProps,
|
|
12
|
+
RowAction,
|
|
13
|
+
} from "../DataTable/types";
|
|
14
|
+
import type { FormEntry, FormShape } from "../Form";
|
|
15
|
+
import {
|
|
16
|
+
resourceColumns,
|
|
17
|
+
resourceDefaults,
|
|
18
|
+
resourceDetailColumns,
|
|
19
|
+
resourceFormFields,
|
|
20
|
+
resourcePermissions,
|
|
21
|
+
resourceRowKey,
|
|
22
|
+
resourceSearchKeys,
|
|
23
|
+
resourceValidator,
|
|
24
|
+
type ResourceDefinition,
|
|
25
|
+
} from "../Resource";
|
|
26
|
+
import {
|
|
27
|
+
CrudDialogs,
|
|
28
|
+
type CrudDialogOptions,
|
|
29
|
+
type CrudDialogState,
|
|
30
|
+
} from "./dialogs";
|
|
6
31
|
import "./styles.css";
|
|
7
32
|
|
|
8
|
-
export
|
|
9
|
-
|
|
33
|
+
export {
|
|
34
|
+
CrudDialogs,
|
|
35
|
+
type CrudDialogOptions,
|
|
36
|
+
type CrudDialogState,
|
|
37
|
+
} from "./dialogs";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* La personnalisation d'une action standard.
|
|
41
|
+
*
|
|
42
|
+
* Tout est facultatif : fournir le gestionnaire suffit à obtenir un bouton
|
|
43
|
+
* qui marche. Ce qui est ici ne sert qu'à s'écarter du défaut — un autre
|
|
44
|
+
* libellé, une règle de droits, une confirmation en plus ou en moins.
|
|
45
|
+
*/
|
|
46
|
+
export interface CrudActionOptions<T> {
|
|
47
|
+
label?: ReactNode;
|
|
48
|
+
icon?: ReactNode;
|
|
49
|
+
permission?: PermissionRule;
|
|
50
|
+
hidden?: (row: T, index: number) => boolean;
|
|
51
|
+
disabled?: (row: T, index: number) => boolean;
|
|
52
|
+
/** `false` retire la confirmation que `delete` a par défaut. */
|
|
53
|
+
confirm?: RowAction<T>["confirm"] | false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CrudCreateOptions {
|
|
57
|
+
label?: ReactNode;
|
|
58
|
+
icon?: ReactNode;
|
|
59
|
+
permission?: PermissionRule;
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface CrudPageBase<T> {
|
|
64
|
+
/** À défaut, le pluriel de la ressource. */
|
|
65
|
+
title?: ReactNode;
|
|
66
|
+
description?: ReactNode;
|
|
67
|
+
eyebrow?: ReactNode;
|
|
68
|
+
|
|
69
|
+
data: T[];
|
|
70
|
+
getRowKey?: (row: T, index: number) => Key;
|
|
71
|
+
|
|
72
|
+
/* ───────────────────────────────── les quatre opérations, par défaut */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ce que fait le bouton de création.
|
|
76
|
+
*
|
|
77
|
+
* Facultatif : avec `fields`, la page ouvre elle-même son formulaire. Le
|
|
78
|
+
* fournir sert à partir ailleurs — une page dédiée, un assistant en
|
|
79
|
+
* plusieurs étapes — et prend alors le pas sur la boîte.
|
|
80
|
+
*/
|
|
81
|
+
onCreate?: () => void;
|
|
82
|
+
/** Idem : sans lui, la boîte de détail s'ouvre. */
|
|
83
|
+
onView?: (row: T, index: number) => void;
|
|
84
|
+
/** Idem : sans lui, le formulaire s'ouvre prérempli. */
|
|
85
|
+
onEdit?: (row: T, index: number) => void;
|
|
86
|
+
/** Confirmée d'office. Détruire sans demander est une faute. */
|
|
87
|
+
onDelete?: (row: T, index: number) => void | Promise<void>;
|
|
88
|
+
|
|
89
|
+
/* ──────────────────────────────────── le formulaire de la page */
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Les champs de création et de modification.
|
|
93
|
+
*
|
|
94
|
+
* Les déclarer suffit à obtenir un CRUD complet : le bouton « Créer » ouvre
|
|
95
|
+
* un formulaire vide, « Modifier » le même prérempli, « Consulter » la
|
|
96
|
+
* ligne en lecture. C'est la même description que `Form` — aucun schéma
|
|
97
|
+
* propre à cette page, aucune bibliothèque imposée.
|
|
98
|
+
*/
|
|
99
|
+
fields?: FormEntry[];
|
|
100
|
+
|
|
101
|
+
/** Ce que la page fait d'un formulaire envoyé. */
|
|
102
|
+
onSubmit?: (
|
|
103
|
+
values: FormShape,
|
|
104
|
+
context: { mode: "create" | "edit"; row?: T; index?: number },
|
|
105
|
+
) => void | Promise<void>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* La validation, sous la même forme que `Form`.
|
|
109
|
+
*
|
|
110
|
+
* `createValidator` de `@sia-ui/utils/validation` en produit une; un Zod s'y
|
|
111
|
+
* branche en cinq lignes, sans que le composant connaisse Zod.
|
|
112
|
+
*/
|
|
113
|
+
validate?: FormValidator<FormShape>;
|
|
114
|
+
|
|
115
|
+
/** Valeurs de départ d'une création. Par défaut, des champs vides. */
|
|
116
|
+
createDefaults?: FormShape;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Comment une ligne devient des valeurs de formulaire.
|
|
120
|
+
*
|
|
121
|
+
* Par défaut, les champs de même nom sont repris tels quels. À fournir dès
|
|
122
|
+
* que la ligne et le formulaire ne parlent pas la même langue — une date
|
|
123
|
+
* ISO à découper, un objet lié à réduire à son identifiant.
|
|
124
|
+
*/
|
|
125
|
+
toFormValues?: (row: T) => FormShape;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* La vue de détail.
|
|
129
|
+
*
|
|
130
|
+
* Par défaut dérivée des colonnes, qui disent déjà quoi montrer. Une
|
|
131
|
+
* fonction la remplace, `false` la retire avec son action.
|
|
132
|
+
*/
|
|
133
|
+
detail?: boolean | ((row: T, index: number) => ReactNode);
|
|
134
|
+
|
|
135
|
+
/** Titres, largeur et nombre de colonnes des boîtes. */
|
|
136
|
+
dialog?: CrudDialogOptions;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* De quoi s'écarter des défauts.
|
|
140
|
+
*
|
|
141
|
+
* `false` retire une action alors même que son gestionnaire existe — utile
|
|
142
|
+
* quand le gestionnaire sert ailleurs, par exemple à un raccourci clavier.
|
|
143
|
+
*/
|
|
144
|
+
operations?: {
|
|
145
|
+
create?: CrudCreateOptions | false;
|
|
146
|
+
view?: CrudActionOptions<T> | false;
|
|
147
|
+
edit?: CrudActionOptions<T> | false;
|
|
148
|
+
delete?: CrudActionOptions<T> | false;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Des actions de ligne en plus des quatre standard. */
|
|
152
|
+
extraRowActions?: Array<RowAction<T>> | ((row: T, index: number) => Array<RowAction<T>>);
|
|
153
|
+
|
|
154
|
+
/** Suppression groupée. Ajoute la sélection et sa barre d'actions. */
|
|
155
|
+
onBulkDelete?: (keys: Key[], rows: T[]) => void | Promise<void>;
|
|
156
|
+
|
|
157
|
+
can?: (rule: PermissionRule) => boolean;
|
|
158
|
+
|
|
159
|
+
/* ────────────────────────────────────────────────── autour du tableau */
|
|
160
|
+
|
|
161
|
+
/** Actions de page, à droite du titre. S'ajoute au bouton de création. */
|
|
162
|
+
headerActions?: ReactNode;
|
|
163
|
+
toolbar?: ReactNode;
|
|
164
|
+
searchKeys?: string[];
|
|
165
|
+
searchPlaceholder?: string;
|
|
166
|
+
onSearch?: (query: string) => void;
|
|
167
|
+
|
|
168
|
+
loading?: boolean;
|
|
169
|
+
error?: ReactNode;
|
|
170
|
+
onRetry?: () => void;
|
|
171
|
+
empty?: DataTableProps<T>["empty"];
|
|
172
|
+
|
|
173
|
+
page?: number;
|
|
174
|
+
totalPages?: number;
|
|
175
|
+
onPageChange?: (page: number) => void;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Ce qui n'a pas de raccourci ici passe au tableau tel quel — densité,
|
|
179
|
+
* mode cartes, tri, colonnes figées.
|
|
180
|
+
*/
|
|
181
|
+
table?: Partial<Omit<DataTableProps<T>, "columns" | "data">>;
|
|
182
|
+
|
|
183
|
+
/** Remplace le tableau. Le reste de la page continue de fonctionner. */
|
|
184
|
+
children?: ReactNode;
|
|
185
|
+
|
|
186
|
+
className?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Les props de la page.
|
|
191
|
+
*
|
|
192
|
+
* Deux façons de la remplir, et une seule des deux à la fois : une ressource
|
|
193
|
+
* déclarée, d'où tout se déduit, ou des colonnes écrites à la main. L'union
|
|
194
|
+
* est là pour que TypeScript exige l'une ou l'autre — une page sans colonnes
|
|
195
|
+
* ni ressource ne montrerait rien, et le dirait à l'exécution.
|
|
196
|
+
*/
|
|
197
|
+
export type CrudPageProps<T> = CrudPageBase<T> &
|
|
198
|
+
(
|
|
199
|
+
| {
|
|
200
|
+
/**
|
|
201
|
+
* La ressource, déclarée une fois avec `defineResource`.
|
|
202
|
+
*
|
|
203
|
+
* Colonnes, formulaire, détail, validation, recherche, clé de ligne
|
|
204
|
+
* et droits en sont dérivés. Chacun reste remplaçable — `columns`,
|
|
205
|
+
* `fields`, `validate`, `searchKeys` passent devant la dérivation.
|
|
206
|
+
*/
|
|
207
|
+
resource: ResourceDefinition<T>;
|
|
208
|
+
columns?: Array<DataTableColumn<T>>;
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
resource?: undefined;
|
|
212
|
+
columns: Array<DataTableColumn<T>>;
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
/** Une icône de crayon. Dessinée ici : le jeu commun n'en a pas. */
|
|
217
|
+
function EditIcon() {
|
|
218
|
+
return (
|
|
219
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
220
|
+
<path
|
|
221
|
+
d="M4 20h4l10-10a2.5 2.5 0 0 0-3.5-3.5L4.5 16.5 4 20Z"
|
|
222
|
+
fill="none"
|
|
223
|
+
stroke="currentColor"
|
|
224
|
+
strokeWidth="1.7"
|
|
225
|
+
strokeLinejoin="round"
|
|
226
|
+
/>
|
|
227
|
+
</svg>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Une page de liste, avec ses quatre opérations.
|
|
233
|
+
*
|
|
234
|
+
* Créer, consulter, modifier, supprimer : ce sont les mêmes partout, et les
|
|
235
|
+
* réécrire à chaque écran produit quatre variantes qui divergent — celle qui
|
|
236
|
+
* oublie la confirmation, celle qui ne vérifie pas les droits, celle dont le
|
|
237
|
+
* bouton ne se désactive pas pendant l'appel.
|
|
238
|
+
*
|
|
239
|
+
* Ici, fournir le gestionnaire suffit. Ne pas le fournir retire l'action.
|
|
240
|
+
*/
|
|
241
|
+
export function CrudPage<T>({
|
|
242
|
+
resource,
|
|
243
|
+
title,
|
|
244
|
+
description,
|
|
245
|
+
eyebrow,
|
|
246
|
+
columns: colonnesFournies,
|
|
247
|
+
data,
|
|
248
|
+
getRowKey: cleFournie,
|
|
249
|
+
onCreate,
|
|
250
|
+
onView,
|
|
251
|
+
onEdit,
|
|
252
|
+
onDelete,
|
|
253
|
+
fields: champsFournis,
|
|
254
|
+
onSubmit,
|
|
255
|
+
validate: validationFournie,
|
|
256
|
+
createDefaults,
|
|
257
|
+
toFormValues,
|
|
258
|
+
detail,
|
|
259
|
+
dialog,
|
|
260
|
+
operations,
|
|
261
|
+
extraRowActions,
|
|
262
|
+
onBulkDelete,
|
|
263
|
+
can,
|
|
264
|
+
headerActions,
|
|
265
|
+
toolbar,
|
|
266
|
+
searchKeys: clesDeRecherche,
|
|
267
|
+
searchPlaceholder,
|
|
268
|
+
onSearch,
|
|
269
|
+
loading = false,
|
|
270
|
+
error,
|
|
271
|
+
onRetry,
|
|
272
|
+
empty,
|
|
273
|
+
page,
|
|
274
|
+
totalPages,
|
|
275
|
+
onPageChange,
|
|
276
|
+
table,
|
|
277
|
+
children,
|
|
278
|
+
className,
|
|
279
|
+
}: CrudPageProps<T>) {
|
|
280
|
+
const [boite, setBoite] = useState<CrudDialogState<T> | null>(null);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Ce que la ressource donne, et ce qui passe devant.
|
|
284
|
+
*
|
|
285
|
+
* Chaque dérivation cède à la prop correspondante : déclarer une ressource
|
|
286
|
+
* ne doit jamais empêcher de reprendre la main sur un point précis. Les
|
|
287
|
+
* dérivations sont mémoïsées — elles parcourent la déclaration entière, et
|
|
288
|
+
* une page de liste se rend à chaque frappe dans sa barre de recherche.
|
|
289
|
+
*/
|
|
290
|
+
const derive = useMemo(
|
|
291
|
+
() =>
|
|
292
|
+
resource
|
|
293
|
+
? {
|
|
294
|
+
columns: resourceColumns(resource),
|
|
295
|
+
detailColumns: resourceDetailColumns(resource),
|
|
296
|
+
fields: resourceFormFields(resource),
|
|
297
|
+
validate: resourceValidator(resource),
|
|
298
|
+
searchKeys: resourceSearchKeys(resource),
|
|
299
|
+
defaults: resourceDefaults(resource),
|
|
300
|
+
getRowKey: resourceRowKey(resource),
|
|
301
|
+
permissions: resourcePermissions(resource),
|
|
302
|
+
formColumns: resource.formColumns,
|
|
303
|
+
label: resource.label,
|
|
304
|
+
}
|
|
305
|
+
: undefined,
|
|
306
|
+
[resource],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const columns = colonnesFournies ?? derive?.columns ?? [];
|
|
310
|
+
const fields = champsFournis ?? derive?.fields;
|
|
311
|
+
const validate = validationFournie ?? derive?.validate;
|
|
312
|
+
const searchKeys = clesDeRecherche ?? derive?.searchKeys;
|
|
313
|
+
const getRowKey = cleFournie ?? derive?.getRowKey;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Qui ouvre quoi.
|
|
317
|
+
*
|
|
318
|
+
* Le gestionnaire fourni l'emporte toujours : c'est la porte de sortie vers
|
|
319
|
+
* une page dédiée. À défaut, la page ouvre sa propre boîte — dès lors
|
|
320
|
+
* qu'elle a de quoi la remplir. Une action reste absente quand ni l'un ni
|
|
321
|
+
* l'autre n'est possible : un bouton qui n'ouvre rien est pire qu'un bouton
|
|
322
|
+
* manquant.
|
|
323
|
+
*/
|
|
324
|
+
const formulaireDispo = Boolean(fields && onSubmit);
|
|
325
|
+
const detailDispo =
|
|
326
|
+
detail === undefined ? Boolean(fields) : detail !== false;
|
|
327
|
+
|
|
328
|
+
/*
|
|
329
|
+
Mémoïsés, et pas par principe : ces trois fonctions entrent dans les
|
|
330
|
+
dépendances des actions de ligne. Recréées à chaque rendu, elles
|
|
331
|
+
annuleraient cette mémoïsation — et une page de liste se rend à chaque
|
|
332
|
+
frappe dans sa barre de recherche.
|
|
333
|
+
*/
|
|
334
|
+
const creer = useMemo(
|
|
335
|
+
() =>
|
|
336
|
+
onCreate ??
|
|
337
|
+
(formulaireDispo ? () => setBoite({ mode: "create" }) : undefined),
|
|
338
|
+
[onCreate, formulaireDispo],
|
|
339
|
+
);
|
|
340
|
+
const modifier = useMemo(
|
|
341
|
+
() =>
|
|
342
|
+
onEdit ??
|
|
343
|
+
(formulaireDispo
|
|
344
|
+
? (row: T, index: number) => setBoite({ mode: "edit", row, index })
|
|
345
|
+
: undefined),
|
|
346
|
+
[onEdit, formulaireDispo],
|
|
347
|
+
);
|
|
348
|
+
const consulter = useMemo(
|
|
349
|
+
() =>
|
|
350
|
+
onView ??
|
|
351
|
+
(detailDispo
|
|
352
|
+
? (row: T, index: number) => setBoite({ mode: "view", row, index })
|
|
353
|
+
: undefined),
|
|
354
|
+
[onView, detailDispo],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Les actions de ligne, dans l'ordre où on les lit : consulter, modifier,
|
|
359
|
+
* supprimer. La destruction en dernier, et jamais à côté d'une action
|
|
360
|
+
* bénigne qu'on viserait de travers.
|
|
361
|
+
*/
|
|
362
|
+
const actionsDeLigne = useMemo(() => {
|
|
363
|
+
const liste: Array<RowAction<T>> = [];
|
|
364
|
+
|
|
365
|
+
const ajouter = (
|
|
366
|
+
key: "view" | "edit" | "delete",
|
|
367
|
+
handler: ((row: T, index: number) => void | Promise<void>) | undefined,
|
|
368
|
+
defauts: Omit<RowAction<T>, "key" | "onSelect">,
|
|
369
|
+
) => {
|
|
370
|
+
const options = operations?.[key];
|
|
371
|
+
if (!handler || options === false) return;
|
|
372
|
+
|
|
373
|
+
// Le droit déclaré sur l'opération l'emporte sur celui de la ressource,
|
|
374
|
+
// qui n'est qu'une convention appliquée faute de mieux.
|
|
375
|
+
const droit = options?.permission ?? derive?.permissions[key];
|
|
376
|
+
|
|
377
|
+
const confirmation =
|
|
378
|
+
options?.confirm === false
|
|
379
|
+
? undefined
|
|
380
|
+
: (options?.confirm ?? defauts.confirm);
|
|
381
|
+
|
|
382
|
+
liste.push({
|
|
383
|
+
key,
|
|
384
|
+
label: options?.label ?? defauts.label,
|
|
385
|
+
icon: options?.icon ?? defauts.icon,
|
|
386
|
+
...(defauts.tone ? { tone: defauts.tone } : {}),
|
|
387
|
+
...(droit !== undefined ? { permission: droit } : {}),
|
|
388
|
+
...(options?.hidden ? { hidden: options.hidden } : {}),
|
|
389
|
+
...(options?.disabled ? { disabled: options.disabled } : {}),
|
|
390
|
+
...(confirmation ? { confirm: confirmation } : {}),
|
|
391
|
+
onSelect: handler,
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
ajouter("view", consulter, { label: "Consulter", icon: <EyeIcon /> });
|
|
396
|
+
ajouter("edit", modifier, { label: "Modifier", icon: <EditIcon /> });
|
|
397
|
+
ajouter("delete", onDelete, {
|
|
398
|
+
label: "Supprimer",
|
|
399
|
+
icon: <TrashIcon />,
|
|
400
|
+
tone: "danger",
|
|
401
|
+
// Détruire sans demander est une faute. On peut la retirer — encore
|
|
402
|
+
// faut-il l'avoir écrit.
|
|
403
|
+
confirm: {
|
|
404
|
+
title: "Supprimer cet élément ?",
|
|
405
|
+
description: "Cette action est définitive.",
|
|
406
|
+
confirmLabel: "Supprimer",
|
|
407
|
+
destructive: true,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return liste;
|
|
412
|
+
}, [operations, onDelete, modifier, consulter, derive]);
|
|
413
|
+
|
|
414
|
+
const toutesLesActions = useMemo(() => {
|
|
415
|
+
if (!extraRowActions) {
|
|
416
|
+
return actionsDeLigne.length > 0 ? actionsDeLigne : undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return (row: T, index: number) => [
|
|
420
|
+
...actionsDeLigne,
|
|
421
|
+
...(typeof extraRowActions === "function"
|
|
422
|
+
? extraRowActions(row, index)
|
|
423
|
+
: extraRowActions),
|
|
424
|
+
];
|
|
425
|
+
}, [actionsDeLigne, extraRowActions]);
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Le bouton de création.
|
|
429
|
+
*
|
|
430
|
+
* Trois conditions, dans cet ordre : un gestionnaire, une action non
|
|
431
|
+
* retirée, et le droit de s'en servir. Un bouton qui ouvre un formulaire
|
|
432
|
+
* que le serveur refusera est pire qu'un bouton absent.
|
|
433
|
+
*/
|
|
434
|
+
const creation = operations?.create === false ? null : (operations?.create ?? {});
|
|
435
|
+
const droitDeCreer = creation?.permission ?? derive?.permissions.create;
|
|
436
|
+
const creationAutorisee =
|
|
437
|
+
droitDeCreer === undefined || !can || can(droitDeCreer);
|
|
438
|
+
|
|
439
|
+
const boutonCreer =
|
|
440
|
+
creer && creation && creationAutorisee ? (
|
|
441
|
+
<Button onClick={creer} disabled={creation.disabled ?? false}>
|
|
442
|
+
{creation.icon ?? <PlusIcon />}
|
|
443
|
+
{creation.label ?? derive?.label?.create ?? "Créer"}
|
|
444
|
+
</Button>
|
|
445
|
+
) : null;
|
|
446
|
+
const enTeteActions =
|
|
447
|
+
boutonCreer || headerActions ? (
|
|
448
|
+
<>
|
|
449
|
+
{headerActions}
|
|
450
|
+
{boutonCreer}
|
|
451
|
+
</>
|
|
452
|
+
) : undefined;
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<section className={cn("sia-crud-page", className)}>
|
|
456
|
+
<PageHeader
|
|
457
|
+
title={title ?? derive?.label?.plural ?? resource?.name ?? ""}
|
|
458
|
+
{...(description ?? resource?.description
|
|
459
|
+
? { description: description ?? resource?.description }
|
|
460
|
+
: {})}
|
|
461
|
+
{...(eyebrow ? { eyebrow } : {})}
|
|
462
|
+
{...(enTeteActions ? { actions: enTeteActions } : {})}
|
|
463
|
+
/>
|
|
464
|
+
|
|
465
|
+
{children ?? (
|
|
466
|
+
<DataTable<T>
|
|
467
|
+
columns={columns}
|
|
468
|
+
data={data}
|
|
469
|
+
{...(getRowKey ? { getRowKey } : {})}
|
|
470
|
+
{...(toolbar ? { toolbar } : {})}
|
|
471
|
+
{...(searchKeys ? { searchKeys } : {})}
|
|
472
|
+
{...(searchPlaceholder ? { searchPlaceholder } : {})}
|
|
473
|
+
{...(onSearch ? { onSearch } : {})}
|
|
474
|
+
{...(toutesLesActions ? { rowActions: toutesLesActions } : {})}
|
|
475
|
+
{...(can ? { can } : {})}
|
|
476
|
+
loading={loading}
|
|
477
|
+
{...(error ? { error } : {})}
|
|
478
|
+
{...(onRetry ? { onRetry } : {})}
|
|
479
|
+
{...(empty ? { empty } : {})}
|
|
480
|
+
{...(onBulkDelete
|
|
481
|
+
? {
|
|
482
|
+
selectable: true,
|
|
483
|
+
selectionActions: ({ keys, rows, clear }) => (
|
|
484
|
+
<Button
|
|
485
|
+
size="sm"
|
|
486
|
+
variant="outline"
|
|
487
|
+
tone="danger"
|
|
488
|
+
onClick={async () => {
|
|
489
|
+
await onBulkDelete(keys, rows);
|
|
490
|
+
clear();
|
|
491
|
+
}}
|
|
492
|
+
>
|
|
493
|
+
<TrashIcon />
|
|
494
|
+
Supprimer
|
|
495
|
+
</Button>
|
|
496
|
+
),
|
|
497
|
+
}
|
|
498
|
+
: {})}
|
|
499
|
+
/*
|
|
500
|
+
Trois actions en ligne plutôt que deux : consulter, modifier et
|
|
501
|
+
supprimer sont précisément ce que cette page existe pour offrir.
|
|
502
|
+
La limite du tableau, elle, reste à deux — un tableau quelconque
|
|
503
|
+
n'a pas de raison d'étaler ses boutons.
|
|
504
|
+
*/
|
|
505
|
+
inlineActionsLimit={3}
|
|
506
|
+
{...table}
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
<CrudDialogs<T>
|
|
511
|
+
state={boite}
|
|
512
|
+
onClose={() => setBoite(null)}
|
|
513
|
+
columns={derive?.detailColumns ?? columns}
|
|
514
|
+
fields={fields}
|
|
515
|
+
validate={validate}
|
|
516
|
+
onSubmit={onSubmit}
|
|
517
|
+
createDefaults={createDefaults ?? derive?.defaults}
|
|
518
|
+
toFormValues={toFormValues}
|
|
519
|
+
renderDetail={typeof detail === "function" ? detail : undefined}
|
|
520
|
+
getRowKey={getRowKey}
|
|
521
|
+
/*
|
|
522
|
+
Les titres des boîtes reprennent le nom de la ressource, faute de
|
|
523
|
+
mieux : « Modifier la facture » se lit mieux que « Modifier », et
|
|
524
|
+
l'écrire trois fois par page de liste est précisément ce que cette
|
|
525
|
+
déclaration existe pour éviter.
|
|
526
|
+
*/
|
|
527
|
+
dialog={{
|
|
528
|
+
...(derive?.label?.singular
|
|
529
|
+
? {
|
|
530
|
+
createTitle: derive.label.create ?? "Créer",
|
|
531
|
+
editTitle: <>Modifier — {derive.label.singular}</>,
|
|
532
|
+
viewTitle: derive.label.singular,
|
|
533
|
+
}
|
|
534
|
+
: {}),
|
|
535
|
+
...(derive?.formColumns ? { columns: derive.formColumns } : {}),
|
|
536
|
+
...dialog,
|
|
537
|
+
}}
|
|
538
|
+
/>
|
|
539
|
+
|
|
540
|
+
{page !== undefined && totalPages !== undefined && onPageChange && (
|
|
541
|
+
<Pagination
|
|
542
|
+
page={page}
|
|
543
|
+
totalPages={totalPages}
|
|
544
|
+
onPageChange={onPageChange}
|
|
545
|
+
showTotal
|
|
546
|
+
/>
|
|
547
|
+
)}
|
|
548
|
+
</section>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
@@ -1 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* La page de liste.
|
|
3
|
+
*
|
|
4
|
+
* Presque rien : le titre, le tableau et la pagination portent leur propre
|
|
5
|
+
* style. Ce qui reste est l'espacement entre eux, et c'est précisément ce
|
|
6
|
+
* qu'une page ne doit pas redéfinir écran par écran.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.sia-crud-page {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
gap: 1.25rem;
|
|
13
|
+
min-width: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* La pagination se détache du tableau sans ligne supplémentaire : le tableau
|
|
17
|
+
en a déjà une sous sa dernière ligne. */
|
|
18
|
+
.sia-crud-page > .sia-pagination {
|
|
19
|
+
align-self: center;
|
|
20
|
+
margin-top: .25rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@media (max-width: 47.9375rem) {
|
|
24
|
+
.sia-crud-page { gap: 1rem; }
|
|
25
|
+
}
|
|
@@ -4,6 +4,13 @@ import "./styles.css";
|
|
|
4
4
|
|
|
5
5
|
export interface CurrencyInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "inputMode"> { value?: number | null; defaultValue?: number | null; currency?: string; locale?: string; onValueChange?: (value: number | null) => void; invalid?: boolean; }
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* La saisie d'un montant.
|
|
9
|
+
*
|
|
10
|
+
* La valeur rendue est un nombre, jamais la chaîne affichée : les espaces
|
|
11
|
+
* de milliers et le symbole appartiennent à l'affichage, et un serveur qui
|
|
12
|
+
* reçoit « 1 250 000 F CFA » ne peut rien en faire.
|
|
13
|
+
*/
|
|
7
14
|
export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(({ value, defaultValue = null, currency = "XAF", locale = "fr-FR", onValueChange, invalid, className, onBlur, onFocus, ...props }, ref) => {
|
|
8
15
|
const [focused, setFocused] = useState(false);
|
|
9
16
|
const [internal, setInternal] = useState<number | null>(defaultValue);
|