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.
|
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.
|
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
|
||||||
Separar componentes por responsabilidades reales, sin fragmentar JSX trivial.
|
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
|
## 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.
|
- Checkbox: estado activo de usuarios, compañías, personas y departamentos.
|
||||||
- Table: listados 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.
|
- 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
|
## 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.
|
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.
|
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).
|
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.
|
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.
|
- 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.
|
- 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.
|
- 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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
|
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@ -14,6 +15,7 @@ import { accessFields } from "./access-fields";
|
|||||||
import type { AccessFormValues } from "./access-form-values";
|
import type { AccessFormValues } from "./access-form-values";
|
||||||
import { compatibleOption, fieldDefault, formBody } from "./access-form-values";
|
import { compatibleOption, fieldDefault, formBody } from "./access-form-values";
|
||||||
import { accessLabel } from "./access-labels";
|
import { accessLabel } from "./access-labels";
|
||||||
|
import { EntityHelp } from "./entity-help";
|
||||||
export function AccessForm({
|
export function AccessForm({
|
||||||
scope,
|
scope,
|
||||||
kind,
|
kind,
|
||||||
@ -119,7 +121,6 @@ export function AccessForm({
|
|||||||
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
|
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
|
||||||
return (
|
return (
|
||||||
<form className="space-y-4" onSubmit={submit}>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{editableFields.map((field) => (
|
{editableFields.map((field) => (
|
||||||
<div
|
<div
|
||||||
@ -138,9 +139,6 @@ export function AccessForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{["account_person", "membership", "grant"].includes(kind) && (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
|
|
||||||
)}
|
|
||||||
{validation && (
|
{validation && (
|
||||||
<p className="text-destructive" role="alert">
|
<p className="text-destructive" role="alert">
|
||||||
{validation}
|
{validation}
|
||||||
@ -153,9 +151,16 @@ export function AccessForm({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
{help[kind] && <p className="text-sm text-muted-foreground">{t(help[kind])}</p>}
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
{["account_person", "membership", "grant"].includes(kind) && (
|
||||||
</Button>
|
<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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { AccessForm } from "./access-form";
|
|||||||
import { AccessHistory } from "./access-history";
|
import { AccessHistory } from "./access-history";
|
||||||
import { accessLabel } from "./access-labels";
|
import { accessLabel } from "./access-labels";
|
||||||
import { AccessRow } from "./access-row";
|
import { AccessRow } from "./access-row";
|
||||||
|
import { EntityHelp } from "./entity-help";
|
||||||
|
|
||||||
export function AccessWorkspacePage({
|
export function AccessWorkspacePage({
|
||||||
scope,
|
scope,
|
||||||
@ -40,6 +41,7 @@ export function AccessWorkspacePage({
|
|||||||
const [kind, setKind] = useState<AccessKind>(scope === "global" ? "system" : "account");
|
const [kind, setKind] = useState<AccessKind>(scope === "global" ? "system" : "account");
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [history, setHistory] = useState<string>();
|
const [history, setHistory] = useState<string>();
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const searchId = useId();
|
const searchId = useId();
|
||||||
const data = query.data;
|
const data = query.data;
|
||||||
@ -63,29 +65,6 @@ export function AccessWorkspacePage({
|
|||||||
"permission",
|
"permission",
|
||||||
];
|
];
|
||||||
const complex = accessFields[kind].length > 5;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
@ -97,10 +76,23 @@ export function AccessWorkspacePage({
|
|||||||
{t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
{t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
<div className="flex gap-2">
|
||||||
{t(createLabels[kind])}
|
<Button onClick={() => setHelpOpen(true)} variant="outline">
|
||||||
</Button>
|
{t("helpButton")}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||||
|
{t(createLabels[kind])}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</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">
|
<nav aria-label={t("sections")} className="flex flex-wrap gap-2">
|
||||||
{kinds.map((value) => (
|
{kinds.map((value) => (
|
||||||
<Button
|
<Button
|
||||||
@ -182,13 +174,15 @@ export function AccessWorkspacePage({
|
|||||||
</Card>
|
</Card>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditor(undefined);
|
setEditor(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={editor !== undefined && !complex}
|
open={editor !== undefined}
|
||||||
title={t(selected ? "edit" : createLabels[kind])}
|
title={t(selected ? "edit" : createLabels[kind])}
|
||||||
|
wide={complex}
|
||||||
>
|
>
|
||||||
<AccessForm
|
<AccessForm
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
|
|||||||
@ -61,3 +61,85 @@ test("cuentas ofrecen caducidad opcional y campos sin contraseña", async () =>
|
|||||||
assert.equal(field.includes("required"), false);
|
assert.equal(field.includes("required"), false);
|
||||||
client.clear();
|
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 = {
|
export const accessSpanish = {
|
||||||
|
...helpSpanish,
|
||||||
title: "Cuentas y accesos",
|
title: "Cuentas y accesos",
|
||||||
catalogTitle: "Catálogos informáticos",
|
catalogTitle: "Catálogos informáticos",
|
||||||
catalogHelp:
|
catalogHelp:
|
||||||
@ -128,6 +131,7 @@ export const accessSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const accessEnglish = {
|
export const accessEnglish = {
|
||||||
|
...helpEnglish,
|
||||||
title: "Accounts and access",
|
title: "Accounts and access",
|
||||||
catalogTitle: "IT catalogs",
|
catalogTitle: "IT catalogs",
|
||||||
catalogHelp: "Shared definitions for all companies. Companies can add their own entries.",
|
catalogHelp: "Shared definitions for all companies. Companies can add their own entries.",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@ -10,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { CompanyDTO, DepartmentAssignmentDTO } from "../../common";
|
import type { CompanyDTO, DepartmentAssignmentDTO } from "../../common";
|
||||||
|
import { EntityHelp } from "../access/entity-help";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
@ -133,9 +135,13 @@ export function AssignmentForm({
|
|||||||
error={mutation.error}
|
error={mutation.error}
|
||||||
invalid={Object.keys(form.formState.errors).length > 0}
|
invalid={Object.keys(form.formState.errors).length > 0}
|
||||||
/>
|
/>
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
<EntityHelp kind="assignment" />
|
||||||
</Button>
|
<FormActions>
|
||||||
|
<Button disabled={mutation.isPending} type="submit">
|
||||||
|
{t(mutation.isPending ? "saving" : "save")}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,6 +125,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
)}
|
)}
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title={t("newCompany")}
|
title={t("newCompany")}
|
||||||
|
|||||||
@ -51,6 +51,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
|||||||
</Card>
|
</Card>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title={t("newSite")}
|
title={t("newSite")}
|
||||||
@ -64,6 +65,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
|||||||
</FormDialog>
|
</FormDialog>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedId(undefined);
|
setSelectedId(undefined);
|
||||||
@ -133,6 +135,7 @@ export function CompanyDetail({
|
|||||||
</div>
|
</div>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
title={t("editCompany")}
|
title={t("editCompany")}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@ -10,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { CompanyRequestDTO, CompanySummaryDTO } from "../../common";
|
import type { CompanyRequestDTO, CompanySummaryDTO } from "../../common";
|
||||||
import { CompanyRequestSchema } from "../../common";
|
import { CompanyRequestSchema } from "../../common";
|
||||||
|
import { EntityHelp } from "../access/entity-help";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
@ -72,9 +74,13 @@ export function CompanyForm({
|
|||||||
invalid={Object.keys(form.formState.errors).length > 0}
|
invalid={Object.keys(form.formState.errors).length > 0}
|
||||||
success={mutation.isSuccess}
|
success={mutation.isSuccess}
|
||||||
/>
|
/>
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
<EntityHelp kind="company" />
|
||||||
</Button>
|
<FormActions>
|
||||||
|
<Button disabled={mutation.isPending} type="submit">
|
||||||
|
{t(mutation.isPending ? "saving" : "save")}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import {
|
import {
|
||||||
@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { CompanyDTO, DepartmentDTO, DepartmentRequestDTO } from "../../common";
|
import type { CompanyDTO, DepartmentDTO, DepartmentRequestDTO } from "../../common";
|
||||||
import { DepartmentRequestSchema } from "../../common";
|
import { DepartmentRequestSchema } from "../../common";
|
||||||
|
import { EntityHelp } from "../access/entity-help";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
@ -73,9 +74,13 @@ function DepartmentForm({
|
|||||||
error={mutation.error}
|
error={mutation.error}
|
||||||
invalid={Object.keys(form.formState.errors).length > 0}
|
invalid={Object.keys(form.formState.errors).length > 0}
|
||||||
/>
|
/>
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
<EntityHelp kind="department" />
|
||||||
</Button>
|
<FormActions>
|
||||||
|
<Button disabled={mutation.isPending} type="submit">
|
||||||
|
{t(mutation.isPending ? "saving" : "save")}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -132,6 +137,7 @@ export function DepartmentSection({
|
|||||||
)}
|
)}
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditor(undefined);
|
setEditor(undefined);
|
||||||
|
|||||||
@ -143,6 +143,7 @@ function PersonDetails({
|
|||||||
</Card>
|
</Card>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditor(undefined);
|
setEditor(undefined);
|
||||||
@ -204,40 +205,41 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [contactsOnly, setContactsOnly] = useState(false);
|
const [contactsOnly, setContactsOnly] = useState(false);
|
||||||
const selected = company.people.find((item) => item.id === selection?.id);
|
const selected = company.people.find((item) => item.id === selection?.id);
|
||||||
if (selection?.mode === "new" || (selection?.mode === "edit" && selected)) {
|
const personEditor = (
|
||||||
return (
|
<FormDialog
|
||||||
<div className="space-y-6">
|
cancelLabel={t("cancel")}
|
||||||
<Button
|
formActions
|
||||||
onClick={() => setSelection(selected ? { mode: "detail", id: selected.id } : undefined)}
|
onOpenChange={(open) => {
|
||||||
variant="outline"
|
if (!open) {
|
||||||
>
|
setSelection(selected ? { mode: "detail", id: selected.id } : undefined);
|
||||||
{t("backToPeople")}
|
}
|
||||||
</Button>
|
}}
|
||||||
<h2 className="text-2xl font-semibold">{t(selected ? "editPerson" : "newPerson")}</h2>
|
open={selection?.mode === "new" || selection?.mode === "edit"}
|
||||||
<Card>
|
title={t(selected ? "editPerson" : "newPerson")}
|
||||||
<CardContent className="pt-6">
|
wide
|
||||||
<PersonForm
|
>
|
||||||
companyId={company.id}
|
<PersonForm
|
||||||
csrfToken={csrfToken}
|
companyId={company.id}
|
||||||
key={selected?.id ?? "new"}
|
|
||||||
onSaved={(id) => setSelection({ mode: "detail", id })}
|
|
||||||
person={selected}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (selected) {
|
|
||||||
return (
|
|
||||||
<PersonDetails
|
|
||||||
company={company}
|
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
key={selected.id}
|
key={selected?.id ?? "new"}
|
||||||
onBack={() => setSelection(undefined)}
|
onSaved={(id) => setSelection({ mode: "detail", id })}
|
||||||
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
|
|
||||||
person={selected}
|
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(
|
const people = company.people.filter(
|
||||||
@ -247,6 +249,7 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{personEditor}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold">{t("people")}</h2>
|
<h2 className="text-lg font-semibold">{t("people")}</h2>
|
||||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
|
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@ -12,6 +13,7 @@ import { z } from "zod/v4";
|
|||||||
import type { PersonDTO } from "../../common";
|
import type { PersonDTO } from "../../common";
|
||||||
import { PersonRequestSchema } from "../../common";
|
import { PersonRequestSchema } from "../../common";
|
||||||
import { accessKeys } from "../access/access-api";
|
import { accessKeys } from "../access/access-api";
|
||||||
|
import { EntityHelp } from "../access/entity-help";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
@ -78,7 +80,6 @@ export function PersonForm({
|
|||||||
className="max-w-2xl space-y-6"
|
className="max-w-2xl space-y-6"
|
||||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||||
@ -124,9 +125,13 @@ export function PersonForm({
|
|||||||
error={mutation.error}
|
error={mutation.error}
|
||||||
invalid={Object.keys(form.formState.errors).length > 0}
|
invalid={Object.keys(form.formState.errors).length > 0}
|
||||||
/>
|
/>
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
<EntityHelp kind="person" />
|
||||||
</Button>
|
<FormActions>
|
||||||
|
<Button disabled={mutation.isPending} type="submit">
|
||||||
|
{t(mutation.isPending ? "saving" : "save")}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
@ -10,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { SiteDTO, SiteRequestDTO } from "../../common";
|
import type { SiteDTO, SiteRequestDTO } from "../../common";
|
||||||
import { SiteRequestSchema } from "../../common";
|
import { SiteRequestSchema } from "../../common";
|
||||||
|
import { EntityHelp } from "../access/entity-help";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
@ -73,9 +75,13 @@ export function SiteForm({
|
|||||||
invalid={Object.keys(form.formState.errors).length > 0}
|
invalid={Object.keys(form.formState.errors).length > 0}
|
||||||
success={mutation.isSuccess}
|
success={mutation.isSuccess}
|
||||||
/>
|
/>
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
<EntityHelp kind="site" />
|
||||||
</Button>
|
<FormActions>
|
||||||
|
<Button disabled={mutation.isPending} type="submit">
|
||||||
|
{t(mutation.isPending ? "saving" : "save")}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
</form>
|
</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 { Button } from "./button";
|
||||||
import {
|
import {
|
||||||
@ -10,33 +10,57 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "./dialog";
|
} from "./dialog";
|
||||||
|
|
||||||
|
const FormCancelContext = createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
export function FormDialog({
|
export function FormDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
title,
|
title,
|
||||||
cancelLabel,
|
cancelLabel,
|
||||||
children,
|
children,
|
||||||
|
wide = false,
|
||||||
|
formActions = false,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
title: string;
|
title: string;
|
||||||
cancelLabel: string;
|
cancelLabel: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
formActions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent
|
<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}
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{children}
|
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
|
||||||
<DialogFooter>
|
{children}
|
||||||
<DialogClose render={<Button variant="outline" />}>{cancelLabel}</DialogClose>
|
</FormCancelContext.Provider>
|
||||||
</DialogFooter>
|
{!formActions && (
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||||
|
{cancelLabel}
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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