feat: integrate contextual help across forms and improve user guidance
- Added contextual help to various forms including companies, people, departments, sites, assignments, and access accounts. - Introduced EntityHelp component to provide descriptions, examples, and guidance for each entity type. - Updated forms to include help sections before submit buttons, enhancing user understanding of each field's purpose. - Implemented help translations in Spanish and English for consistency across the application. - Created a new ContextHelp component for displaying help information in a structured format. - Added automated tests to ensure help content is correctly rendered in both languages. - Documented the new contextual help feature in ayuda-contextual.md for user reference.
This commit is contained in:
parent
7a83f9780e
commit
8386679181
@ -80,7 +80,7 @@ Estado remoto en TanStack Query; claves centralizadas e invalidación explícita
|
||||
No duplicar estado remoto ni usar efectos para derivar valores calculables durante render.
|
||||
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
|
||||
Separar componentes por responsabilidades reales, sin fragmentar JSX trivial.
|
||||
Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios sencillos en modal shadcn/ui y formularios complejos o extensos en una página propia. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario.
|
||||
Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios en modal shadcn/ui como norma general, con anchura y desplazamiento adaptados a su contenido. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario. Incluir ayuda contextual con finalidad, ámbito y ejemplos, sin rellenar los campos automáticamente. En formularios, colocar la ayuda en la parte inferior, inmediatamente antes del pie del modal con Guardar y Cancelar.
|
||||
|
||||
## Validación y entrega
|
||||
|
||||
|
||||
15
docs/ayuda-contextual.md
Normal file
15
docs/ayuda-contextual.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Ayuda integrada en Metis Desk
|
||||
|
||||
Cada formulario de compañías, personas, departamentos, sedes, asignaciones y cuentas y accesos incluye una explicación de qué representa el elemento y para qué sirve. La ayuda está en la parte inferior, justo antes del pie con Guardar y Cancelar.
|
||||
|
||||
«Ver ejemplos y recomendaciones» despliega valores orientativos y reglas útiles. Los ejemplos nunca rellenan campos ni crean registros. En sistemas puedes consultar Dominio, Microsoft 365, ERP, Brevo, Zoom o Canva.
|
||||
|
||||
Los sistemas, tipos de recurso y permisos explican su ámbito: global para todas las compañías o exclusivo de la compañía actual. Las plantillas globales comparten valores iniciales, mientras que miembros y accesos se mantienen separados por compañía.
|
||||
|
||||
El botón Ayuda en Catálogos informáticos y Cuentas y accesos explica la sección seleccionada sin abrir el alta. Todo el contenido está disponible en español e inglés.
|
||||
|
||||
Los formularios se abren en modales como norma general. Personas y formularios extensos usan mayor anchura y desplazamiento cuando hace falta. La ayuda desplegable funciona con teclado y los diálogos conservan cancelación y cierre con Escape.
|
||||
|
||||
## Validación
|
||||
|
||||
44 pruebas automatizadas correctas, comprobaciones de tipos y estilo y compilación. Verificación en navegador de sistemas globales y propios, ayuda de consulta, alta y edición de personas, modal amplio de cuentas, ejemplos desplegables y cancelación sin guardar.
|
||||
@ -13,11 +13,11 @@ El código se genera desde el registro oficial y queda versionado en este reposi
|
||||
- Checkbox: estado activo de usuarios, compañías, personas y departamentos.
|
||||
- Table: listados de usuarios, compañías, personas y departamentos.
|
||||
- Textarea: dirección opcional de las sedes, con varias líneas.
|
||||
- Dialog: formularios sencillos de alta y edición, con foco y cierre por teclado. FormDialog comparte el título y la cancelación traducidos.
|
||||
- Dialog: formularios de alta y edición, con foco y cierre por teclado. FormDialog comparte el título y la cancelación traducidos.
|
||||
|
||||
## Convención de navegación
|
||||
|
||||
Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios sencillos se abren en un modal; los complejos o con muchos campos se presentan en una página propia. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas se crean y editan en una página propia; los departamentos y las asignaciones usan modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui.
|
||||
Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios se abren en modal como norma general, con anchura adaptable y desplazamiento si el contenido lo requiere. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas, los departamentos y las asignaciones se crean y editan en modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui.
|
||||
|
||||
TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos.
|
||||
|
||||
@ -54,3 +54,11 @@ lucide-react aporta los iconos del selector y tw-animate-css sus transiciones.
|
||||
shadcn es una herramienta de desarrollo; su hoja de estilos común se incorpora al compilar.
|
||||
|
||||
Guías oficiales: [monorepos](https://ui.shadcn.com/docs/monorepo) e [instalación manual](https://ui.shadcn.com/docs/installation/manual).
|
||||
|
||||
## Ayuda contextual
|
||||
|
||||
ContextHelp muestra una explicación breve y permite desplegar ejemplos y recomendaciones mediante teclado. EntityHelp aporta contenido específico en español e inglés para compañías, personas, departamentos, sedes, asignaciones y todas las secciones de cuentas y accesos. Los catálogos explican si la definición es global o exclusiva de la compañía. Los ejemplos son orientativos: nunca se convierten en valores del formulario.
|
||||
|
||||
El botón Ayuda de Cuentas y accesos y Catálogos informáticos permite consultar la explicación de la sección sin abrir un alta. La misma ayuda aparece dentro de los modales de alta y edición. FormDialog utiliza una variante amplia para personas y formularios con más campos; el número de campos no cambia la navegación a una página propia.
|
||||
|
||||
La ayuda de los formularios se coloca en su parte inferior, inmediatamente antes de FormActions. Este pie compartido reúne Guardar y Cancelar dentro del formulario, conservando el envío y el cierre accesible del diálogo. Los diálogos de consulta mantienen únicamente Cerrar.
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
Abrir una compañía y elegir Personas o Departamentos.
|
||||
|
||||
- Nueva persona, a la derecha de la cabecera, abre una página propia. Nombre obligatorio; puesto general, email y teléfono opcionales. El estado activo y la marca Contacto de la compañía se editan por separado.
|
||||
- Nueva persona, a la derecha de la cabecera, abre un modal. Nombre obligatorio; puesto general, email y teléfono opcionales. El estado activo y la marca Contacto de la compañía se editan por separado.
|
||||
- El listado permite buscar por nombre y mostrar solo contactos. Abrir ficha muestra los datos de la persona y sus asignaciones.
|
||||
- Nuevo departamento abre un modal con nombre y estado activo. Los nombres son únicos dentro de cada compañía, ignorando mayúsculas y espacios exteriores. No hay departamentos anidados.
|
||||
- Desde la ficha de una persona, Asignar departamento abre un modal con departamento, puesto específico opcional, fecha de inicio, fecha de fin opcional y marca de departamento principal.
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -14,6 +15,7 @@ import { accessFields } from "./access-fields";
|
||||
import type { AccessFormValues } from "./access-form-values";
|
||||
import { compatibleOption, fieldDefault, formBody } from "./access-form-values";
|
||||
import { accessLabel } from "./access-labels";
|
||||
import { EntityHelp } from "./entity-help";
|
||||
export function AccessForm({
|
||||
scope,
|
||||
kind,
|
||||
@ -119,7 +121,6 @@ export function AccessForm({
|
||||
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<p className="text-sm text-muted-foreground">{t(help[kind] ?? "formHelp")}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{editableFields.map((field) => (
|
||||
<div
|
||||
@ -138,9 +139,6 @@ export function AccessForm({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{["account_person", "membership", "grant"].includes(kind) && (
|
||||
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
|
||||
)}
|
||||
{validation && (
|
||||
<p className="text-destructive" role="alert">
|
||||
{validation}
|
||||
@ -153,9 +151,16 @@ export function AccessForm({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
{help[kind] && <p className="text-sm text-muted-foreground">{t(help[kind])}</p>}
|
||||
{["account_person", "membership", "grant"].includes(kind) && (
|
||||
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
|
||||
)}
|
||||
<EntityHelp kind={kind} scope={scope} />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import { AccessForm } from "./access-form";
|
||||
import { AccessHistory } from "./access-history";
|
||||
import { accessLabel } from "./access-labels";
|
||||
import { AccessRow } from "./access-row";
|
||||
import { EntityHelp } from "./entity-help";
|
||||
|
||||
export function AccessWorkspacePage({
|
||||
scope,
|
||||
@ -40,6 +41,7 @@ export function AccessWorkspacePage({
|
||||
const [kind, setKind] = useState<AccessKind>(scope === "global" ? "system" : "account");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [history, setHistory] = useState<string>();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchId = useId();
|
||||
const data = query.data;
|
||||
@ -63,29 +65,6 @@ export function AccessWorkspacePage({
|
||||
"permission",
|
||||
];
|
||||
const complex = accessFields[kind].length > 5;
|
||||
if (data && editor !== undefined && complex) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button onClick={() => setEditor(undefined)} variant="outline">
|
||||
{t("back")}
|
||||
</Button>
|
||||
<h2 className="text-xl font-semibold">{t(selected ? "edit" : createLabels[kind])}</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<AccessForm
|
||||
csrfToken={csrfToken}
|
||||
data={data}
|
||||
item={selected}
|
||||
kind={kind}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
people={people}
|
||||
scope={scope}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
@ -97,10 +76,23 @@ export function AccessWorkspacePage({
|
||||
{t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||
{t(createLabels[kind])}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setHelpOpen(true)} variant="outline">
|
||||
{t("helpButton")}
|
||||
</Button>
|
||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||
{t(createLabels[kind])}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FormDialog
|
||||
cancelLabel={t("helpClose")}
|
||||
onOpenChange={setHelpOpen}
|
||||
open={helpOpen}
|
||||
title={t(`kinds.${kind}`)}
|
||||
>
|
||||
<EntityHelp kind={kind} scope={scope} />
|
||||
</FormDialog>
|
||||
<nav aria-label={t("sections")} className="flex flex-wrap gap-2">
|
||||
{kinds.map((value) => (
|
||||
<Button
|
||||
@ -182,13 +174,15 @@ export function AccessWorkspacePage({
|
||||
</Card>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open={editor !== undefined && !complex}
|
||||
open={editor !== undefined}
|
||||
title={t(selected ? "edit" : createLabels[kind])}
|
||||
wide={complex}
|
||||
>
|
||||
<AccessForm
|
||||
csrfToken={csrfToken}
|
||||
|
||||
@ -61,3 +61,85 @@ test("cuentas ofrecen caducidad opcional y campos sin contraseña", async () =>
|
||||
assert.equal(field.includes("required"), false);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
test("la ayuda explica todos los elementos en ambos idiomas y distingue el ámbito", async () => {
|
||||
const { EntityHelp } = await import("./entity-help");
|
||||
const { helpSpanish, helpEnglish } = await import("./help-i18n");
|
||||
for (const [language, content] of [
|
||||
["es", helpSpanish],
|
||||
["en", helpEnglish],
|
||||
] as const) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({ lng: language, resources: { [language]: { access: content } } });
|
||||
for (const kind of [
|
||||
"system",
|
||||
"resource_type",
|
||||
"permission",
|
||||
"template",
|
||||
"environment",
|
||||
"account",
|
||||
"account_person",
|
||||
"group",
|
||||
"membership",
|
||||
"resource",
|
||||
"grant",
|
||||
"need",
|
||||
"coverage",
|
||||
"company",
|
||||
"person",
|
||||
"department",
|
||||
"site",
|
||||
"assignment",
|
||||
] as const) {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<EntityHelp kind={kind} scope="global" />
|
||||
</I18nextProvider>
|
||||
);
|
||||
assert.ok(markup.includes("<details"));
|
||||
assert.ok(
|
||||
markup.includes(
|
||||
content.helpContent[kind].examples.replaceAll("&", "&").replaceAll("'", "'")
|
||||
)
|
||||
);
|
||||
assert.equal(markup.includes("helpContent."), false);
|
||||
}
|
||||
const renderScope = (scope: string) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<EntityHelp kind="system" scope={scope} />
|
||||
</I18nextProvider>
|
||||
);
|
||||
assert.ok(renderScope("global").includes(content.helpGlobalScope));
|
||||
assert.ok(renderScope("company").includes(content.helpCompanyScope));
|
||||
assert.equal(renderScope("company").includes(content.helpGlobalScope), false);
|
||||
}
|
||||
});
|
||||
test("los ejemplos del sistema orientan sin rellenar datos del alta", async () => {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({ lng: "es", resources: { es: { access: accessSpanish } } });
|
||||
const client = new QueryClient();
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<AccessForm
|
||||
csrfToken="test"
|
||||
data={{ items: [], reviews: [], coverage: [] }}
|
||||
kind="system"
|
||||
onSaved={() => undefined}
|
||||
people={[]}
|
||||
scope="global"
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
assert.ok(markup.includes("Microsoft 365"));
|
||||
assert.ok(markup.includes("Ámbito global"));
|
||||
assert.ok(markup.indexOf('name="name"') < markup.indexOf("<aside"));
|
||||
assert.ok(markup.indexOf("<aside") < markup.indexOf('type="submit"'));
|
||||
const name = markup.match(/<input[^>]*name="name"[^>]*>/)?.[0];
|
||||
assert.ok(name);
|
||||
assert.equal(/value="[^"]+"/.test(name), false);
|
||||
assert.equal(markup.includes('value="Microsoft 365"'), false);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
23
modules/companies/src/web/access/entity-help.tsx
Normal file
23
modules/companies/src/web/access/entity-help.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { HelpKind } from "./help-i18n";
|
||||
|
||||
export function EntityHelp({ kind, scope }: { kind: HelpKind; scope?: string }) {
|
||||
const { t } = useTranslation("access");
|
||||
const catalogue = ["system", "resource_type", "permission", "template"].includes(kind);
|
||||
return (
|
||||
<ContextHelp
|
||||
description={t(`helpContent.${kind}.description`)}
|
||||
examples={t(`helpContent.${kind}.examples`)}
|
||||
examplesLabel={t("helpExamples")}
|
||||
guidance={t(`helpContent.${kind}.guidance`)}
|
||||
scopeNote={
|
||||
catalogue && scope
|
||||
? t(scope === "global" ? "helpGlobalScope" : "helpCompanyScope")
|
||||
: undefined
|
||||
}
|
||||
title={t("helpTitle")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
272
modules/companies/src/web/access/help-i18n.ts
Normal file
272
modules/companies/src/web/access/help-i18n.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import type { AccessKind } from "../../common";
|
||||
export type HelpKind = AccessKind | "company" | "person" | "department" | "site" | "assignment";
|
||||
export const helpSpanish = {
|
||||
helpTitle: "¿Qué es y para qué sirve?",
|
||||
helpExamples: "Ver ejemplos y recomendaciones",
|
||||
helpButton: "Ayuda",
|
||||
helpClose: "Cerrar",
|
||||
helpGlobalScope:
|
||||
"Ámbito global: estará disponible para todas las compañías. Aquí solo se comparte la definición; las cuentas y los accesos pertenecen a cada compañía.",
|
||||
helpCompanyScope:
|
||||
"Ámbito de compañía: este elemento solo estará disponible en esta compañía. También puedes utilizar los elementos del catálogo global.",
|
||||
helpContent: {
|
||||
system: {
|
||||
description:
|
||||
"Un sistema identifica el servicio o plataforma donde existen cuentas y accesos. Sirve para organizar cuentas, entornos y grupos relacionados.",
|
||||
examples: "Dominio; Microsoft 365; ERP; Brevo; Zoom; Canva.",
|
||||
guidance:
|
||||
"Crea el sistema antes de registrar sus cuentas. No representa una cuenta concreta ni guarda credenciales.",
|
||||
},
|
||||
resource_type: {
|
||||
description:
|
||||
"Un tipo de recurso clasifica los elementos a los que se puede acceder. Permite definir qué permisos tienen sentido para cada clase.",
|
||||
examples: "Carpeta compartida; aplicación; impresora.",
|
||||
guidance:
|
||||
"Define primero el tipo y después sus permisos. Una carpeta concreta se registra como recurso.",
|
||||
},
|
||||
permission: {
|
||||
description:
|
||||
"Un permiso define una acción posible sobre un tipo de recurso. Después puedes concederlo a una cuenta o a un grupo.",
|
||||
examples: "Lectura o modificación para carpetas; uso para impresoras.",
|
||||
guidance:
|
||||
"Selecciona el tipo de recurso al que corresponde. Registrar el permiso no lo concede a nadie.",
|
||||
},
|
||||
template: {
|
||||
description:
|
||||
"Una plantilla de grupo facilita crear grupos similares en distintas compañías, usando un nombre y una descripción comunes.",
|
||||
examples: "Administración; Dirección; Soporte.",
|
||||
guidance:
|
||||
"Copia valores iniciales al crear un grupo. Cada compañía conserva sus miembros y accesos; los cambios posteriores en la plantilla no se propagan.",
|
||||
},
|
||||
environment: {
|
||||
description: "Un entorno distingue instalaciones o ámbitos dentro de un mismo sistema.",
|
||||
examples: "ERP → Producción y Pruebas; Dominio → Oficina.",
|
||||
guidance:
|
||||
"Usa un entorno cuando necesites diferenciar cuentas o grupos del mismo sistema. Pertenece a esta compañía.",
|
||||
},
|
||||
account: {
|
||||
description:
|
||||
"Una cuenta es la identidad utilizada para iniciar sesión en un sistema de la compañía. Puede ser personal, compartida o técnica.",
|
||||
examples:
|
||||
"ana.garcia en Dominio; administracion@example.com en Microsoft 365; svc_backup como cuenta técnica.",
|
||||
guidance:
|
||||
"Después vincula las personas correspondientes. La caducidad es opcional. No se almacenan contraseñas ni se crea acceso a Metis Desk.",
|
||||
},
|
||||
account_person: {
|
||||
description:
|
||||
"Esta relación indica quién utiliza una cuenta o quién es responsable de ella, conservando sus periodos e historial.",
|
||||
examples:
|
||||
"Ana como titular de ana.garcia; Ana y Luis autorizados a usar administracion@example.com.",
|
||||
guidance:
|
||||
"Una cuenta personal tiene un titular; una compartida admite varias personas; una técnica puede tener un responsable. Configuración refleja el trabajo técnico realizado.",
|
||||
},
|
||||
group: {
|
||||
description:
|
||||
"Un grupo reúne cuentas del mismo sistema para gestionar accesos conjuntamente. Pertenece a una compañía.",
|
||||
examples: "Grupo Administración del dominio; grupo Ventas del ERP.",
|
||||
guidance:
|
||||
"Puedes partir de una plantilla global. Añade cuentas mediante pertenencias y concede permisos al grupo. No hay grupos dentro de otros grupos.",
|
||||
},
|
||||
membership: {
|
||||
description:
|
||||
"Una pertenencia vincula una cuenta con un grupo durante un periodo. Sirve para justificar los accesos obtenidos mediante ese grupo.",
|
||||
examples: "ana.garcia pertenece al grupo Administración desde su incorporación.",
|
||||
guidance:
|
||||
"Indica si está pendiente de configurar o ya configurada. Cerrar el periodo puede dejar pendiente retirar el acceso real.",
|
||||
},
|
||||
resource: {
|
||||
description: "Un recurso es un elemento concreto al que una cuenta o un grupo puede acceder.",
|
||||
examples:
|
||||
"Carpeta Administración con referencia \\\\SERVIDOR\\Administracion; aplicación ERP; impresora Recepción.",
|
||||
guidance:
|
||||
"Selecciona su tipo. Después registra las concesiones de permisos. Esta ficha no configura el recurso real.",
|
||||
},
|
||||
grant: {
|
||||
description:
|
||||
"Una concesión registra un permiso otorgado a una cuenta o a un grupo sobre un recurso concreto.",
|
||||
examples: "Grupo Administración → modificación → carpeta Administración.",
|
||||
guidance:
|
||||
"Elige cuenta o grupo como destinatario. La autorización y su configuración técnica se registran por separado.",
|
||||
},
|
||||
need: {
|
||||
description:
|
||||
"Una necesidad describe qué acceso requiere una persona para trabajar. Sirve para comprobar si sus cuentas y grupos lo cubren.",
|
||||
examples: "Ana necesita lectura y modificación sobre la carpeta Administración.",
|
||||
guidance:
|
||||
"Selecciona los permisos requeridos y después vincula su cobertura. Registrar la necesidad no concede acceso.",
|
||||
},
|
||||
coverage: {
|
||||
description:
|
||||
"Una cobertura vincula una necesidad con la vía que proporciona sus permisos: una concesión directa o a través de un grupo.",
|
||||
examples:
|
||||
"La necesidad de Ana se cubre mediante ana.garcia → grupo Administración → permiso de modificación.",
|
||||
guidance:
|
||||
"Se comprueba que la vía sea vigente y esté configurada. Si faltan permisos o hay accesos pendientes, la necesidad sigue requiriendo revisión.",
|
||||
},
|
||||
company: {
|
||||
description:
|
||||
"Una compañía reúne las personas, sedes, cuentas y recursos que gestionas para una organización.",
|
||||
examples: "Talleres Rivera; Asesoría Norte.",
|
||||
guidance:
|
||||
"Activa indica si la compañía está en uso. Su información se conserva cuando se desactiva.",
|
||||
},
|
||||
person: {
|
||||
description:
|
||||
"Una persona pertenece a una compañía y puede ser contacto, utilizar cuentas y estar asignada a departamentos.",
|
||||
examples: "Ana García; puesto general: Responsable de administración.",
|
||||
guidance:
|
||||
"Esta ficha no da acceso a Metis Desk. Una persona puede pertenecer a varios departamentos con un puesto diferente en cada uno.",
|
||||
},
|
||||
department: {
|
||||
description: "Un departamento organiza las personas dentro de una compañía.",
|
||||
examples: "Administración; Ventas; Soporte.",
|
||||
guidance:
|
||||
"Las pertenencias se añaden desde la ficha de la persona. No se conceden permisos informáticos por pertenecer a un departamento.",
|
||||
},
|
||||
site: {
|
||||
description: "Una sede representa una ubicación de la compañía.",
|
||||
examples: "Oficina central; Almacén; dirección: Calle Mayor 12, 28013 Madrid.",
|
||||
guidance: "La dirección es opcional y admite varias líneas.",
|
||||
},
|
||||
assignment: {
|
||||
description:
|
||||
"Una asignación registra la pertenencia de una persona a un departamento, su puesto y su periodo.",
|
||||
examples: "Ana → Administración → Contable; también pertenece a Compras como Responsable.",
|
||||
guidance:
|
||||
"El puesto en el departamento puede ser diferente del puesto general. Puedes indicar cuál es su departamento principal y conservar el historial.",
|
||||
},
|
||||
} satisfies Record<HelpKind, { description: string; examples: string; guidance: string }>,
|
||||
};
|
||||
export const helpEnglish = {
|
||||
helpTitle: "What is it and what is it for?",
|
||||
helpExamples: "Show examples and recommendations",
|
||||
helpButton: "Help",
|
||||
helpClose: "Close",
|
||||
helpGlobalScope:
|
||||
"Global scope: available to all companies. Only the definition is shared; accounts and access belong to each company.",
|
||||
helpCompanyScope:
|
||||
"Company scope: this element is only available in this company. You can also use global catalogue elements.",
|
||||
helpContent: {
|
||||
system: {
|
||||
description:
|
||||
"A system identifies the service or platform where accounts and access exist. It organizes related accounts, environments and groups.",
|
||||
examples: "Domain; Microsoft 365; ERP; Brevo; Zoom; Canva.",
|
||||
guidance:
|
||||
"Create the system before registering accounts. It is not an individual account and does not store credentials.",
|
||||
},
|
||||
resource_type: {
|
||||
description:
|
||||
"A resource type classifies elements people can access and determines which permissions apply.",
|
||||
examples: "Shared folder; application; printer.",
|
||||
guidance:
|
||||
"Define the type and its permissions first. Register an individual folder as a resource.",
|
||||
},
|
||||
permission: {
|
||||
description:
|
||||
"A permission defines an action available for a resource type. You can then grant it to an account or group.",
|
||||
examples: "Read or modify for folders; use for printers.",
|
||||
guidance: "Select the relevant resource type. Defining a permission does not grant access.",
|
||||
},
|
||||
template: {
|
||||
description:
|
||||
"A group template helps create similar groups across companies using a common name and description.",
|
||||
examples: "Administration; Management; Support.",
|
||||
guidance:
|
||||
"It copies initial values. Each company keeps its own members and access; later template changes do not propagate.",
|
||||
},
|
||||
environment: {
|
||||
description: "An environment distinguishes installations or scopes within the same system.",
|
||||
examples: "ERP → Production and Test; Domain → Office.",
|
||||
guidance:
|
||||
"Use it to distinguish accounts or groups in the same system. It belongs to this company.",
|
||||
},
|
||||
account: {
|
||||
description:
|
||||
"An account is the identity used to sign into a company system. It can be personal, shared or technical.",
|
||||
examples:
|
||||
"ana.garcia in Domain; administration@example.com in Microsoft 365; svc_backup as a technical account.",
|
||||
guidance:
|
||||
"Link the relevant people afterwards. Expiry is optional. Passwords are not stored and this does not grant access to Metis Desk.",
|
||||
},
|
||||
account_person: {
|
||||
description:
|
||||
"This relationship records who uses an account or is responsible for it, keeping periods and history.",
|
||||
examples: "Ana owns ana.garcia; Ana and Luis can use administration@example.com.",
|
||||
guidance:
|
||||
"Personal accounts have one owner; shared accounts allow several people; technical accounts may have a responsible person. Configuration records technical work.",
|
||||
},
|
||||
group: {
|
||||
description:
|
||||
"A group collects accounts from the same system to manage access together. It belongs to one company.",
|
||||
examples: "Administration domain group; Sales ERP group.",
|
||||
guidance:
|
||||
"You can start from a global template. Add accounts through memberships and grant permissions to the group. Nested groups are not supported.",
|
||||
},
|
||||
membership: {
|
||||
description:
|
||||
"A membership links an account to a group for a period and explains access obtained through that group.",
|
||||
examples: "ana.garcia joins the Administration group on arrival.",
|
||||
guidance:
|
||||
"Record whether configuration is pending or complete. Ending the period may leave actual access pending removal.",
|
||||
},
|
||||
resource: {
|
||||
description: "A resource is a specific element an account or group can access.",
|
||||
examples:
|
||||
"Administration folder at \\\\SERVER\\Administration; ERP application; Reception printer.",
|
||||
guidance:
|
||||
"Select its type, then record permission grants. This record does not configure the actual resource.",
|
||||
},
|
||||
grant: {
|
||||
description:
|
||||
"A grant records a permission given to an account or group on a specific resource.",
|
||||
examples: "Administration group → modify → Administration folder.",
|
||||
guidance:
|
||||
"Choose an account or group as the recipient. Authorization and technical configuration are recorded separately.",
|
||||
},
|
||||
need: {
|
||||
description:
|
||||
"A need describes the access a person requires for work and helps check whether accounts and groups provide it.",
|
||||
examples: "Ana needs read and modify access to the Administration folder.",
|
||||
guidance:
|
||||
"Select required permissions, then link coverage. Recording a need does not grant access.",
|
||||
},
|
||||
coverage: {
|
||||
description:
|
||||
"Coverage links a need to the access path providing its permissions: directly or through a group.",
|
||||
examples:
|
||||
"Ana's need is covered through ana.garcia → Administration group → modify permission.",
|
||||
guidance:
|
||||
"The path must be current and configured. Missing permissions or pending access mean the need still requires review.",
|
||||
},
|
||||
company: {
|
||||
description:
|
||||
"A company brings together the people, sites, accounts and resources managed for an organization.",
|
||||
examples: "Rivera Workshops; North Consulting.",
|
||||
guidance: "Active indicates whether it is in use. Its information is kept when deactivated.",
|
||||
},
|
||||
person: {
|
||||
description:
|
||||
"A person belongs to a company and may be a contact, use accounts and belong to departments.",
|
||||
examples: "Ana García; general position: Administration manager.",
|
||||
guidance:
|
||||
"This record does not grant access to Metis Desk. A person can belong to several departments with a different position in each.",
|
||||
},
|
||||
department: {
|
||||
description: "A department organizes people within a company.",
|
||||
examples: "Administration; Sales; Support.",
|
||||
guidance:
|
||||
"Add memberships from the person's record. Department membership does not grant computer access.",
|
||||
},
|
||||
site: {
|
||||
description: "A site represents a company location.",
|
||||
examples: "Head office; Warehouse; address: 12 Main Street, London.",
|
||||
guidance: "The address is optional and supports multiple lines.",
|
||||
},
|
||||
assignment: {
|
||||
description: "An assignment records a person's department membership, position and period.",
|
||||
examples: "Ana → Administration → Accountant; also Purchasing → Manager.",
|
||||
guidance:
|
||||
"Department positions can differ from the general position. You can mark a primary department and keep history.",
|
||||
},
|
||||
} satisfies Record<HelpKind, { description: string; examples: string; guidance: string }>,
|
||||
};
|
||||
@ -1,4 +1,7 @@
|
||||
import { helpEnglish, helpSpanish } from "./help-i18n";
|
||||
|
||||
export const accessSpanish = {
|
||||
...helpSpanish,
|
||||
title: "Cuentas y accesos",
|
||||
catalogTitle: "Catálogos informáticos",
|
||||
catalogHelp:
|
||||
@ -128,6 +131,7 @@ export const accessSpanish = {
|
||||
},
|
||||
};
|
||||
export const accessEnglish = {
|
||||
...helpEnglish,
|
||||
title: "Accounts and access",
|
||||
catalogTitle: "IT catalogs",
|
||||
catalogHelp: "Shared definitions for all companies. Companies can add their own entries.",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@ -10,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CompanyDTO, DepartmentAssignmentDTO } from "../../common";
|
||||
import { EntityHelp } from "../access/entity-help";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -133,9 +135,13 @@ export function AssignmentForm({
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
|
||||
<EntityHelp kind="assignment" />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -125,6 +125,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setCreateOpen}
|
||||
open={createOpen}
|
||||
title={t("newCompany")}
|
||||
|
||||
@ -51,6 +51,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
||||
</Card>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setCreateOpen}
|
||||
open={createOpen}
|
||||
title={t("newSite")}
|
||||
@ -64,6 +65,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedId(undefined);
|
||||
@ -133,6 +135,7 @@ export function CompanyDetail({
|
||||
</div>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setEditOpen}
|
||||
open={editOpen}
|
||||
title={t("editCompany")}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@ -10,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyRequestDTO, CompanySummaryDTO } from "../../common";
|
||||
import { CompanyRequestSchema } from "../../common";
|
||||
import { EntityHelp } from "../access/entity-help";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -72,9 +74,13 @@ export function CompanyForm({
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
success={mutation.isSuccess}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
|
||||
<EntityHelp kind="company" />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, DepartmentDTO, DepartmentRequestDTO } from "../../common";
|
||||
import { DepartmentRequestSchema } from "../../common";
|
||||
import { EntityHelp } from "../access/entity-help";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -73,9 +74,13 @@ function DepartmentForm({
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
|
||||
<EntityHelp kind="department" />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -132,6 +137,7 @@ export function DepartmentSection({
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
|
||||
@ -143,6 +143,7 @@ function PersonDetails({
|
||||
</Card>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
@ -204,40 +205,41 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
||||
const [search, setSearch] = useState("");
|
||||
const [contactsOnly, setContactsOnly] = useState(false);
|
||||
const selected = company.people.find((item) => item.id === selection?.id);
|
||||
if (selection?.mode === "new" || (selection?.mode === "edit" && selected)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
onClick={() => setSelection(selected ? { mode: "detail", id: selected.id } : undefined)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("backToPeople")}
|
||||
</Button>
|
||||
<h2 className="text-2xl font-semibold">{t(selected ? "editPerson" : "newPerson")}</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<PersonForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
key={selected?.id ?? "new"}
|
||||
onSaved={(id) => setSelection({ mode: "detail", id })}
|
||||
person={selected}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selected) {
|
||||
return (
|
||||
<PersonDetails
|
||||
company={company}
|
||||
const personEditor = (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelection(selected ? { mode: "detail", id: selected.id } : undefined);
|
||||
}
|
||||
}}
|
||||
open={selection?.mode === "new" || selection?.mode === "edit"}
|
||||
title={t(selected ? "editPerson" : "newPerson")}
|
||||
wide
|
||||
>
|
||||
<PersonForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
key={selected.id}
|
||||
onBack={() => setSelection(undefined)}
|
||||
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
|
||||
key={selected?.id ?? "new"}
|
||||
onSaved={(id) => setSelection({ mode: "detail", id })}
|
||||
person={selected}
|
||||
/>
|
||||
</FormDialog>
|
||||
);
|
||||
if (selected) {
|
||||
return (
|
||||
<>
|
||||
{personEditor}
|
||||
<PersonDetails
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
key={selected.id}
|
||||
onBack={() => setSelection(undefined)}
|
||||
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
|
||||
person={selected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const people = company.people.filter(
|
||||
@ -247,6 +249,7 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{personEditor}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("people")}</h2>
|
||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@ -12,6 +13,7 @@ import { z } from "zod/v4";
|
||||
import type { PersonDTO } from "../../common";
|
||||
import { PersonRequestSchema } from "../../common";
|
||||
import { accessKeys } from "../access/access-api";
|
||||
import { EntityHelp } from "../access/entity-help";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -78,7 +80,6 @@ export function PersonForm({
|
||||
className="max-w-2xl space-y-6"
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
@ -124,9 +125,13 @@ export function PersonForm({
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
|
||||
<EntityHelp kind="person" />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
@ -10,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SiteDTO, SiteRequestDTO } from "../../common";
|
||||
import { SiteRequestSchema } from "../../common";
|
||||
import { EntityHelp } from "../access/entity-help";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -73,9 +75,13 @@ export function SiteForm({
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
success={mutation.isSuccess}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
|
||||
<EntityHelp kind="site" />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
32
packages/shadcn-ui/src/components/context-help.tsx
Normal file
32
packages/shadcn-ui/src/components/context-help.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
export function ContextHelp({
|
||||
title,
|
||||
description,
|
||||
examples,
|
||||
guidance,
|
||||
examplesLabel,
|
||||
scopeNote,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
examples: string;
|
||||
guidance: string;
|
||||
examplesLabel: string;
|
||||
scopeNote?: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<aside aria-label={title} className="space-y-2 rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
{scopeNote && <p>{scopeNote}</p>}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer rounded-sm font-medium text-primary focus-visible:outline-2 focus-visible:outline-ring">
|
||||
{examplesLabel}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 text-muted-foreground">
|
||||
<p>{examples}</p>
|
||||
<p>{guidance}</p>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
@ -10,33 +10,57 @@ import {
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
const FormCancelContext = createContext<string | undefined>(undefined);
|
||||
|
||||
export function FormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
cancelLabel,
|
||||
children,
|
||||
wide = false,
|
||||
formActions = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
cancelLabel: string;
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
formActions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl"
|
||||
className={`max-h-[calc(100dvh-2rem)] overflow-y-auto ${wide ? "sm:max-w-3xl" : "sm:max-w-xl"}`}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{cancelLabel}</DialogClose>
|
||||
</DialogFooter>
|
||||
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
|
||||
{children}
|
||||
</FormCancelContext.Provider>
|
||||
{!formActions && (
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{cancelLabel}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormActions({ children }: { children: ReactNode }) {
|
||||
const cancelLabel = useContext(FormCancelContext);
|
||||
return (
|
||||
<DialogFooter className="border-t pt-4">
|
||||
{children}
|
||||
{cancelLabel && (
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>{cancelLabel}</DialogClose>
|
||||
)}
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user