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:
David Arranz 2026-10-03 18:23:38 +02:00
parent 7a83f9780e
commit 8386679181
20 changed files with 587 additions and 92 deletions

View File

@ -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
View 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.

View File

@ -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.

View File

@ -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.

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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("&", "&amp;").replaceAll("'", "&#x27;")
)
);
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();
});

View 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")}
/>
);
}

View 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 }>,
};

View File

@ -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.",

View File

@ -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>
);
}

View File

@ -125,6 +125,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
)}
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setCreateOpen}
open={createOpen}
title={t("newCompany")}

View File

@ -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")}

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>
);
}