@zauru-sdk/components 6.9.0 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ import type {
2
+ EmployeeGraphQL,
3
+ SelectFieldOption,
4
+ WebAppRowGraphQL,
5
+ WebappEmployeeRole,
6
+ WebappRole,
7
+ } from "@zauru-sdk/types";
8
+ import { isSuperAdminEmail } from "@zauru-sdk/utils";
9
+ import { useMemo, type ReactNode } from "react";
10
+ import { z } from "zod";
11
+ import { Button } from "../Buttons/Button.js";
12
+ import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
13
+ import { DoubleFieldContainer } from "../Form/FieldContainer/DoubleFieldContainer.js";
14
+ import { ReactZodForm } from "../Form/ReactZodForm/index.js";
15
+ import { SelectField } from "../Form/SelectField/index.js";
16
+ import { ZauruTable } from "../Table/ZauruTable.js";
17
+
18
+ const addEmployeeSchema = z.object({
19
+ employee_id: z.coerce.number().min(1, "El empleado es requerido"),
20
+ role_id: z.coerce.number().min(1, "El rol es requerido"),
21
+ });
22
+
23
+ export type WebappEmployeeAssignmentChange<
24
+ TAssignment extends WebappEmployeeRole,
25
+ > = {
26
+ employee: EmployeeGraphQL;
27
+ assignment?: WebAppRowGraphQL<TAssignment>;
28
+ roleId: number | null;
29
+ };
30
+
31
+ export type WebappEmployeeAssignmentDetails<
32
+ TAssignment extends WebappEmployeeRole,
33
+ > = {
34
+ employee: EmployeeGraphQL;
35
+ assignment?: WebAppRowGraphQL<TAssignment>;
36
+ disabled: boolean;
37
+ };
38
+
39
+ type Props<TAssignment extends WebappEmployeeRole> = {
40
+ employees: EmployeeGraphQL[];
41
+ roles: WebAppRowGraphQL<WebappRole>[];
42
+ assignments: WebAppRowGraphQL<TAssignment>[];
43
+ canAssign?: boolean;
44
+ loading?: boolean;
45
+ description?: string;
46
+ superAdminLabel?: string;
47
+ addEmployeeTitle?: string;
48
+ addEmployeeDescription?: string;
49
+ noEmployeesToAddText?: string;
50
+ noRolesText?: string;
51
+ assignmentDetailsColumn?: {
52
+ name: string;
53
+ minWidth?: string;
54
+ grow?: number;
55
+ };
56
+ renderAssignmentDetails?: (
57
+ details: WebappEmployeeAssignmentDetails<TAssignment>,
58
+ ) => ReactNode;
59
+ superAdminEmailSuffixes?: readonly string[];
60
+ onAssignRole: (
61
+ change: WebappEmployeeAssignmentChange<TAssignment>,
62
+ ) => void;
63
+ onAddEmployee: (employeeId: number, roleId: number) => void;
64
+ };
65
+
66
+ export function WebappEmployeeAccess<
67
+ TAssignment extends WebappEmployeeRole = WebappEmployeeRole,
68
+ >({
69
+ employees,
70
+ roles,
71
+ assignments,
72
+ canAssign = false,
73
+ loading = false,
74
+ description =
75
+ "Solo aparecen quienes ya tienen acceso: rol asignado o super admin. Quitar el rol los saca de la lista.",
76
+ superAdminLabel = "Super admin (todos los permisos)",
77
+ addEmployeeTitle = "Agregar empleado",
78
+ addEmployeeDescription =
79
+ "Busca entre quienes aún no están en la lista y asígnales un rol.",
80
+ noEmployeesToAddText = "No quedan empleados activos por agregar.",
81
+ noRolesText = "Crea al menos un rol antes de agregar empleados.",
82
+ assignmentDetailsColumn,
83
+ renderAssignmentDetails,
84
+ superAdminEmailSuffixes,
85
+ onAssignRole,
86
+ onAddEmployee,
87
+ }: Props<TAssignment>) {
88
+ const roleOptions: SelectFieldOption[] = useMemo(
89
+ () => [
90
+ { value: "", label: "Sin rol" },
91
+ ...roles.map((role) => ({
92
+ value: String(role.id),
93
+ label: role.data.name,
94
+ })),
95
+ ],
96
+ [roles],
97
+ );
98
+
99
+ const addRoleOptions: SelectFieldOption[] = useMemo(
100
+ () =>
101
+ roles.map((role) => ({
102
+ value: String(role.id),
103
+ label: role.data.name,
104
+ })),
105
+ [roles],
106
+ );
107
+
108
+ const assignmentByEmployee = useMemo(() => {
109
+ const map = new Map<number, WebAppRowGraphQL<TAssignment>>();
110
+ for (const row of assignments) {
111
+ map.set(Number(row.data.employee_id), row);
112
+ }
113
+ return map;
114
+ }, [assignments]);
115
+
116
+ const listedEmployees = useMemo(
117
+ () =>
118
+ employees
119
+ .filter(
120
+ (employee) =>
121
+ isSuperAdminEmail(employee.email, superAdminEmailSuffixes) ||
122
+ assignmentByEmployee.has(employee.id),
123
+ )
124
+ .sort((a, b) => a.name.localeCompare(b.name)),
125
+ [assignmentByEmployee, employees, superAdminEmailSuffixes],
126
+ );
127
+
128
+ const addEmployeeOptions: SelectFieldOption[] = useMemo(() => {
129
+ const listedIds = new Set(listedEmployees.map((employee) => employee.id));
130
+ return employees
131
+ .filter((employee) => !listedIds.has(employee.id))
132
+ .sort((a, b) => a.name.localeCompare(b.name))
133
+ .map((employee) => ({
134
+ value: employee.id,
135
+ label: employee.email
136
+ ? `${employee.name} (${employee.email})`
137
+ : employee.name,
138
+ }));
139
+ }, [employees, listedEmployees]);
140
+
141
+ const columns = [
142
+ {
143
+ name: "Empleado",
144
+ sortable: true,
145
+ grow: 1,
146
+ minWidth: "12rem",
147
+ selector: (employee: EmployeeGraphQL) => employee.name,
148
+ },
149
+ {
150
+ name: "Email",
151
+ sortable: true,
152
+ grow: 1,
153
+ minWidth: "14rem",
154
+ selector: (employee: EmployeeGraphQL) => employee.email || "—",
155
+ },
156
+ {
157
+ name: "Rol",
158
+ minWidth: "14rem",
159
+ cell: (employee: EmployeeGraphQL) => {
160
+ const assignment = assignmentByEmployee.get(employee.id);
161
+ const superAdmin = isSuperAdminEmail(
162
+ employee.email,
163
+ superAdminEmailSuffixes,
164
+ );
165
+ const currentRole = assignment
166
+ ? String(assignment.data.role_id)
167
+ : "";
168
+
169
+ return (
170
+ <div className="w-56 py-2">
171
+ {superAdmin ? (
172
+ <p className="text-sm text-gray-600">{superAdminLabel}</p>
173
+ ) : (
174
+ <SelectField
175
+ name={`role_${employee.id}`}
176
+ options={roleOptions}
177
+ defaultValue={
178
+ roleOptions.find(
179
+ (option) => option.value === currentRole,
180
+ ) ?? roleOptions[0]
181
+ }
182
+ disabled={!canAssign || loading}
183
+ onChange={(option) => {
184
+ const value = option?.value?.toString() ?? "";
185
+ onAssignRole({
186
+ employee,
187
+ assignment,
188
+ roleId: value ? Number(value) : null,
189
+ });
190
+ }}
191
+ />
192
+ )}
193
+ </div>
194
+ );
195
+ },
196
+ },
197
+ ...(assignmentDetailsColumn && renderAssignmentDetails
198
+ ? [
199
+ {
200
+ name: assignmentDetailsColumn.name,
201
+ minWidth: assignmentDetailsColumn.minWidth ?? "16rem",
202
+ grow: assignmentDetailsColumn.grow ?? 1,
203
+ cell: (employee: EmployeeGraphQL) => {
204
+ const assignment = assignmentByEmployee.get(employee.id);
205
+ return renderAssignmentDetails({
206
+ employee,
207
+ assignment,
208
+ disabled: !canAssign || !assignment || loading,
209
+ });
210
+ },
211
+ },
212
+ ]
213
+ : []),
214
+ ];
215
+
216
+ return (
217
+ <div className="mt-4 space-y-6">
218
+ <p className="text-sm text-gray-600">{description}</p>
219
+ <ZauruTable
220
+ columns={columns}
221
+ data={listedEmployees}
222
+ loading={loading}
223
+ offlineSearch={["name", "email"]}
224
+ />
225
+ {canAssign ? (
226
+ roles.length === 0 ? (
227
+ <p className="text-sm text-gray-600" role="status">
228
+ {noRolesText}
229
+ </p>
230
+ ) : addEmployeeOptions.length > 0 ? (
231
+ <div className="rounded-md border border-gray-200 bg-white p-4">
232
+ <ReactZodForm
233
+ schema={addEmployeeSchema}
234
+ onSubmit={(values) =>
235
+ onAddEmployee(
236
+ Number(values.employee_id),
237
+ Number(values.role_id),
238
+ )
239
+ }
240
+ >
241
+ <h2 className="text-base font-semibold text-gray-900">
242
+ {addEmployeeTitle}
243
+ </h2>
244
+ <p className="mb-4 mt-1 text-sm text-gray-600">
245
+ {addEmployeeDescription}
246
+ </p>
247
+ <DoubleFieldContainer alignCenter={false}>
248
+ <SelectField
249
+ name="employee_id"
250
+ title="Empleado"
251
+ hint="Escribe para filtrar por nombre o correo"
252
+ options={addEmployeeOptions}
253
+ required
254
+ />
255
+ <SelectField
256
+ name="role_id"
257
+ title="Rol"
258
+ options={addRoleOptions}
259
+ required
260
+ />
261
+ </DoubleFieldContainer>
262
+ <ButtonSectionContainer>
263
+ <Button loading={loading} title="Agregar" />
264
+ </ButtonSectionContainer>
265
+ </ReactZodForm>
266
+ </div>
267
+ ) : (
268
+ <p className="text-sm text-gray-600" role="status">
269
+ {noEmployeesToAddText}
270
+ </p>
271
+ )
272
+ ) : null}
273
+ </div>
274
+ );
275
+ }
@@ -0,0 +1,207 @@
1
+ import { PencilSvg, TrashSvg } from "@zauru-sdk/icons";
2
+ import type {
3
+ WebAppRowGraphQL,
4
+ WebappRole,
5
+ } from "@zauru-sdk/types";
6
+ import { useSyncExternalStore } from "react";
7
+ import { z } from "zod";
8
+ import { Button } from "../Buttons/Button.js";
9
+ import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
10
+ import { ReactZodForm } from "../Form/ReactZodForm/index.js";
11
+ import { TextField } from "../Form/TextField/index.js";
12
+ import { createModal } from "../Modal/Modal.js";
13
+ import { ZauruTable } from "../Table/ZauruTable.js";
14
+
15
+ const roleSchema = z.object({
16
+ name: z.string().min(1, "El nombre es requerido"),
17
+ description: z.string().optional(),
18
+ });
19
+
20
+ const subscribeToBrowser = () => () => undefined;
21
+ const getBrowserSnapshot = () => true;
22
+ const getServerSnapshot = () => false;
23
+
24
+ export type WebappRoleValues = {
25
+ name: string;
26
+ description: string;
27
+ };
28
+
29
+ type Props = {
30
+ roles: WebAppRowGraphQL<WebappRole>[];
31
+ canCreate?: boolean;
32
+ canEdit?: boolean;
33
+ canDelete?: boolean;
34
+ loading?: boolean;
35
+ createTitle?: string;
36
+ createButtonLabel?: string;
37
+ editTitle?: string;
38
+ deleteTitle?: string;
39
+ deleteDescription?: string;
40
+ onCreate: (values: WebappRoleValues) => void;
41
+ onUpdate: (roleId: number, values: WebappRoleValues) => void;
42
+ onDelete: (roleId: number) => void;
43
+ };
44
+
45
+ export function WebappRoleManagement({
46
+ roles,
47
+ canCreate = false,
48
+ canEdit = false,
49
+ canDelete = false,
50
+ loading = false,
51
+ createTitle = "Crear rol",
52
+ createButtonLabel = "Crear rol",
53
+ editTitle = "Editar rol",
54
+ deleteTitle = "¿Eliminar este rol?",
55
+ deleteDescription = "No se puede eliminar si hay empleados asignados.",
56
+ onCreate,
57
+ onUpdate,
58
+ onDelete,
59
+ }: Props) {
60
+ const isClient = useSyncExternalStore(
61
+ subscribeToBrowser,
62
+ getBrowserSnapshot,
63
+ getServerSnapshot,
64
+ );
65
+
66
+ const editRole = async (role: WebAppRowGraphQL<WebappRole>) => {
67
+ let name = role.data.name;
68
+ let description = role.data.description ?? "";
69
+ const response = await createModal({
70
+ title: editTitle,
71
+ okButtonText: "Guardar",
72
+ description: (
73
+ <>
74
+ <TextField
75
+ title="Nombre"
76
+ name="name"
77
+ defaultValue={name}
78
+ onChange={(value) => {
79
+ name = value;
80
+ }}
81
+ />
82
+ <TextField
83
+ className="mt-4"
84
+ title="Descripción"
85
+ name="description"
86
+ defaultValue={description}
87
+ onChange={(value) => {
88
+ description = value;
89
+ }}
90
+ />
91
+ </>
92
+ ),
93
+ });
94
+
95
+ if (response === "OK") {
96
+ onUpdate(role.id, { name, description });
97
+ }
98
+ };
99
+
100
+ const confirmDelete = async (roleId: number) => {
101
+ const response = await createModal({
102
+ title: deleteTitle,
103
+ description: deleteDescription,
104
+ });
105
+ if (response === "OK") {
106
+ onDelete(roleId);
107
+ }
108
+ };
109
+
110
+ const columns = [
111
+ {
112
+ name: "Nombre",
113
+ sortable: true,
114
+ grow: 1,
115
+ selector: (role: WebAppRowGraphQL<WebappRole>) => role.data.name,
116
+ },
117
+ {
118
+ name: "Descripción",
119
+ grow: 2,
120
+ selector: (role: WebAppRowGraphQL<WebappRole>) =>
121
+ role.data.description || "—",
122
+ },
123
+ {
124
+ name: "Permisos",
125
+ width: "8%",
126
+ selector: (role: WebAppRowGraphQL<WebappRole>) =>
127
+ role.data.permission_keys?.length ?? 0,
128
+ },
129
+ {
130
+ name: "",
131
+ grow: 0,
132
+ minWidth: "90px",
133
+ maxWidth: "90px",
134
+ cell: (role: WebAppRowGraphQL<WebappRole>) => (
135
+ <div className="flex gap-2">
136
+ {canDelete ? (
137
+ <button
138
+ type="button"
139
+ title="Eliminar"
140
+ aria-label={`Eliminar rol ${role.data.name}`}
141
+ disabled={loading}
142
+ className="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
143
+ onClick={() => void confirmDelete(role.id)}
144
+ >
145
+ <TrashSvg />
146
+ </button>
147
+ ) : null}
148
+ {canEdit ? (
149
+ <button
150
+ type="button"
151
+ title="Editar"
152
+ aria-label={`Editar rol ${role.data.name}`}
153
+ disabled={loading}
154
+ className="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
155
+ onClick={() => void editRole(role)}
156
+ >
157
+ <PencilSvg />
158
+ </button>
159
+ ) : null}
160
+ </div>
161
+ ),
162
+ },
163
+ ];
164
+
165
+ if (!isClient) {
166
+ return null;
167
+ }
168
+
169
+ return (
170
+ <div className="mt-4 space-y-6">
171
+ <ZauruTable
172
+ columns={columns}
173
+ data={[...roles].sort((a, b) =>
174
+ a.data.name.localeCompare(b.data.name),
175
+ )}
176
+ loading={loading}
177
+ offlineSearch={["data.name", "data.description"]}
178
+ />
179
+ {canCreate ? (
180
+ <div className="rounded-md border border-gray-200 bg-white p-4">
181
+ <ReactZodForm
182
+ schema={roleSchema}
183
+ onSubmit={(values) =>
184
+ onCreate({
185
+ name: String(values.name ?? ""),
186
+ description: String(values.description ?? ""),
187
+ })
188
+ }
189
+ >
190
+ <h2 className="mb-4 text-base font-semibold text-gray-900">
191
+ {createTitle}
192
+ </h2>
193
+ <TextField name="name" title="Nombre del rol" required />
194
+ <TextField
195
+ className="mt-4"
196
+ name="description"
197
+ title="Descripción"
198
+ />
199
+ <ButtonSectionContainer>
200
+ <Button loading={loading} title={createButtonLabel} />
201
+ </ButtonSectionContainer>
202
+ </ReactZodForm>
203
+ </div>
204
+ ) : null}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./WebappEmployeeAccess.js";
2
+ export * from "./WebappRoleManagement.js";
@@ -4,6 +4,7 @@ import React, {
4
4
  useEffect,
5
5
  useState,
6
6
  useRef,
7
+ useId,
7
8
  KeyboardEvent,
8
9
  useCallback,
9
10
  } from "react";
@@ -89,6 +90,10 @@ export const SelectField = (props: Props) => {
89
90
  );
90
91
  const [isTabPressed, setIsTabPressed] = useState<boolean>(false);
91
92
  const [isSearching, setIsSearching] = useState<boolean>(false);
93
+ const generatedInputId = useId();
94
+ const inputId = id ?? (name ? `${name}-input` : generatedInputId);
95
+ const errorId = `${inputId}-error`;
96
+ const hintId = `${inputId}-hint`;
92
97
 
93
98
  // Use refs to store stable references to callbacks
94
99
  const onChangeRef = useRef(onChange);
@@ -409,12 +414,11 @@ export const SelectField = (props: Props) => {
409
414
  return (
410
415
  <>
411
416
  {title && (
412
- <label
413
- htmlFor={error ? `${name}-error` : `${name}-success`}
417
+ <span
414
418
  className={`block text-sm font-medium text-${color}-700 dark:text-${color}-500`}
415
419
  >
416
420
  {title}
417
- </label>
421
+ </span>
418
422
  )}
419
423
  <LoadingInputSkeleton />
420
424
  {helpText && (
@@ -432,7 +436,7 @@ export const SelectField = (props: Props) => {
432
436
  <div className={`col-span-6 sm:col-span-3 ${className}`} ref={selectRef}>
433
437
  {title && (
434
438
  <label
435
- htmlFor={error ? `${name}-error` : `${name}-success`}
439
+ htmlFor={inputId}
436
440
  className={`block text-sm font-medium ${
437
441
  color === "red"
438
442
  ? "text-red-700 dark:text-red-500"
@@ -447,7 +451,7 @@ export const SelectField = (props: Props) => {
447
451
  <div className="flex items-center">
448
452
  <input
449
453
  type="text"
450
- id={id}
454
+ id={inputId}
451
455
  value={inputValue}
452
456
  onFocus={() => setIsOpen(true)}
453
457
  onKeyDown={handleKeyDown}
@@ -459,14 +463,21 @@ export const SelectField = (props: Props) => {
459
463
  onChange={handleInputChange}
460
464
  onBlur={handleBlur}
461
465
  required={required}
466
+ aria-invalid={Boolean(error)}
467
+ aria-describedby={error ? errorId : hint ? hintId : undefined}
462
468
  />
463
469
  {isClearable && (value || valueMulti.length > 0) && (
464
470
  <button
465
471
  type="button"
466
472
  onClick={handleClear}
467
- className="absolute inset-y-0 right-0 pr-3 flex items-center"
473
+ aria-label={
474
+ isMulti
475
+ ? "Limpiar todas las opciones seleccionadas"
476
+ : "Limpiar la opción seleccionada"
477
+ }
478
+ className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-600 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
468
479
  >
469
- ×
480
+ <span aria-hidden="true">×</span>
470
481
  </button>
471
482
  )}
472
483
  {helpText && (
@@ -542,32 +553,40 @@ export const SelectField = (props: Props) => {
542
553
  )}
543
554
  </div>
544
555
  {isMulti && valueMulti.length > 0 && (
545
- <div className="mt-2 flex flex-wrap gap-2">
556
+ <ul
557
+ className="mt-2 flex max-h-20 flex-wrap gap-1.5 overflow-auto overscroll-contain rounded-md border border-blue-100 bg-blue-50/40 p-1.5"
558
+ aria-label="Opciones seleccionadas"
559
+ >
546
560
  {valueMulti.map((option, index) => (
547
- <span
561
+ <li
548
562
  key={`${option.value}-${index}`}
549
- className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded"
563
+ className="inline-flex max-w-full shrink-0 items-center rounded bg-blue-100 py-0.5 pl-2.5 pr-0.5 text-xs font-semibold text-blue-800"
550
564
  >
551
- {option.label}
565
+ <span className="whitespace-nowrap">{option.label}</span>
552
566
  <button
553
567
  type="button"
554
568
  onClick={() => handleRemoveMultiValue(option)}
555
- className="ml-1 text-blue-600 hover:text-blue-800"
569
+ aria-label={`Quitar ${option.label}`}
570
+ className="ml-1 inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-blue-700 hover:bg-blue-200 hover:text-blue-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
556
571
  >
557
- ×
572
+ <span aria-hidden="true">×</span>
558
573
  </button>
559
- </span>
574
+ </li>
560
575
  ))}
561
- </div>
576
+ </ul>
562
577
  )}
563
578
  {error && (
564
- <p className={`mt-2 text-sm text-${color}-600 dark:text-${color}-500`}>
579
+ <p
580
+ id={errorId}
581
+ className={`mt-2 text-sm text-${color}-600 dark:text-${color}-500`}
582
+ >
565
583
  <span className="font-medium">Oops!</span>{" "}
566
584
  {error?.message?.toString() || "Error desconocido"}
567
585
  </p>
568
586
  )}
569
587
  {!error && hint && (
570
588
  <p
589
+ id={hintId}
571
590
  className={`mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`}
572
591
  >
573
592
  {hint}
@@ -15,7 +15,12 @@ import type {
15
15
  import { Link, useNavigate, useLocation } from "@remix-run/react";
16
16
  import { useAppSelector } from "@zauru-sdk/redux";
17
17
 
18
- const OptionsDropDownButton = ({ color, options, name }: EntityProps) => {
18
+ const OptionsDropDownButton = ({
19
+ color,
20
+ options,
21
+ name,
22
+ ariaLabel,
23
+ }: EntityProps & { ariaLabel?: string }) => {
19
24
  const [showOptionsMenu, setShowOptionsMenu] = useState(true);
20
25
 
21
26
  return (
@@ -23,8 +28,14 @@ const OptionsDropDownButton = ({ color, options, name }: EntityProps) => {
23
28
  <div className="flex justify-center">
24
29
  <div className="relative inline-block">
25
30
  <button
31
+ type="button"
32
+ aria-label={
33
+ ariaLabel ?? (typeof name === "string" ? name : "Menú")
34
+ }
35
+ aria-haspopup="menu"
36
+ aria-expanded={!showOptionsMenu}
26
37
  onClick={() => setShowOptionsMenu(!showOptionsMenu)}
27
- className={`relative flex cursor-pointer items-center p-2 text-xs text-white ${color.bg700} active:${color.bg900} border border-transparent rounded-full uppercase focus:ring-opacity-40 focus:outline-none`}
38
+ className={`relative flex cursor-pointer items-center p-2 text-xs text-white ${color.bg700} active:${color.bg900} border border-transparent rounded-full uppercase focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2`}
28
39
  >
29
40
  {name ?? <OpcionButtonSvgIcon />}
30
41
  <DropDownArrowSvgIcon />
@@ -128,6 +139,7 @@ export const NavBar = ({
128
139
  selectedColor,
129
140
  version,
130
141
  reloadCatalogsOption,
142
+ settingsOption,
131
143
  showDarkModeToggle = false,
132
144
  }: NavBarProps) => {
133
145
  const color: ColorInterface = COLORS[selectedColor];
@@ -191,7 +203,35 @@ export const NavBar = ({
191
203
  {loggedIn && (
192
204
  <OptionsDropDownButton
193
205
  color={color}
206
+ ariaLabel="Menú de usuario"
194
207
  options={[
208
+ settingsOption && (
209
+ <Link
210
+ key="configuracion"
211
+ className="block cursor-pointer px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white"
212
+ to="/configuracion"
213
+ >
214
+ <div className="mx-auto pt-2">
215
+ <svg
216
+ className="h-5 w-5 mr-1"
217
+ width="24"
218
+ height="24"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ strokeWidth="1.5"
222
+ viewBox="0 0 24 24"
223
+ strokeLinecap="round"
224
+ strokeLinejoin="round"
225
+ xmlns="http://www.w3.org/2000/svg"
226
+ aria-hidden="true"
227
+ >
228
+ <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
229
+ <path d="M19.4 13a7.7 7.7 0 0 0 .06-2l2.1-1.64-2-3.46-2.55 1a7.7 7.7 0 0 0-1.73-1L14.7 2H9.3l-.58 2.9a7.7 7.7 0 0 0-1.73 1l-2.55-1-2 3.46L4.54 11a7.7 7.7 0 0 0 0 2l-2.1 1.64 2 3.46 2.55-1a7.7 7.7 0 0 0 1.73 1L9.3 22h5.4l.58-2.9a7.7 7.7 0 0 0 1.73-1l2.55 1 2-3.46Z" />
230
+ </svg>
231
+ <span>Configuración</span>
232
+ </div>
233
+ </Link>
234
+ ),
195
235
  reloadCatalogsOption && (
196
236
  <Link
197
237
  key="recargar-catalogos"
@@ -247,7 +287,7 @@ export const NavBar = ({
247
287
  </ul>
248
288
  </>
249
289
  );
250
- }, [items, loggedIn, hiddenItemsChange, showDarkModeToggle, isDarkMode]);
290
+ }, [items, loggedIn, hiddenItemsChange, reloadCatalogsOption, settingsOption, showDarkModeToggle, isDarkMode]);
251
291
 
252
292
  return (
253
293
  <nav className={`py-3 ${color.bg600}`}>