diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md
index eb87546..fbc493c 100644
--- a/docs/componentes-de-interfaz.md
+++ b/docs/componentes-de-interfaz.md
@@ -62,3 +62,26 @@ ContextHelp muestra una explicación breve y permite desplegar ejemplos y recome
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.
+
+## SelectWithAction: desplegable con acción integrada
+
+El componente compartido vive en `@repo/shadcn-ui/components/select-with-action`. Compone Select, Button y ButtonGroup de shadcn/ui (Base UI, Vega). Presenta selección y botón «+» como un único grupo visual, manteniendo controles separados para teclado y lectores de pantalla. ButtonGroup utiliza fieldset para expresar semánticamente la agrupación.
+
+Su contrato recibe id, etiqueta, opciones, placeholder, valor controlado y callback de selección. La acción es opcional y contiene etiqueta accesible, callback y estado deshabilitado. Admite opciones deshabilitadas, estado inválido, descripción accesible y obligatoriedad. Deshabilitar el selector también deshabilita la acción. El botón usa type=button y no envía el formulario.
+
+No conoce compañías, HTTP, formularios ni modales: esas responsabilidades pertenecen al consumidor. No contiene textos propios que deban traducirse.
+
+### Formularios que permiten alta rápida
+
+Sistema permite crear un sistema propio de la compañía mediante un modal adicional. Entorno permite crear un entorno para el sistema activo seleccionado; su acción no aparece antes de elegir un sistema. Los campos fijos en edición no ofrecen alta rápida. Uso y otros valores cerrados tampoco ofrecen altas.
+
+QuickCreateDialog conserva los valores del formulario principal, actualiza el catálogo y selecciona el nuevo UUID después de guardar. Usa las rutas, protección CSRF y reglas de negocio existentes. Un nombre activo ya existente permite usar ese registro sin duplicarlo. Cancelar el modal adicional conserva la cuenta sin cambios. El elemento creado permanece en el catálogo aunque se cancele después la cuenta.
+
+La ayuda aparece antes del pie con Guardar y seleccionar / Cancelar. Solo hay un nivel de alta adicional; el nuevo formulario no vuelve a ofrecer altas rápidas.
+
+
+La revisión de los formularios extiende este patrón a personas, departamentos, cuentas, grupos, recursos, tipos de recurso, permisos y plantillas. Las asignaciones de departamento permiten añadir el primero sin abandonar el formulario. Se reutilizan los formularios y rutas existentes, manteniendo su validación y ayuda inferior.
+
+Los entornos necesitan un sistema activo seleccionado. Los permisos nuevos heredan el tipo del recurso elegido. Al crear un grupo desde una pertenencia, sistema y entorno quedan fijados a los de la cuenta. Las plantillas siguen siendo globales; el modal lo explica y carga únicamente sus opciones globales. Los elementos creados desde un selector están activos para poder seleccionarlos.
+
+Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas.
diff --git a/modules/companies/src/web/access/access-field-control.tsx b/modules/companies/src/web/access/access-field-control.tsx
index 76ad424..cf00cf9 100644
--- a/modules/companies/src/web/access/access-field-control.tsx
+++ b/modules/companies/src/web/access/access-field-control.tsx
@@ -1,13 +1,8 @@
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@repo/shadcn-ui/components/select";
+import type { SelectAction } from "@repo/shadcn-ui/components/select-with-action";
+import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -24,6 +19,9 @@ export function AccessFieldControl({
options,
disabled,
onSelected,
+ onCreate,
+ createDisabled = false,
+ createLabel,
}: {
field: AccessField;
id: string;
@@ -32,6 +30,9 @@ export function AccessFieldControl({
options: readonly Option[];
disabled: boolean;
onSelected: (value: string) => void;
+ onCreate?: (() => void) | undefined;
+ createDisabled?: boolean;
+ createLabel?: string | undefined;
}) {
const { t } = useTranslation("access");
if (field.type === "boolean") {
@@ -39,6 +40,7 @@ export function AccessFieldControl({
form.setValue(field.key, checked === true)}
/>
@@ -48,28 +50,13 @@ export function AccessFieldControl({
}
if (field.type === "permissions") {
return (
-
- {t(field.key)}
- {options.map((option) => (
-
- {
- const values = Array.isArray(value) ? value : [];
- form.setValue(
- field.key,
- checked
- ? [...values, option.value]
- : values.filter((item) => item !== option.value)
- );
- }}
- />
- {option.label}
-
- ))}
- {!options.length && {t("noPermissions")}
}
-
+
);
}
return (
@@ -81,28 +68,20 @@ export function AccessFieldControl({
{field.type === "note" ? (
) : field.type === "select" ? (
- {
- if (typeof selected === "string") {
- onSelected(selected);
- }
- }}
+ id={id}
+ label={t(field.key)}
+ onValueChange={onSelected}
+ options={options}
+ placeholder={t("choose")}
value={stringValue(value)}
- >
-
-
-
-
- {t("choose")}
- {options.map((option) => (
-
- {option.label}
-
- ))}
-
-
+ />
) : (
;
+}) {
+ const { t } = useTranslation("access");
+ const values = Array.isArray(value) ? value : [];
+ return (
+
+ {t(fieldKey)}
+ {options.map((option) => (
+
+
+ form.setValue(
+ fieldKey,
+ checked ? [...values, option.value] : values.filter((item) => item !== option.value)
+ )
+ }
+ />
+ {option.label}
+
+ ))}
+ {!options.length && {t("noPermissions")}
}
+
+ );
+}
+
+function quickAddLabel(key: string) {
+ return key === "systemId" ? "quickAddSystem" : "quickAddEnvironment";
+}
+function selectAction(
+ onClick: (() => void) | undefined,
+ disabled: boolean,
+ label: string
+): SelectAction | undefined {
+ if (!onClick) {
+ return undefined;
+ }
+ return { onClick, disabled, label };
+}
diff --git a/modules/companies/src/web/access/access-form.tsx b/modules/companies/src/web/access/access-form.tsx
index 3a763f8..b47df30 100644
--- a/modules/companies/src/web/access/access-form.tsx
+++ b/modules/companies/src/web/access/access-form.tsx
@@ -1,21 +1,31 @@
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 { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import type { AccessItemDTO, AccessKind, AccessResponseDTO, PersonDTO } from "../../common";
+import type {
+ AccessFieldsDTO,
+ AccessItemDTO,
+ AccessKind,
+ AccessResponseDTO,
+ PersonDTO,
+} from "../../common";
import { AccessFieldsSchema } from "../../common";
+import { PersonForm } from "../components/person-form";
import { AccessApiError, accessApi, accessKeys } from "./access-api";
import { AccessFieldControl } from "./access-field-control";
import type { AccessField } from "./access-fields";
-import { accessFields } from "./access-fields";
+import { accessFields, createLabels } 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";
+import { QuickCreateDialog } from "./quick-create-dialog";
+import type { QuickCreatePlan } from "./quick-create-plan";
+import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
export function AccessForm({
scope,
kind,
@@ -24,6 +34,9 @@ export function AccessForm({
people,
csrfToken,
onSaved,
+ allowQuickCreate = true,
+ initialValues = {},
+ lockedFields = [],
}: {
scope: string;
kind: AccessKind;
@@ -31,16 +44,23 @@ export function AccessForm({
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
- onSaved: () => void;
+ onSaved: (id?: string, values?: AccessFieldsDTO) => void;
+ allowQuickCreate?: boolean;
+ initialValues?: AccessFormValues;
+ lockedFields?: readonly string[];
}) {
const { t } = useTranslation("access");
const prefix = useId();
const client = useQueryClient();
const [validation, setValidation] = useState();
const fields = accessFields[kind];
+ const [quickCreate, setQuickCreate] = useState<{
+ field: AccessField;
+ plan: QuickCreatePlan;
+ }>();
const form = useForm({
defaultValues: Object.fromEntries(
- fields.map((field) => [field.key, fieldDefault(field, item)])
+ fields.map((field) => [field.key, initialValues[field.key] ?? fieldDefault(field, item)])
),
});
const watched = form.watch();
@@ -56,10 +76,18 @@ export function AccessForm({
}
return accessApi.request(scope, parsed.data, item?.id, csrfToken);
},
- onSuccess: async (result) => {
+ onSuccess: async (result, submitted) => {
+ const savedId =
+ item?.id ??
+ result.items.find(
+ (candidate) =>
+ candidate.companyId === (scope === "global" ? null : scope) &&
+ !data.items.some((existing) => existing.id === candidate.id) &&
+ matchesSubmittedValues(candidate.values, AccessFieldsSchema.parse(submitted))
+ )?.id;
await client.invalidateQueries({ queryKey: ["access"] });
client.setQueryData(accessKeys.scope(scope), result);
- onSaved();
+ onSaved(savedId, AccessFieldsSchema.parse(submitted));
},
});
const submit = form.handleSubmit(async (raw) => {
@@ -71,8 +99,12 @@ export function AccessForm({
}
await mutation.mutateAsync(parsed.data).catch(() => undefined);
});
- const select = (field: AccessField, value: string) => {
+ const select = (field: AccessField, value: string, createdValues?: AccessFieldsDTO) => {
form.setValue(field.key, value);
+ if (field.key === "resourceId" || field.key === "resourceTypeId") {
+ form.setValue("permissionId", "");
+ form.setValue("permissionIds", []);
+ }
if (field.key === "systemId") {
form.setValue("environmentId", "");
}
@@ -83,17 +115,17 @@ export function AccessForm({
form.setValue("accountId", "");
}
if (field.key === "templateId") {
- applyTemplate(value);
+ applyTemplate(value, createdValues);
}
};
- const applyTemplate = (id: string) => {
- const template = data.items.find((target) => target.id === id)?.values;
+ const applyTemplate = (id: string, createdValues?: AccessFieldsDTO) => {
+ const template = createdValues ?? data.items.find((target) => target.id === id)?.values;
if (template?.kind !== "template") {
return;
}
form.setValue("name", template.name);
form.setValue("description", template.description ?? "");
- if (template.systemId) {
+ if (template.systemId && !lockedFields.includes("systemId")) {
form.setValue("systemId", template.systemId);
form.setValue("environmentId", "");
}
@@ -113,6 +145,19 @@ export function AccessForm({
)
.map((target) => ({ value: target.id, label: accessLabel(target, data.items, people) }));
};
+ const quickAction = (field: AccessField) => {
+ if (!allowQuickCreate || (item && field.fixed) || lockedFields.includes(field.key)) {
+ return undefined;
+ }
+ const plan = quickCreatePlan(field, scope, kind, data, watched);
+ return plan ? () => setQuickCreate({ field, plan }) : undefined;
+ };
+ const created = (id?: string, values?: AccessFieldsDTO) => {
+ if (quickCreate && id) {
+ select(quickCreate.field, id, values);
+ setQuickCreate(undefined);
+ }
+ };
const help: Readonly> = {
account: "accountHelp",
coverage: "coverageHelp",
@@ -120,47 +165,168 @@ export function AccessForm({
};
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
return (
-
+ {quickCreate && (
+ setQuickCreate(undefined)}
+ onCreated={created}
+ people={people}
+ plan={quickCreate.plan}
+ scope={scope}
+ />
)}
- {mutation.isError && (
-
- {t(
- `errors.${mutation.error instanceof AccessApiError ? mutation.error.code : "INTERNAL_ERROR"}`
- )}
-
- )}
- {help[kind] && {t(help[kind])}
}
- {["account_person", "membership", "grant"].includes(kind) && (
- {t("configurationHelp")}
- )}
-
-
-
- {t(mutation.isPending ? "saving" : "save")}
-
-
-
+ >
+ );
+}
+
+function AccessQuickEditor({
+ plan,
+ scope,
+ data,
+ people,
+ csrfToken,
+ onCreated,
+ onClose,
+}: {
+ plan: QuickCreatePlan;
+ scope: string;
+ data: AccessResponseDTO;
+ people: readonly PersonDTO[];
+ csrfToken: string;
+ onCreated: (id?: string, values?: AccessFieldsDTO) => void;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation("access");
+ const active = useRef(true);
+ useEffect(() => {
+ active.current = true;
+ return () => {
+ active.current = false;
+ };
+ }, []);
+ const close = () => {
+ active.current = false;
+ onClose();
+ };
+ const created = (id?: string, values?: AccessFieldsDTO) => {
+ if (active.current) {
+ onCreated(id, values);
+ }
+ };
+ const globalQuery = useQuery({
+ queryKey: accessKeys.scope("global"),
+ queryFn: () => accessApi.request("global"),
+ enabled: plan.type === "access" && plan.scope === "global" && scope !== "global",
+ });
+ if (plan.type === "simple") {
+ return (
+
+ );
+ }
+ const title = plan.type === "person" ? "quickAddPerson" : createLabels[plan.kind];
+ const childData = plan.type === "access" && plan.scope !== scope ? globalQuery.data : data;
+ return (
+ {
+ if (!open) {
+ close();
+ }
+ }}
+ open
+ title={t(title)}
+ wide
+ >
+ {plan.type === "person" ? (
+ <>
+ {t("quickCreateNotice")}
+
+ >
+ ) : (
+ <>
+
+ {t(plan.scope === "global" ? "quickGlobalNotice" : "quickCreateNotice")}
+
+ {childData ? (
+
+ ) : (
+ {t("loading")}
+ )}
+ {globalQuery.isError && {t("errors.INTERNAL_ERROR")}
}
+ >
+ )}
+
);
}
diff --git a/modules/companies/src/web/access/access.test.tsx b/modules/companies/src/web/access/access.test.tsx
index 9b6dcd6..19584aa 100644
--- a/modules/companies/src/web/access/access.test.tsx
+++ b/modules/companies/src/web/access/access.test.tsx
@@ -6,10 +6,14 @@ import { createInstance } from "i18next";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
+import type { AccessResponseDTO } from "../../common";
+
import { accessKeys } from "./access-api";
+import { accessFields } from "./access-fields";
import { AccessForm } from "./access-form";
import { AccessWorkspacePage } from "./access-workspace";
import { accessEnglish, accessSpanish } from "./i18n";
+import { quickCreatePlan } from "./quick-create-plan";
test("los catálogos presentan alta mediante acción y no un formulario junto al listado", async () => {
for (const [language, label] of [
@@ -54,6 +58,8 @@ test("cuentas ofrecen caducidad opcional y campos sin contraseña", async () =>
);
assert.ok(markup.includes("Fecha de caducidad"));
+ assert.ok(markup.includes('aria-label="Añadir sistema"'));
+ assert.equal(markup.includes('aria-label="Añadir entorno"'), false);
assert.ok(markup.includes("Sistema, entorno y uso quedan fijos"));
assert.equal(markup.includes('type="password"'), false);
const field = markup.match(/ ]*name="expiresOn"[^>]*>/)?.[0];
@@ -143,3 +149,130 @@ test("los ejemplos del sistema orientan sin rellenar datos del alta", async () =
assert.equal(markup.includes('value="Microsoft 365"'), false);
client.clear();
});
+
+test("las altas rápidas respetan el contexto, ámbito y relaciones históricas", () => {
+ const data: AccessResponseDTO = {
+ items: [
+ {
+ id: "system",
+ companyId: null,
+ history: [],
+ values: { kind: "system", name: "Directorio", active: true },
+ },
+ {
+ id: "type",
+ companyId: null,
+ history: [],
+ values: { kind: "resource_type", name: "Carpeta", active: true },
+ },
+ {
+ id: "resource",
+ companyId: "company",
+ history: [],
+ values: {
+ kind: "resource",
+ name: "Documentos",
+ resourceTypeId: "type",
+ active: true,
+ systemId: null,
+ environmentId: null,
+ reference: null,
+ notes: null,
+ },
+ },
+ {
+ id: "account",
+ companyId: "company",
+ history: [],
+ values: {
+ kind: "account",
+ systemId: "system",
+ environmentId: "env",
+ login: "ana",
+ expiresOn: null,
+ notes: null,
+ usage: "personal",
+ active: true,
+ },
+ },
+ ],
+ reviews: [],
+ coverage: [],
+ };
+ const field = (kind: keyof typeof accessFields, key: string) => {
+ const result = accessFields[kind].find((candidate) => candidate.key === key);
+ assert.ok(result);
+ return result;
+ };
+ const plan = (kind: keyof typeof accessFields, key: string, values = {}, scope = "company") =>
+ quickCreatePlan(field(kind, key), scope, kind, data, values);
+ assert.equal(plan("account", "usage"), undefined);
+ assert.equal(plan("account", "environmentId"), undefined);
+ assert.deepEqual(plan("account", "environmentId", { systemId: "system" }), {
+ type: "simple",
+ target: { kind: "environment", systemId: "system", systemName: "Directorio" },
+ });
+ assert.equal(plan("grant", "permissionId"), undefined);
+ assert.deepEqual(plan("grant", "permissionId", { resourceId: "resource" }), {
+ type: "access",
+ kind: "permission",
+ scope: "company",
+ defaults: { active: true, resourceTypeId: "type" },
+ locked: ["active", "resourceTypeId"],
+ });
+ assert.equal(plan("membership", "groupId"), undefined);
+ assert.deepEqual(plan("membership", "groupId", { accountId: "account" }), {
+ type: "access",
+ kind: "group",
+ scope: "company",
+ defaults: { active: true, systemId: "system", environmentId: "env" },
+ locked: ["active", "systemId", "environmentId"],
+ });
+ assert.deepEqual(plan("account_person", "personId"), { type: "person" });
+ assert.equal(plan("account_person", "personId", {}, "global"), undefined);
+ assert.deepEqual(plan("group", "templateId"), {
+ type: "access",
+ kind: "template",
+ scope: "global",
+ defaults: { active: true },
+ locked: ["active"],
+ });
+ for (const entry of accessFields.coverage) {
+ assert.equal(quickCreatePlan(entry, "company", "coverage", data, {}), undefined);
+ }
+});
+
+test("los formularios ofrecen altas relacionadas y el segundo modal evita altas anidadas", async () => {
+ const i18n = createInstance();
+ await i18n.init({ lng: "es", resources: { es: { access: accessSpanish } } });
+ const client = new QueryClient();
+ const render = (kind: keyof typeof accessFields, allowQuickCreate = true) =>
+ renderToStaticMarkup(
+
+
+ undefined}
+ people={[]}
+ scope="company"
+ />
+
+
+ );
+ for (const kind of [
+ "permission",
+ "account_person",
+ "resource",
+ "grant",
+ "need",
+ "group",
+ ] as const) {
+ assert.ok(render(kind).includes('data-slot="button-group"'), kind);
+ assert.equal(render(kind, false).includes('data-slot="button-group"'), false, kind);
+ }
+ assert.equal(render("coverage").includes('data-slot="button-group"'), false);
+ client.clear();
+});
diff --git a/modules/companies/src/web/access/i18n.ts b/modules/companies/src/web/access/i18n.ts
index 76d047a..06aae1e 100644
--- a/modules/companies/src/web/access/i18n.ts
+++ b/modules/companies/src/web/access/i18n.ts
@@ -2,6 +2,19 @@ import { helpEnglish, helpSpanish } from "./help-i18n";
export const accessSpanish = {
...helpSpanish,
+ quickAddPerson: "Nueva persona",
+ quickAddEntry: "Añadir: {{name}}",
+ quickGlobalNotice:
+ "Se guardará en el catálogo global y estará disponible para todas las compañías, aunque canceles el formulario principal.",
+ quickExisting:
+ "Ya existe un elemento activo con este nombre. Puedes seleccionarlo sin crear otro.",
+ quickUseExisting: "Usar existente",
+ quickAddSystem: "Añadir sistema",
+ quickAddEnvironment: "Añadir entorno",
+ quickSaveSelect: "Guardar y seleccionar",
+ quickEnvironmentContext: "Sistema: {{name}}",
+ quickCreateNotice:
+ "Se guardará en el catálogo de esta compañía, aunque después canceles el formulario principal.",
title: "Cuentas y accesos",
catalogTitle: "Catálogos informáticos",
catalogHelp:
@@ -132,6 +145,19 @@ export const accessSpanish = {
};
export const accessEnglish = {
...helpEnglish,
+ quickAddPerson: "New person",
+ quickAddEntry: "Add: {{name}}",
+ quickGlobalNotice:
+ "It will be saved in the global catalogue for all companies even if you cancel the main form.",
+ quickExisting:
+ "An active entry with this name already exists. You can select it without creating another.",
+ quickUseExisting: "Use existing",
+ quickAddSystem: "Add system",
+ quickAddEnvironment: "Add environment",
+ quickSaveSelect: "Save and select",
+ quickEnvironmentContext: "System: {{name}}",
+ quickCreateNotice:
+ "It will be saved in this company's catalogue even if you later cancel the main form.",
title: "Accounts and access",
catalogTitle: "IT catalogs",
catalogHelp: "Shared definitions for all companies. Companies can add their own entries.",
diff --git a/modules/companies/src/web/access/quick-create-dialog.tsx b/modules/companies/src/web/access/quick-create-dialog.tsx
new file mode 100644
index 0000000..fce51aa
--- /dev/null
+++ b/modules/companies/src/web/access/quick-create-dialog.tsx
@@ -0,0 +1,151 @@
+import { Button } from "@repo/shadcn-ui/components/button";
+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 { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useId, useRef } from "react";
+import { useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+import type { AccessFieldsDTO, AccessItemDTO } from "../../common";
+import { AccessFieldsSchema } from "../../common";
+
+import { AccessApiError, accessApi, accessKeys } from "./access-api";
+import { EntityHelp } from "./entity-help";
+
+export type QuickCreateTarget =
+ | { kind: "system" }
+ | { kind: "environment"; systemId: string; systemName: string };
+
+export function QuickCreateDialog({
+ target,
+ scope,
+ csrfToken,
+ onClose,
+ onCreated,
+ items,
+}: {
+ target: QuickCreateTarget;
+ scope: string;
+ csrfToken: string;
+ onClose: () => void;
+ onCreated: (id: string) => void;
+ items: readonly AccessItemDTO[];
+}) {
+ const { t } = useTranslation("access");
+ const id = useId();
+ const client = useQueryClient();
+ const dismissed = useRef(false);
+ const form = useForm<{ name: string }>({ defaultValues: { name: "" } });
+ const enteredName = form.watch("name").trim().toLocaleLowerCase();
+ const existing = items.find((item) => {
+ const values = item.values;
+ return (
+ "name" in values &&
+ "active" in values &&
+ values.active &&
+ values.name.toLocaleLowerCase() === enteredName &&
+ (target.kind === "system"
+ ? values.kind === "system"
+ : values.kind === "environment" && values.systemId === target.systemId)
+ );
+ });
+ const mutation = useMutation({
+ mutationFn: async ({ name }: { name: string }) => {
+ const values = AccessFieldsSchema.parse(
+ target.kind === "system"
+ ? { kind: "system", name, active: true }
+ : { kind: "environment", name, active: true, systemId: target.systemId }
+ );
+ const result = await accessApi.request(scope, values, undefined, csrfToken);
+ const created = result.items.find(
+ (item) =>
+ item.companyId === (scope === "global" ? null : scope) && matches(item.values, values)
+ );
+ if (!created) {
+ throw new AccessApiError("INTERNAL_ERROR");
+ }
+ return { result, id: created.id };
+ },
+ onSuccess: async ({ result, id }) => {
+ await client.invalidateQueries({ queryKey: ["access"] });
+ client.setQueryData(accessKeys.scope(scope), result);
+ if (!dismissed.current) {
+ onCreated(id);
+ }
+ },
+ });
+ return (
+ {
+ if (!open) {
+ dismissed.current = true;
+ onClose();
+ }
+ }}
+ open
+ title={t(target.kind === "system" ? "quickAddSystem" : "quickAddEnvironment")}
+ >
+
+
+ );
+}
+
+function matches(item: AccessFieldsDTO, values: AccessFieldsDTO) {
+ if (values.kind === "system" && item.kind === "system") {
+ return item.name === values.name;
+ }
+ if (values.kind === "environment" && item.kind === "environment") {
+ return item.name === values.name && item.systemId === values.systemId;
+ }
+ return false;
+}
diff --git a/modules/companies/src/web/access/quick-create-plan.ts b/modules/companies/src/web/access/quick-create-plan.ts
new file mode 100644
index 0000000..3188200
--- /dev/null
+++ b/modules/companies/src/web/access/quick-create-plan.ts
@@ -0,0 +1,120 @@
+import type { AccessFieldsDTO, AccessKind, AccessResponseDTO } from "../../common";
+
+import type { AccessField } from "./access-fields";
+import type { AccessFormValues } from "./access-form-values";
+import type { QuickCreateTarget } from "./quick-create-dialog";
+
+export type QuickCreatePlan =
+ | { type: "simple"; target: QuickCreateTarget }
+ | { type: "person" }
+ | {
+ type: "access";
+ kind: AccessKind;
+ scope: string;
+ defaults: AccessFormValues;
+ locked: readonly string[];
+ };
+
+const CREATABLE = new Set([
+ "system",
+ "environment",
+ "resource_type",
+ "permission",
+ "template",
+ "account",
+ "group",
+ "resource",
+]);
+
+/** Solo ofrece altas de fichas y catálogos; las autorizaciones históricas mantienen su flujo propio. */
+export function quickCreatePlan(
+ field: AccessField,
+ scope: string,
+ kind: AccessKind,
+ data: AccessResponseDTO,
+ watched: AccessFormValues
+): QuickCreatePlan | undefined {
+ if (field.key === "personId" && scope !== "global") {
+ return { type: "person" };
+ }
+ if (!(field.target && CREATABLE.has(field.target))) {
+ return undefined;
+ }
+ if (field.target === "system") {
+ return { type: "simple", target: { kind: "system" } };
+ }
+ if (field.target === "environment") {
+ return environmentPlan(data, watched);
+ }
+ const context = contextValues(field.target, kind, data, watched);
+ if (!context) {
+ return undefined;
+ }
+ const { defaults, locked } = context;
+ return {
+ type: "access",
+ kind: field.target,
+ scope: field.target === "template" ? "global" : scope,
+ defaults,
+ locked,
+ };
+}
+
+function environmentPlan(
+ data: AccessResponseDTO,
+ watched: AccessFormValues
+): QuickCreatePlan | undefined {
+ const system = data.items.find((item) => item.id === watched.systemId)?.values;
+ if (system?.kind !== "system" || !system.active || typeof watched.systemId !== "string") {
+ return undefined;
+ }
+ return {
+ type: "simple",
+ target: { kind: "environment", systemId: watched.systemId, systemName: system.name },
+ };
+}
+
+function contextValues(
+ target: AccessKind,
+ kind: AccessKind,
+ data: AccessResponseDTO,
+ watched: AccessFormValues
+) {
+ const defaults: AccessFormValues = { active: true };
+ const locked = ["active"];
+ if (target === "permission") {
+ const resource = data.items.find((item) => item.id === watched.resourceId)?.values;
+ const typeId = resource?.kind === "resource" ? resource.resourceTypeId : watched.resourceTypeId;
+ const resourceType = data.items.find((item) => item.id === typeId)?.values;
+ if (
+ resourceType?.kind !== "resource_type" ||
+ !resourceType.active ||
+ typeof typeId !== "string"
+ ) {
+ return undefined;
+ }
+ defaults.resourceTypeId = typeId;
+ locked.push("resourceTypeId");
+ }
+ if (target === "group" && kind === "membership") {
+ const account = data.items.find((item) => item.id === watched.accountId)?.values;
+ if (account?.kind !== "account" || !account.active) {
+ return undefined;
+ }
+ defaults.systemId = account.systemId;
+ defaults.environmentId = account.environmentId ?? "";
+ locked.push("systemId", "environmentId");
+ }
+ return { defaults, locked };
+}
+
+export function matchesSubmittedValues(left: AccessFieldsDTO, right: AccessFieldsDTO): boolean {
+ const entries = new Map(Object.entries(left));
+ return Object.entries(right).every(([key, value]) => {
+ const actual = entries.get(key);
+ if (Array.isArray(value) && Array.isArray(actual)) {
+ return value.length === actual.length && value.every((entry) => actual.includes(entry));
+ }
+ return actual === value;
+ });
+}
diff --git a/modules/companies/src/web/components/assignment-form.tsx b/modules/companies/src/web/components/assignment-form.tsx
index 93d0fe7..142b389 100644
--- a/modules/companies/src/web/components/assignment-form.tsx
+++ b/modules/companies/src/web/components/assignment-form.tsx
@@ -1,11 +1,12 @@
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 { 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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useId } from "react";
+import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
@@ -15,6 +16,7 @@ import { EntityHelp } from "../access/entity-help";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
+import { DepartmentForm } from "./department-section";
const FormSchema = z.strictObject({
departmentId: z.uuid(),
@@ -47,6 +49,7 @@ export function AssignmentForm({
}) {
const { t } = useTranslation("companies");
const id = useId();
+ const [creatingDepartment, setCreatingDepartment] = useState(false);
const client = useQueryClient();
const form = useForm({
resolver: zodResolver(FormSchema),
@@ -79,69 +82,106 @@ export function AssignmentForm({
},
});
return (
-
+
+
+
+ {t(mutation.isPending ? "saving" : "save")}
+
+
+
+ {creatingDepartment && (
+ {
+ if (!open) {
+ setCreatingDepartment(false);
+ }
+ }}
+ open
+ title={t("newDepartment")}
+ >
+ {
+ form.setValue("departmentId", departmentId, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ setCreatingDepartment(false);
+ }}
+ requireActive
+ />
+
+ )}
+ >
);
}
diff --git a/modules/companies/src/web/components/companies-page.test.tsx b/modules/companies/src/web/components/companies-page.test.tsx
index f27dfca..40a9ab0 100644
--- a/modules/companies/src/web/components/companies-page.test.tsx
+++ b/modules/companies/src/web/components/companies-page.test.tsx
@@ -10,6 +10,7 @@ import { I18nextProvider } from "react-i18next";
import { companyKeys } from "../api/company-api";
import { companiesEnglish, companiesSpanish } from "../i18n";
+import { AssignmentForm } from "./assignment-form";
import { CompaniesPage } from "./companies-page";
import { CompanyDetail } from "./company-detail";
import { PersonForm } from "./person-form";
@@ -123,3 +124,51 @@ test("el formulario de persona distingue contactos de cuentas de acceso y permit
}
assert.equal(markup.includes('name="password"'), false);
});
+
+test("asignar un departamento permite añadir el primero y conserva el selector en edición", async () => {
+ const company = {
+ id: companyId,
+ name: "Empresa",
+ active: true,
+ departments: [],
+ assignments: [],
+ people: [],
+ contacts: [],
+ sites: [],
+ };
+ const markup = await renderPage(
+ "es",
+ undefined}
+ personId={companyId}
+ />,
+ () => undefined
+ );
+ assert.ok(markup.includes('aria-label="Nuevo departamento"'));
+ assert.ok(markup.includes('role="combobox"'));
+ assert.equal(markup.includes(" undefined}
+ personId={companyId}
+ />,
+ () => undefined
+ );
+ assert.equal(edited.includes('aria-label="Nuevo departamento"'), false);
+});
diff --git a/modules/companies/src/web/components/department-section.tsx b/modules/companies/src/web/components/department-section.tsx
index d95712a..58b89b1 100644
--- a/modules/companies/src/web/components/department-section.tsx
+++ b/modules/companies/src/web/components/department-section.tsx
@@ -14,7 +14,7 @@ import {
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useId, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -25,20 +25,29 @@ import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
-function DepartmentForm({
+export function DepartmentForm({
companyId,
department,
csrfToken,
onSaved,
+ requireActive = false,
}: {
companyId: string;
department?: DepartmentDTO | undefined;
csrfToken: string;
- onSaved: () => void;
+ onSaved: (id: string) => void;
+ requireActive?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
const form = useForm({
resolver: zodResolver(DepartmentRequestSchema),
defaultValues: { name: department?.name ?? "", active: department?.active ?? true },
@@ -48,7 +57,12 @@ function DepartmentForm({
companyApi.department(companyId, department?.id, values, csrfToken),
onSuccess: (response) => {
client.setQueryData(companyKeys.detail(companyId), response);
- onSaved();
+ const saved = department
+ ? response.company.departments.find((item) => item.id === department.id)
+ : response.company.departments.at(-1);
+ if (saved && mounted.current) {
+ onSaved(saved.id);
+ }
},
});
return (
@@ -60,6 +74,7 @@ function DepartmentForm({
form.setValue("active", checked === true, { shouldDirty: true })
diff --git a/modules/companies/src/web/components/people-section.tsx b/modules/companies/src/web/components/people-section.tsx
index 3507cc8..290632d 100644
--- a/modules/companies/src/web/components/people-section.tsx
+++ b/modules/companies/src/web/components/people-section.tsx
@@ -84,10 +84,7 @@ function PersonDetails({
{t("departmentAssignments")}
- item.active))}
- onClick={() => setEditor("new")}
- >
+ setEditor("new")}>
{t("addAssignment")}
diff --git a/modules/companies/src/web/components/person-form.tsx b/modules/companies/src/web/components/person-form.tsx
index 156bda6..048686b 100644
--- a/modules/companies/src/web/components/person-form.tsx
+++ b/modules/companies/src/web/components/person-form.tsx
@@ -29,11 +29,13 @@ export function PersonForm({
companyId,
csrfToken,
onSaved,
+ requireActive = false,
}: {
person?: PersonDTO | undefined;
companyId: string;
csrfToken: string;
onSaved: (id: string) => void;
+ requireActive?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
@@ -101,6 +103,7 @@ export function PersonForm({
form.setValue("active", checked === true, { shouldDirty: true })
diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts
index 53b1c34..9a99a0f 100644
--- a/modules/companies/src/web/i18n.ts
+++ b/modules/companies/src/web/i18n.ts
@@ -42,7 +42,7 @@ export const companiesSpanish = {
"Sin fecha de fin, la asignación sigue abierta. Para registrar un cambio de puesto con un nuevo periodo, cierra esta asignación y crea la siguiente; puedes usar el mismo día. Las ediciones conservan su historial.",
history: "Historial",
assignmentHistory: "Historial de la asignación",
- createDepartmentFirst: "Crea o activa un departamento para poder asignarlo.",
+ createDepartmentFirst: "Puedes añadir un departamento desde el formulario de asignación.",
yes: "Sí",
no: "No",
@@ -148,7 +148,7 @@ export const companiesEnglish = {
"Without an end date, the assignment remains open. To record a new job period, close this assignment and create the next one; both may use the same day. Edits retain history.",
history: "History",
assignmentHistory: "Assignment history",
- createDepartmentFirst: "Create or activate a department before assigning it.",
+ createDepartmentFirst: "You can add a department from the assignment form.",
yes: "Yes",
no: "No",
diff --git a/packages/shadcn-ui/package.json b/packages/shadcn-ui/package.json
index ac59fcb..17e8be7 100644
--- a/packages/shadcn-ui/package.json
+++ b/packages/shadcn-ui/package.json
@@ -11,7 +11,8 @@
},
"scripts": {
"ui:add": "shadcn add",
- "typecheck": "tsc -p tsconfig.json"
+ "typecheck": "tsc -p tsconfig.json",
+ "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
diff --git a/packages/shadcn-ui/src/components/button-group.tsx b/packages/shadcn-ui/src/components/button-group.tsx
new file mode 100644
index 0000000..af2c955
--- /dev/null
+++ b/packages/shadcn-ui/src/components/button-group.tsx
@@ -0,0 +1,77 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { Separator } from "@repo/shadcn-ui/components/separator";
+import { type VariantProps, cva } from "class-variance-authority";
+import { cn } from "cn";
+import type { ComponentProps } from "react";
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+ vertical:
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: ComponentProps<"fieldset"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "button-group-text",
+ },
+ });
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
diff --git a/packages/shadcn-ui/src/components/select-with-action.tsx b/packages/shadcn-ui/src/components/select-with-action.tsx
new file mode 100644
index 0000000..919476b
--- /dev/null
+++ b/packages/shadcn-ui/src/components/select-with-action.tsx
@@ -0,0 +1,86 @@
+import { PlusIcon } from "lucide-react";
+
+import { Button } from "./button";
+import { ButtonGroup } from "./button-group";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
+
+export type SelectOption = { value: string; label: string; disabled?: boolean };
+export type SelectAction = { label: string; onClick: () => void; disabled?: boolean };
+
+/** Selección controlada con una acción adyacente; el consumidor gestiona el alta y su contexto. */
+export function SelectWithAction({
+ id,
+ label,
+ options,
+ placeholder,
+ value,
+ onValueChange,
+ disabled = false,
+ action,
+ required = false,
+ invalid = false,
+ describedBy,
+}: {
+ id: string;
+ label: string;
+ options: readonly SelectOption[];
+ placeholder: string;
+ value: string;
+ onValueChange: (value: string) => void;
+ disabled?: boolean;
+ action?: SelectAction | undefined;
+ required?: boolean;
+ invalid?: boolean;
+ describedBy?: string | undefined;
+}) {
+ const select = (
+ {
+ if (typeof selected === "string") {
+ onValueChange(selected);
+ }
+ }}
+ required={required}
+ value={value}
+ >
+
+
+
+
+ {placeholder}
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+ if (!action) {
+ return select;
+ }
+ return (
+
+ {select}
+
+
+
+
+ );
+}
diff --git a/packages/shadcn-ui/src/components/separator.tsx b/packages/shadcn-ui/src/components/separator.tsx
new file mode 100644
index 0000000..b6a44c9
--- /dev/null
+++ b/packages/shadcn-ui/src/components/separator.tsx
@@ -0,0 +1,18 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+import { cn } from "cn";
+
+function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/packages/shadcn-ui/src/select-with-action.test.tsx b/packages/shadcn-ui/src/select-with-action.test.tsx
new file mode 100644
index 0000000..8034a5a
--- /dev/null
+++ b/packages/shadcn-ui/src/select-with-action.test.tsx
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { SelectWithAction } from "./components/select-with-action";
+
+test("el selector agrupa la acción etiquetada sin convertirla en envío de formulario", () => {
+ const markup = renderToStaticMarkup(
+ undefined }}
+ id="system"
+ label="Sistema"
+ onValueChange={() => undefined}
+ options={[{ value: "domain", label: "Dominio" }]}
+ placeholder="Selecciona"
+ value=""
+ />
+ );
+ assert.ok(markup.includes('data-slot="button-group"'));
+ assert.ok(markup.includes('aria-label="Añadir sistema"'));
+ const button = markup.match(/]*aria-label="Añadir sistema"[^>]*>/)?.[0];
+ assert.ok(button);
+ assert.ok(button.includes('type="button"'));
+});
+test("el selector deshabilitado protege ambas acciones y admite selección sin botón", () => {
+ const markup = renderToStaticMarkup(
+ undefined }}
+ disabled
+ id="system"
+ label="Sistema"
+ onValueChange={() => undefined}
+ options={[]}
+ placeholder="Selecciona"
+ value=""
+ />
+ );
+ const button = markup.match(/]*aria-label="Añadir sistema"[^>]*>/)?.[0];
+ assert.ok(button?.includes("disabled"));
+ const plain = renderToStaticMarkup(
+ undefined}
+ options={[]}
+ placeholder="Selecciona"
+ value=""
+ />
+ );
+ assert.equal(plain.includes('data-slot="button-group"'), false);
+});
diff --git a/packages/shadcn-ui/tsconfig.json b/packages/shadcn-ui/tsconfig.json
index 3960f26..e2c8cdc 100644
--- a/packages/shadcn-ui/tsconfig.json
+++ b/packages/shadcn-ui/tsconfig.json
@@ -2,7 +2,8 @@
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
- "lib": ["ES2022", "DOM", "DOM.Iterable"]
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node"]
},
"include": ["src"]
}