feat: add person and department management features
- Implemented Person entity with validation and update methods. - Created CalendarDate value object for date validation. - Developed migration script to transition company_contacts to person and department structures. - Established Sequelize models for departments and assignments. - Introduced request DTOs for person and department management. - Added shared DTO schemas for validation. - Built assignment and department forms with validation and submission handling. - Created people section to manage and display company personnel. - Implemented person form for adding and editing personnel details.
This commit is contained in:
parent
e3f92fa534
commit
18324f92bd
@ -7,7 +7,8 @@ Aplicación interna para soporte técnico a pequeñas empresas.
|
||||
- [Organización del código](docs/arquitectura/capas-del-servidor.md)
|
||||
- [Usuarios y acceso](docs/usuarios-y-acceso.md)
|
||||
- [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md)
|
||||
- [Personas y departamentos](docs/personas-y-departamentos.md)
|
||||
- [Componentes de interfaz](docs/componentes-de-interfaz.md)
|
||||
- [Alcance y siguientes entregas](docs/plan-de-desarrollo.md)
|
||||
|
||||
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con contactos y sedes. Inventario y tickets se incorporarán progresivamente.
|
||||
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes. Inventario y tickets se incorporarán progresivamente.
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
CompanyErrorResponseSchema,
|
||||
CompanyResponseSchema,
|
||||
} from "@metis/companies/common";
|
||||
import { createCompanyModule } from "@metis/companies/module";
|
||||
import {
|
||||
IdentityErrorResponseSchema,
|
||||
SessionResponseSchema,
|
||||
@ -31,9 +32,41 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||
await database.query("DELETE FROM http_sessions");
|
||||
await database.query("DELETE FROM users");
|
||||
await database.query("DELETE FROM department_assignment_revisions");
|
||||
await database.query("DELETE FROM person_department_assignments");
|
||||
await database.query("DELETE FROM company_contacts");
|
||||
await database.query("DELETE FROM company_departments");
|
||||
await database.query("DELETE FROM company_sites");
|
||||
await database.query("DELETE FROM companies");
|
||||
// Simular la tabla de contactos de la versión anterior solo en la base de pruebas.
|
||||
const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411";
|
||||
const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898";
|
||||
await database.query("INSERT INTO companies (id,name,active) VALUES (:id,'Legacy',1)", {
|
||||
replacements: { id: legacyCompanyId },
|
||||
});
|
||||
await database.query(
|
||||
"INSERT INTO company_contacts (id,companyId,name,position,email,phone,active,isContact) VALUES (:id,:companyId,'Contacto anterior','Compras','legacy@example.test','900123456',1,1)",
|
||||
{ replacements: { id: legacyPersonId, companyId: legacyCompanyId } }
|
||||
);
|
||||
await database.query(
|
||||
"ALTER TABLE company_contacts DROP COLUMN isContact, MODIFY position VARCHAR(100) NOT NULL, MODIFY email VARCHAR(254) NOT NULL, MODIFY phone VARCHAR(50) NOT NULL"
|
||||
);
|
||||
await createCompanyModule(database, (_request, _response, next) => next());
|
||||
await createCompanyModule(database, (_request, _response, next) => next());
|
||||
const legacy = await database.query<{ id: string; email: string; isContact: number }>(
|
||||
"SELECT id,email,isContact FROM company_contacts WHERE id=:id",
|
||||
{ replacements: { id: legacyPersonId }, type: QueryTypes.SELECT }
|
||||
);
|
||||
assert.equal(legacy[0]?.id, legacyPersonId);
|
||||
assert.equal(legacy[0]?.email, "legacy@example.test");
|
||||
assert.equal(Number(legacy[0]?.isContact), 1);
|
||||
await database.query("DELETE FROM company_contacts WHERE id=:id", {
|
||||
replacements: { id: legacyPersonId },
|
||||
});
|
||||
await database.query("DELETE FROM companies WHERE id=:id", {
|
||||
replacements: { id: legacyCompanyId },
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}/api`;
|
||||
@ -388,6 +421,242 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
{ name: "Sede central", address: null }
|
||||
);
|
||||
assert.equal(clearedSite.status, 200);
|
||||
// Personas y departamentos: mismas fronteras de sesión, UUID y compañía.
|
||||
const peoplePath = `/companies/${customer.id}`;
|
||||
async function companyData(response: Response) {
|
||||
assert.ok(response.status === 200 || response.status === 201);
|
||||
return CompanyResponseSchema.parse(await response.json()).company;
|
||||
}
|
||||
async function companyError(response: Response, status: number, code: string) {
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code);
|
||||
}
|
||||
const personBody = {
|
||||
name: "Persona sin correo",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
};
|
||||
const personResponse = await companyData(
|
||||
await operator.request(`${peoplePath}/people`, personBody)
|
||||
);
|
||||
const companyPerson = personResponse.people.find((item) => item.name === personBody.name);
|
||||
assert.ok(companyPerson);
|
||||
assert.equal(companyPerson.email, null);
|
||||
assert.equal(companyPerson.id.charAt(14), "7");
|
||||
assert.equal(
|
||||
personResponse.contacts.some((item) => item.id === companyPerson.id),
|
||||
false
|
||||
);
|
||||
await companyError(
|
||||
await operator.request(`${peoplePath}/people`, { ...personBody, isInitialAdmin: true }),
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
await companyError(
|
||||
await operator.request(
|
||||
`${peoplePath}/departments`,
|
||||
{ name: "Forbidden", active: true },
|
||||
"https://other.example"
|
||||
),
|
||||
403,
|
||||
"FORBIDDEN"
|
||||
);
|
||||
const departmentResponse = await companyData(
|
||||
await operator.request(`${peoplePath}/departments`, { name: " Compras ", active: true })
|
||||
);
|
||||
const department = departmentResponse.departments.find((item) => item.name === "Compras");
|
||||
assert.ok(department);
|
||||
await companyError(
|
||||
await operator.request(`${peoplePath}/departments`, { name: "COMPRAS", active: true }),
|
||||
409,
|
||||
"DEPARTMENT_NAME_EXISTS"
|
||||
);
|
||||
const otherDepartmentResponse = await companyData(
|
||||
await operator.request(`/companies/${secondCompany.id}/departments`, {
|
||||
name: "Compras",
|
||||
active: true,
|
||||
})
|
||||
);
|
||||
const foreignDepartment = otherDepartmentResponse.departments[0];
|
||||
assert.ok(foreignDepartment);
|
||||
const assignmentBody = {
|
||||
departmentId: department.id,
|
||||
position: "Responsable",
|
||||
startDate: "2026-01-01",
|
||||
endDate: null,
|
||||
isPrimary: true,
|
||||
};
|
||||
await companyError(
|
||||
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
departmentId: foreignDepartment.id,
|
||||
}),
|
||||
404,
|
||||
"DEPARTMENT_NOT_FOUND"
|
||||
);
|
||||
const assignmentResponse = await companyData(
|
||||
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, assignmentBody)
|
||||
);
|
||||
const membership = assignmentResponse.assignments.find(
|
||||
(item) => item.personId === companyPerson.id
|
||||
);
|
||||
assert.ok(membership);
|
||||
assert.equal(membership.id.charAt(14), "7");
|
||||
assert.equal(membership.history.length, 1);
|
||||
await companyError(
|
||||
await operator.request(
|
||||
`${peoplePath}/people/${companyPerson.id}/assignments`,
|
||||
assignmentBody
|
||||
),
|
||||
409,
|
||||
"ASSIGNMENT_CONFLICT"
|
||||
);
|
||||
const secondResponse = await companyData(
|
||||
await operator.request(`${peoplePath}/departments`, { name: "Administración", active: true })
|
||||
);
|
||||
const secondDepartment = secondResponse.departments.find(
|
||||
(item) => item.name === "Administración"
|
||||
);
|
||||
assert.ok(secondDepartment);
|
||||
await companyError(
|
||||
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
departmentId: secondDepartment.id,
|
||||
}),
|
||||
409,
|
||||
"ASSIGNMENT_CONFLICT"
|
||||
);
|
||||
await companyData(
|
||||
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
departmentId: secondDepartment.id,
|
||||
isPrimary: false,
|
||||
})
|
||||
);
|
||||
const editedAssignment = {
|
||||
position: "Supervisor",
|
||||
startDate: "2026-01-01",
|
||||
endDate: null,
|
||||
isPrimary: true,
|
||||
};
|
||||
const editedMembership = await companyData(
|
||||
await operator.request(
|
||||
`${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`,
|
||||
editedAssignment
|
||||
)
|
||||
);
|
||||
const history = editedMembership.assignments.find((item) => item.id === membership.id)?.history;
|
||||
assert.equal(history?.length, 2);
|
||||
assert.equal(history?.[0]?.position, "Responsable");
|
||||
const repeatedMembership = await companyData(
|
||||
await operator.request(
|
||||
`${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`,
|
||||
editedAssignment
|
||||
)
|
||||
);
|
||||
assert.equal(
|
||||
repeatedMembership.assignments.find((item) => item.id === membership.id)?.history.length,
|
||||
2
|
||||
);
|
||||
await companyError(
|
||||
await operator.request(
|
||||
`${peoplePath}/people/${contact.id}/assignments/${membership.id}/update`,
|
||||
editedAssignment
|
||||
),
|
||||
404,
|
||||
"ASSIGNMENT_NOT_FOUND"
|
||||
);
|
||||
await companyError(
|
||||
await operator.request(
|
||||
`${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`,
|
||||
{ ...editedAssignment, endDate: "2025-12-31", isPrimary: false }
|
||||
),
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
await companyData(
|
||||
await operator.request(`${peoplePath}/departments/${secondDepartment.id}/update`, {
|
||||
name: secondDepartment.name,
|
||||
active: false,
|
||||
})
|
||||
);
|
||||
await companyError(
|
||||
await operator.request(`${peoplePath}/people/${contact.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
departmentId: secondDepartment.id,
|
||||
isPrimary: false,
|
||||
}),
|
||||
409,
|
||||
"INACTIVE_ASSIGNMENT_TARGET"
|
||||
);
|
||||
await companyData(
|
||||
await operator.request(`${peoplePath}/people/${companyPerson.id}/update`, {
|
||||
...personBody,
|
||||
active: false,
|
||||
})
|
||||
);
|
||||
await companyError(
|
||||
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
startDate: "2025-01-01",
|
||||
endDate: "2025-02-01",
|
||||
isPrimary: false,
|
||||
}),
|
||||
409,
|
||||
"INACTIVE_ASSIGNMENT_TARGET"
|
||||
);
|
||||
const closed = await companyData(
|
||||
await operator.request(
|
||||
`${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`,
|
||||
{ ...editedAssignment, endDate: "2026-03-01", isPrimary: false }
|
||||
)
|
||||
);
|
||||
assert.equal(closed.assignments.find((item) => item.id === membership.id)?.history.length, 3);
|
||||
await companyData(
|
||||
await operator.request(`${peoplePath}/people/${contact.id}/update`, {
|
||||
name: contact.name,
|
||||
position: contact.position,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
active: true,
|
||||
isContact: true,
|
||||
})
|
||||
);
|
||||
// Concurrencia: el bloqueo de compañía evita dos asignaciones abiertas al mismo departamento.
|
||||
const concurrent = await Promise.all([
|
||||
operator.request(`${peoplePath}/people/${contact.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
isPrimary: false,
|
||||
}),
|
||||
operator.request(`${peoplePath}/people/${contact.id}/assignments`, {
|
||||
...assignmentBody,
|
||||
isPrimary: false,
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
|
||||
// La FK compuesta impide pertenencias cruzadas aunque se intente escribir SQL directamente.
|
||||
await assert.rejects(
|
||||
database.query(
|
||||
"INSERT INTO person_department_assignments (id, companyId, personId, departmentId, position, startDate, endDate, isPrimary) VALUES (:id, :companyId, :personId, :departmentId, NULL, '2026-01-01', NULL, 0)",
|
||||
{
|
||||
replacements: {
|
||||
id: "a2349bc1-8c29-4d8b-b53a-5ec40e37a002",
|
||||
companyId: customer.id,
|
||||
personId: companyPerson.id,
|
||||
departmentId: foreignDepartment.id,
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
const userCount = await database.query<{ total: number }>(
|
||||
"SELECT COUNT(*) AS total FROM users",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
assert.equal(Number(userCount[0]?.total), 2);
|
||||
|
||||
assert.equal((await operator.request("/auth/logout", {})).status, 200);
|
||||
assert.equal((await operator.session()).user, null);
|
||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## Regla de identidad
|
||||
|
||||
Toda entidad de negocio de Metis Desk, tanto raíz de agregado como entidad interna, tiene un identificador global único UniqueID. Se genera como UUID v7, igual que en factuges-erp. Clientes, contactos, sedes/ubicaciones, documentos, tickets, intervenciones, assets, contratos y las demás entidades seguirán esta regla.
|
||||
Toda entidad de negocio de Metis Desk, tanto raíz de agregado como entidad interna, tiene un identificador global único UniqueID. Se genera como UUID v7, igual que en factuges-erp. Clientes, personas, departamentos, asignaciones, revisiones, sedes/ubicaciones, documentos, tickets, intervenciones, assets, contratos y las demás entidades seguirán esta regla.
|
||||
|
||||
La identidad no depende de nombres, correos, referencias visibles ni claves numéricas incrementales. No cambia al actualizar, activar o archivar. Las relaciones entre entidades usan el UniqueID de la entidad referenciada.
|
||||
|
||||
@ -41,7 +41,7 @@ Infrastructure reconstruye los Value Objects mediante create antes de llamar a r
|
||||
|
||||
User es la raíz del agregado de identidad. Sus Value Objects son UniqueID, Name, EmailAddress y PasswordHash. Conserva las invariantes del administrador inicial, la activación y la invalidación de sesiones.
|
||||
|
||||
Company es la raíz del agregado de compañías clientes. Contact y Site son entidades internas con UniqueID propio y referencia UniqueID a su compañía. Name, EmailAddress, PhoneNumber y SiteAddress representan sus valores; la dirección opcional usa Maybe. El repositorio carga y guarda el agregado completo dentro de una transacción con bloqueo de la compañía.
|
||||
Company es la raíz del agregado de compañías clientes. Person, Department, DepartmentAssignment y Site son entidades internas con UniqueID propio y referencia UniqueID a su compañía. AssignmentRevision conserva cada versión de una asignación y tiene su propio UniqueID. Los contactos son personas con isContact=true. Name, EmailAddress, PhoneNumber, CalendarDate y SiteAddress representan sus valores; los campos opcionales usan Maybe. Las reglas de pertenencia, periodos y departamento principal se resuelven dentro de Company. El repositorio carga y guarda el agregado completo dentro de una transacción con bloqueo de la compañía.
|
||||
|
||||
Los módulos de documentos, tickets y assets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.
|
||||
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
## Uso
|
||||
|
||||
Tras iniciar sesión, abrir Compañías. El listado permite buscar por nombre, crear compañías y abrir su ficha. El botón Nueva compañía está en la cabecera, a la derecha, y abre un modal. Dentro de cada ficha se puede editar la compañía y gestionar sus contactos y sedes mediante modales de alta y edición. Los listados no muestran formularios integrados.
|
||||
Tras iniciar sesión, abrir Compañías. El listado permite buscar por nombre, crear compañías y abrir su ficha. El botón Nueva compañía está en la cabecera, a la derecha, y abre un modal. Dentro de cada ficha se puede editar la compañía y gestionar personas, departamentos y sedes. Las personas se crean y editan en una página propia; los departamentos, las asignaciones y las sedes utilizan modales. Los listados no muestran formularios integrados.
|
||||
|
||||
- Compañía: nombre obligatorio (100 caracteres como máximo) y estado activo/inactivo.
|
||||
- Contacto: nombre, puesto, email y teléfono obligatorios; estado activo/inactivo. Nombre y puesto admiten 100 caracteres. El email se normaliza a minúsculas; el teléfono admite prefijo internacional, espacios, paréntesis, guiones y extensión.
|
||||
- Persona: nombre obligatorio; puesto, email y teléfono opcionales; estado activo/inactivo y clasificación como contacto. Nombre y puesto admiten 100 caracteres. El email se normaliza a minúsculas; el teléfono admite prefijo internacional, espacios, paréntesis, guiones y extensión.
|
||||
- Sede: nombre obligatorio y dirección opcional, multilínea, de hasta 10.000 caracteres. Una dirección omitida, vacía o con solo espacios se guarda como ausencia.
|
||||
|
||||
Las compañías representan empresas clientes atendidas, no la organización que presta soporte ni usuarios con acceso. Todos los usuarios autenticados activos pueden gestionarlas. El administrador inicial conserva su autorización exclusiva sobre la gestión de usuarios.
|
||||
@ -14,14 +14,16 @@ Desactivar una compañía no desactiva sus contactos ni oculta sus sedes. Los es
|
||||
|
||||
## Dominio y persistencia
|
||||
|
||||
El módulo companies sigue Domain, Application e Infrastructure. Company es raíz de agregado; Contact y Site son entidades internas, cada una con su UniqueID. Los identificadores nuevos son UUID v7; rehydrate y update conservan los persistidos.
|
||||
El módulo companies sigue Domain, Application e Infrastructure. Company es raíz de agregado; Person, Department, DepartmentAssignment, AssignmentRevision y Site son entidades internas, cada una con su UniqueID. Los identificadores nuevos son UUID v7; rehydrate y update conservan los persistidos.
|
||||
|
||||
Name, EmailAddress, PhoneNumber y SiteAddress encapsulan sus invariantes. Maybe representa la dirección ausente. Result expresa errores esperados. Los mappers convierten DTOs a valores de dominio y reconstruyen el agregado desde la persistencia; un registro inconsistente falla sin exponer sus datos.
|
||||
Name, EmailAddress, PhoneNumber y SiteAddress encapsulan sus invariantes. CalendarDate representa las fechas de los periodos. Maybe representa los campos opcionales. Result expresa errores esperados. Los mappers convierten DTOs a valores de dominio y reconstruyen el agregado desde la persistencia; un registro inconsistente falla sin exponer sus datos.
|
||||
|
||||
MariaDB almacena companies, company_contacts y company_sites. Las claves foráneas relacionan cada contacto y sede con su compañía, con restricción de borrado. Las migraciones explícitas se aplican al arrancar y no borran datos existentes.
|
||||
MariaDB conserva companies, company_contacts y company_sites y añade las tablas de departamentos, asignaciones e historial descritas en [Personas y departamentos](personas-y-departamentos.md). Los contactos anteriores conservan sus identificadores y se representan como personas marcadas como contacto. Las claves foráneas relacionan cada contacto y sede con su compañía, con restricción de borrado. Las migraciones explícitas se aplican al arrancar y no borran datos existentes.
|
||||
|
||||
Las modificaciones bloquean la compañía dentro de una transacción antes de cargar y guardar el agregado. No se permite trasladar una entidad a otra compañía mediante una edición ni editar un contacto o sede usando el identificador de otra compañía.
|
||||
|
||||
Consultar [Personas y departamentos](personas-y-departamentos.md) para las reglas y nuevas rutas. Las rutas de contactos se mantienen por compatibilidad y gestionan personas con la marca de contacto.
|
||||
|
||||
## API
|
||||
|
||||
Todas las rutas requieren sesión válida. Las escrituras requieren el origen configurado y el token CSRF de la sesión.
|
||||
@ -29,7 +31,7 @@ Todas las rutas requieren sesión válida. Las escrituras requieren el origen co
|
||||
| Método | Ruta | Operación |
|
||||
| --- | --- | --- |
|
||||
| GET | /api/companies | Listado |
|
||||
| GET | /api/companies/:companyId | Ficha con contactos y sedes |
|
||||
| GET | /api/companies/:companyId | Ficha con personas, contactos, departamentos, asignaciones y sedes |
|
||||
| POST | /api/companies | Crear compañía |
|
||||
| POST | /api/companies/:companyId/update | Editar compañía |
|
||||
| POST | /api/companies/:companyId/contacts | Crear contacto |
|
||||
|
||||
@ -10,14 +10,14 @@ El código se genera desde el registro oficial y queda versionado en este reposi
|
||||
- Card: bloques de información y estado.
|
||||
- Select: cambio entre castellano e inglés, con navegación por teclado.
|
||||
- Input y Label: credenciales y datos de usuario.
|
||||
- Checkbox: estado activo de usuarios, compañías y contactos.
|
||||
- Table: listados de usuarios, compañías y contactos.
|
||||
- Checkbox: estado activo de usuarios, compañías, personas y departamentos.
|
||||
- Table: listados de usuarios, compañías, personas y departamentos.
|
||||
- Textarea: dirección opcional de las sedes, con varias líneas.
|
||||
- Dialog: formularios sencillos de alta y edición, con foco y cierre por teclado. FormDialog comparte el título y la cancelación traducidos.
|
||||
|
||||
## 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, contactos, sedes y usuarios siguen este criterio.
|
||||
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.
|
||||
|
||||
TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos.
|
||||
|
||||
|
||||
59
docs/personas-y-departamentos.md
Normal file
59
docs/personas-y-departamentos.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Personas y departamentos
|
||||
|
||||
## Uso
|
||||
|
||||
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.
|
||||
- El listado permite buscar por nombre y mostrar solo contactos. Abrir ficha muestra los datos de la persona y sus asignaciones.
|
||||
- Nuevo departamento abre un modal con nombre y estado activo. Los nombres son únicos dentro de cada compañía, ignorando mayúsculas y espacios exteriores. No hay departamentos anidados.
|
||||
- Desde la ficha de una persona, Asignar departamento abre un modal con departamento, puesto específico opcional, fecha de inicio, fecha de fin opcional y marca de departamento principal.
|
||||
- Editar una asignación permite corregir su puesto, periodo o carácter principal. Historial muestra las versiones anteriores y cuándo se registraron.
|
||||
|
||||
Las personas de las compañías no tienen acceso a Metis Desk. No se crean usuarios ni contraseñas al dar de alta una persona. Un contacto es una persona marcada como contacto; no una segunda entidad ni una cuenta de acceso.
|
||||
|
||||
## Reglas
|
||||
|
||||
Una persona pertenece a una compañía y puede tener asignaciones a varios departamentos de esa misma compañía. Su puesto general es independiente del puesto en cada departamento. Los emails no identifican personas ni necesitan ser únicos.
|
||||
|
||||
Cada periodo tiene fecha de inicio y una fecha de fin opcional. Sin fecha de fin la asignación está abierta. Con fecha de fin está cerrada, aunque la fecha sea futura: en esta entrega las fechas documentan periodos y no hay un planificador que active o cierre asignaciones automáticamente.
|
||||
|
||||
Solo puede existir una asignación abierta por persona y departamento, y un único departamento principal entre las asignaciones abiertas de esa persona. Los periodos del mismo departamento no pueden solaparse. La fecha de fin marca el límite del periodo: una nueva asignación puede comenzar el mismo día que termina la anterior. No se permite una fecha de fin anterior al inicio. Una asignación cerrada no puede ser principal.
|
||||
|
||||
Para cambiar de puesto conservando periodos independientes, cerrar el periodo anterior y crear el siguiente. Corregir una asignación también conserva las versiones anteriores. Repetir una edición idéntica no añade una revisión.
|
||||
|
||||
Desactivar una persona o un departamento conserva sus asignaciones e historial. No permite nuevas asignaciones ni reabrir las cerradas; sí permite corregir o cerrar las existentes. La ficha de una persona inactiva avisa si quedan asignaciones abiertas. Reactivar no crea nuevas asignaciones automáticamente. No hay borrado físico.
|
||||
|
||||
## Identidad y compatibilidad
|
||||
|
||||
Company sigue siendo la raíz del agregado. Person, Department, DepartmentAssignment, AssignmentRevision y Site son entidades con UniqueID propio. Los nuevos identificadores son UUID v7; los anteriores se conservan.
|
||||
|
||||
Name, EmailAddress, PhoneNumber y CalendarDate encapsulan los valores. Maybe expresa la ausencia de datos opcionales. Result representa errores esperados. El dominio no depende de DTOs HTTP ni de Sequelize.
|
||||
|
||||
Los contactos existentes pasan a ser personas con isContact=true, conservando UUID, compañía y datos. La tabla company_contacts conserva su nombre para mantener los registros y relaciones. Las rutas antiguas de contactos siguen disponibles; la propiedad contacts de la respuesta contiene las personas marcadas como contacto.
|
||||
|
||||
Las tablas company_departments, person_department_assignments y department_assignment_revisions almacenan departamentos, periodos e historial. Las claves foráneas compuestas garantizan que persona y departamento pertenecen a la misma compañía. Cada escritura bloquea el agregado de compañía dentro de una transacción. Las revisiones se insertan y no se sobrescriben. Al cargar se comprueban valores, periodos, secuencia del historial y correspondencia de la última revisión con el estado actual.
|
||||
|
||||
Las migraciones son explícitas, conservan los datos y pueden ejecutarse de nuevo sin duplicar tablas o restricciones.
|
||||
|
||||
## API
|
||||
|
||||
Todas las rutas requieren sesión válida. Las escrituras requieren origen y token CSRF válidos. Las mutaciones devuelven la ficha completa de compañía.
|
||||
|
||||
| Método | Ruta | Operación |
|
||||
| --- | --- | --- |
|
||||
| POST | /api/companies/:companyId/people | Crear persona |
|
||||
| POST | /api/companies/:companyId/people/:id/update | Editar persona |
|
||||
| POST | /api/companies/:companyId/departments | Crear departamento |
|
||||
| POST | /api/companies/:companyId/departments/:id/update | Editar departamento |
|
||||
| POST | /api/companies/:companyId/people/:personId/assignments | Crear asignación |
|
||||
| POST | /api/companies/:companyId/people/:personId/assignments/:id/update | Editar asignación |
|
||||
| GET | /api/companies/:companyId | Consultar personas, departamentos, asignaciones, historial y sedes |
|
||||
|
||||
Las pertenencias cruzadas se rechazan. El departamento de una asignación existente no se traslada mediante edición; se cierra y se crea otra.
|
||||
|
||||
## Alcance de esta entrega
|
||||
|
||||
Implementado: personas, clasificación como contacto, departamentos, puestos específicos, departamento principal, periodos e historial de asignaciones.
|
||||
|
||||
Pendiente: cuentas informáticas, grupos, recursos, autorizaciones de acceso, teletrabajo, dispositivos personales, procesos y plantillas, inventario de activos. Los datos de estas funciones no se crean todavía.
|
||||
@ -27,22 +27,37 @@ Distinguir:
|
||||
La adopción de una sola organización o de varias se decidirá antes del modelo persistente.
|
||||
No crear un sistema multiempresa complejo sin necesidad, companyId identifica a la compañía cliente; no se utiliza como identificador de tenant.
|
||||
|
||||
## Entrega 2: inventario
|
||||
## Personas y departamentos: implementado
|
||||
|
||||
Personas sin acceso a Metis Desk, contactos como clasificación, puesto general y pertenencia a varios departamentos con puestos específicos, un principal, periodos e historial. Datos existentes e identificadores preservados. Consultar [Personas y departamentos](personas-y-departamentos.md).
|
||||
|
||||
## Siguientes entregas: orden propuesto
|
||||
|
||||
1. Cuentas informáticas, grupos sin grupos anidados y recursos de la compañía. Separar personas, cuentas y usuarios autenticados.
|
||||
2. Perfil de configuración de cada persona: correos, carpetas compartidas, impresoras y demás recursos autorizados.
|
||||
3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. La VPN no concede permisos sobre todos los recursos. Asistencia técnica remota independiente.
|
||||
4. Configuraciones por dispositivo, incluidos móviles corporativos y personales con correo POP/IMAP. Dispositivos personales con identificación mínima, sin inventario técnico detallado.
|
||||
5. Procesos de alta, cambio y salida, con seguimiento de retiradas pendientes y acceso que permanece en dispositivos personales. Plantillas reutilizables entre compañías; los datos y ejecuciones pertenecen a cada compañía.
|
||||
6. Modelos de activos, inventario, relaciones entre activos y conjuntos de puesto de trabajo.
|
||||
|
||||
Este orden se concreta antes de cada entrega. Las plantillas con acciones automáticas requieren los recursos y relaciones sobre los que actuar.
|
||||
|
||||
## Inventario: pendiente
|
||||
|
||||
Infraestructura por sede: routers, firewalls, switches, puntos de acceso, NAS y servidores.
|
||||
Puestos de trabajo y asignaciones: equipos, monitores, impresoras y usuarios.
|
||||
Definir identificación, estado e historial de movimientos.
|
||||
|
||||
## Entrega 3: tickets e intervenciones
|
||||
## Tickets e intervenciones: pendiente
|
||||
|
||||
Cliente, sede, activo afectado, técnico, fecha, tiempo empleado, diagnóstico, trabajo realizado y resolución.
|
||||
Un ticket puede tener varias intervenciones. No confundir la solicitud de soporte con cada trabajo realizado.
|
||||
|
||||
## Entrega 4: servicios y documentación
|
||||
## Servicios y documentación: pendiente
|
||||
|
||||
Software, licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación.
|
||||
Las credenciales se resolverán con una solución específica; no almacenarlas como texto libre.
|
||||
|
||||
## Decisiones pendientes
|
||||
|
||||
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, contactos y sedes está implementada. Inventario y tickets siguen pendientes.
|
||||
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. Inventario y tickets siguen pendientes.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/web/components/companies-page.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -1,11 +1,31 @@
|
||||
import { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CompanyRequestDTO, ContactRequestDTO, SiteRequestDTO } from "../../common";
|
||||
import type {
|
||||
AssignmentUpdateRequestDTO,
|
||||
CompanyRequestDTO,
|
||||
ContactRequestDTO,
|
||||
DepartmentRequestDTO,
|
||||
PersonRequestDTO,
|
||||
SiteRequestDTO,
|
||||
} from "../../common";
|
||||
import { CompanyError } from "../domain/company-error";
|
||||
import { CalendarDate } from "../domain/value-objects/calendar-date";
|
||||
import { PhoneNumber } from "../domain/value-objects/phone-number";
|
||||
import { SiteAddress } from "../domain/value-objects/site-address";
|
||||
|
||||
function optional<T>(
|
||||
value: string | null,
|
||||
create: (value: string) => Result<T, Error>
|
||||
): Result<Maybe<T>, CompanyError> {
|
||||
if (value === null || !value.trim()) {
|
||||
return Result.ok(Maybe.none<T>());
|
||||
}
|
||||
const parsed = create(value);
|
||||
return parsed.isSuccess
|
||||
? Result.ok(Maybe.some(parsed.data))
|
||||
: Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
export class CompanyInputMapper {
|
||||
id(value: string): Result<UniqueID, CompanyError> {
|
||||
const result = UniqueID.create(value);
|
||||
@ -19,11 +39,17 @@ export class CompanyInputMapper {
|
||||
? Result.ok({ name: name.data, active: dto.active })
|
||||
: Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
department(dto: DepartmentRequestDTO) {
|
||||
return this.company(dto);
|
||||
}
|
||||
contact(dto: ContactRequestDTO) {
|
||||
return this.person({ ...dto, isContact: true });
|
||||
}
|
||||
person(dto: PersonRequestDTO) {
|
||||
const name = Name.create(dto.name, 100);
|
||||
const position = Name.create(dto.position, 100);
|
||||
const email = EmailAddress.create(dto.email);
|
||||
const phone = PhoneNumber.create(dto.phone);
|
||||
const position = optional(dto.position, (value) => Name.create(value, 100));
|
||||
const email = optional(dto.email, (value) => EmailAddress.create(value));
|
||||
const phone = optional(dto.phone, (value) => PhoneNumber.create(value));
|
||||
if (!(name.isSuccess && position.isSuccess && email.isSuccess && phone.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
@ -33,6 +59,21 @@ export class CompanyInputMapper {
|
||||
email: email.data,
|
||||
phone: phone.data,
|
||||
active: dto.active,
|
||||
isContact: dto.isContact,
|
||||
});
|
||||
}
|
||||
assignment(dto: AssignmentUpdateRequestDTO) {
|
||||
const position = optional(dto.position, (value) => Name.create(value, 100));
|
||||
const startDate = CalendarDate.create(dto.startDate);
|
||||
const endDate = optional(dto.endDate, (value) => CalendarDate.create(value));
|
||||
if (!(position.isSuccess && startDate.isSuccess && endDate.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok({
|
||||
position: position.data,
|
||||
startDate: startDate.data,
|
||||
endDate: endDate.data,
|
||||
isPrimary: dto.isPrimary,
|
||||
});
|
||||
}
|
||||
site(dto: SiteRequestDTO) {
|
||||
|
||||
@ -1,20 +1,49 @@
|
||||
import type { CompanyDTO, CompanySummaryDTO } from "../../common";
|
||||
import type { Company } from "../domain/company.aggregate";
|
||||
|
||||
import type { AssignmentValues } from "../domain/department-assignment.entity";
|
||||
export function buildCompanySummary(company: Company): CompanySummaryDTO {
|
||||
return { id: company.id.toPrimitive(), name: company.name.toPrimitive(), active: company.active };
|
||||
}
|
||||
export function buildAssignmentValues(values: AssignmentValues) {
|
||||
return {
|
||||
position: values.position.getOrUndefined()?.toPrimitive() ?? null,
|
||||
startDate: values.startDate.toPrimitive(),
|
||||
endDate: values.endDate.getOrUndefined()?.toPrimitive() ?? null,
|
||||
isPrimary: values.isPrimary,
|
||||
};
|
||||
}
|
||||
export function buildCompanyResponse(company: Company): CompanyDTO {
|
||||
const people = company.people.map((person) => ({
|
||||
id: person.id.toPrimitive(),
|
||||
companyId: person.companyId.toPrimitive(),
|
||||
name: person.name.toPrimitive(),
|
||||
position: person.position.getOrUndefined()?.toPrimitive() ?? null,
|
||||
email: person.email.getOrUndefined()?.toPrimitive() ?? null,
|
||||
phone: person.phone.getOrUndefined()?.toPrimitive() ?? null,
|
||||
active: person.active,
|
||||
isContact: person.isContact,
|
||||
}));
|
||||
return {
|
||||
...buildCompanySummary(company),
|
||||
contacts: company.contacts.map((contact) => ({
|
||||
id: contact.id.toPrimitive(),
|
||||
companyId: contact.companyId.toPrimitive(),
|
||||
name: contact.name.toPrimitive(),
|
||||
position: contact.position.toPrimitive(),
|
||||
email: contact.email.toPrimitive(),
|
||||
phone: contact.phone.toPrimitive(),
|
||||
active: contact.active,
|
||||
people,
|
||||
contacts: people.filter((person) => person.isContact),
|
||||
departments: company.departments.map((item) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
name: item.name.toPrimitive(),
|
||||
active: item.active,
|
||||
})),
|
||||
assignments: company.assignments.map((item) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
personId: item.personId.toPrimitive(),
|
||||
departmentId: item.departmentId.toPrimitive(),
|
||||
...buildAssignmentValues(item.values),
|
||||
history: item.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
recordedAt: revision.values.recordedAt,
|
||||
...buildAssignmentValues(revision.values),
|
||||
})),
|
||||
})),
|
||||
sites: company.sites.map((site) => ({
|
||||
id: site.id.toPrimitive(),
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CompanyRequestDTO, ContactRequestDTO, SiteRequestDTO } from "../../common";
|
||||
import type {
|
||||
AssignmentRequestDTO,
|
||||
AssignmentUpdateRequestDTO,
|
||||
CompanyRequestDTO,
|
||||
ContactRequestDTO,
|
||||
DepartmentRequestDTO,
|
||||
PersonRequestDTO,
|
||||
SiteRequestDTO,
|
||||
} from "../../common";
|
||||
import { Company } from "../domain/company.aggregate";
|
||||
import { CompanyError } from "../domain/company-error";
|
||||
import { Contact } from "../domain/contact.entity";
|
||||
import { Department } from "../domain/department.entity";
|
||||
import { Person } from "../domain/person.entity";
|
||||
import { Site } from "../domain/site.entity";
|
||||
|
||||
import type { ICompanyRepository } from "./company.repository";
|
||||
@ -48,8 +57,8 @@ export class CompanyUseCases {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parsed.data, (company) => {
|
||||
const contact = Contact.create({ ...props.data, companyId: company.id });
|
||||
return contact.isSuccess ? company.addContact(contact.data) : contact;
|
||||
const contact = Person.create({ ...props.data, companyId: company.id });
|
||||
return contact.isSuccess ? company.addPerson(contact.data) : contact;
|
||||
});
|
||||
}
|
||||
async updateContact(companyId: string, id: string, dto: ContactRequestDTO) {
|
||||
@ -59,8 +68,84 @@ export class CompanyUseCases {
|
||||
if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
const result = await this.companies.update(parent.data, (company) =>
|
||||
company.updatePerson(child.data, props.data)
|
||||
);
|
||||
return !result.isSuccess && result.error.code === "PERSON_NOT_FOUND"
|
||||
? Result.fail(new CompanyError("CONTACT_NOT_FOUND"))
|
||||
: result;
|
||||
}
|
||||
async addPerson(id: string, dto: PersonRequestDTO) {
|
||||
const parsed = this.input.id(id);
|
||||
const props = this.input.person(dto);
|
||||
if (!(parsed.isSuccess && props.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parsed.data, (company) => {
|
||||
const created = Person.create({ ...props.data, companyId: company.id });
|
||||
return created.isSuccess ? company.addPerson(created.data) : created;
|
||||
});
|
||||
}
|
||||
async updatePerson(companyId: string, id: string, dto: PersonRequestDTO) {
|
||||
const parent = this.input.id(companyId);
|
||||
const child = this.input.id(id);
|
||||
const props = this.input.person(dto);
|
||||
if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parent.data, (company) =>
|
||||
company.updateContact(child.data, props.data)
|
||||
company.updatePerson(child.data, props.data)
|
||||
);
|
||||
}
|
||||
async addDepartment(id: string, dto: DepartmentRequestDTO) {
|
||||
const parsed = this.input.id(id);
|
||||
const props = this.input.department(dto);
|
||||
if (!(parsed.isSuccess && props.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parsed.data, (company) => {
|
||||
const created = Department.create({ ...props.data, companyId: company.id });
|
||||
return created.isSuccess ? company.addDepartment(created.data) : created;
|
||||
});
|
||||
}
|
||||
async updateDepartment(companyId: string, id: string, dto: DepartmentRequestDTO) {
|
||||
const parent = this.input.id(companyId);
|
||||
const child = this.input.id(id);
|
||||
const props = this.input.department(dto);
|
||||
if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parent.data, (company) =>
|
||||
company.updateDepartment(child.data, props.data)
|
||||
);
|
||||
}
|
||||
async addAssignment(companyId: string, personId: string, dto: AssignmentRequestDTO) {
|
||||
const parent = this.input.id(companyId);
|
||||
const person = this.input.id(personId);
|
||||
const department = this.input.id(dto.departmentId);
|
||||
const values = this.input.assignment(dto);
|
||||
if (!(parent.isSuccess && person.isSuccess && department.isSuccess && values.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parent.data, (company) =>
|
||||
company.addAssignment(person.data, department.data, values.data, new Date().toISOString())
|
||||
);
|
||||
}
|
||||
async updateAssignment(
|
||||
companyId: string,
|
||||
personId: string,
|
||||
id: string,
|
||||
dto: AssignmentUpdateRequestDTO
|
||||
) {
|
||||
const parent = this.input.id(companyId);
|
||||
const person = this.input.id(personId);
|
||||
const child = this.input.id(id);
|
||||
const values = this.input.assignment(dto);
|
||||
if (!(parent.isSuccess && person.isSuccess && child.isSuccess && values.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.update(parent.data, (company) =>
|
||||
company.updateAssignment(person.data, child.data, values.data, new Date().toISOString())
|
||||
);
|
||||
}
|
||||
async addSite(id: string, dto: SiteRequestDTO) {
|
||||
|
||||
@ -2,7 +2,13 @@ export type CompanyErrorCode =
|
||||
| "INVALID_REQUEST"
|
||||
| "COMPANY_NOT_FOUND"
|
||||
| "CONTACT_NOT_FOUND"
|
||||
| "SITE_NOT_FOUND";
|
||||
| "SITE_NOT_FOUND"
|
||||
| "PERSON_NOT_FOUND"
|
||||
| "DEPARTMENT_NOT_FOUND"
|
||||
| "DEPARTMENT_NAME_EXISTS"
|
||||
| "ASSIGNMENT_NOT_FOUND"
|
||||
| "ASSIGNMENT_CONFLICT"
|
||||
| "INACTIVE_ASSIGNMENT_TARGET";
|
||||
export class CompanyError extends Error {
|
||||
constructor(public readonly code: CompanyErrorCode) {
|
||||
super(code);
|
||||
|
||||
@ -6,7 +6,7 @@ import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { CompanyInputMapper } from "../application/company-input.mapper";
|
||||
|
||||
import { Company } from "./company.aggregate";
|
||||
import { Contact } from "./contact.entity";
|
||||
import { Person } from "./person.entity";
|
||||
import { Site } from "./site.entity";
|
||||
|
||||
const input = new CompanyInputMapper();
|
||||
@ -27,19 +27,19 @@ test("compañía, contacto y sede tienen UUID v7 propios y se conserva la identi
|
||||
active: true,
|
||||
});
|
||||
assert.ok(props.isSuccess);
|
||||
const contact = Contact.create({ ...props.data, companyId: company.id });
|
||||
const contact = Person.create({ ...props.data, companyId: company.id });
|
||||
assert.ok(contact.isSuccess);
|
||||
const added = company.addContact(contact.data);
|
||||
const added = company.addPerson(contact.data);
|
||||
assert.ok(added.isSuccess);
|
||||
assert.notEqual(company.id.toPrimitive(), contact.data.id.toPrimitive());
|
||||
assert.equal(contact.data.id.toPrimitive().charAt(14), "7");
|
||||
const changed = added.data.updateContact(contact.data.id, { ...props.data, active: false });
|
||||
const changed = added.data.updatePerson(contact.data.id, { ...props.data, active: false });
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.ok(changed.data.id.equals(company.id));
|
||||
assert.ok(changed.data.contacts[0]?.id.equals(contact.data.id));
|
||||
assert.equal(changed.data.contacts[0]?.active, false);
|
||||
assert.equal(added.data.contacts[0]?.active, true);
|
||||
const repeated = changed.data.updateContact(contact.data.id, { ...props.data, active: false });
|
||||
const repeated = changed.data.updatePerson(contact.data.id, { ...props.data, active: false });
|
||||
assert.ok(repeated.isSuccess);
|
||||
assert.equal(repeated.data, changed.data);
|
||||
});
|
||||
@ -53,7 +53,7 @@ test("la pertenencia impide asociar o editar entidades de otra compañía", () =
|
||||
assert.equal(company.addSite(site.data).isSuccess, false);
|
||||
const missing = company.updateSite(site.data.id, props.data);
|
||||
assert.equal(missing.isSuccess, false);
|
||||
const contact = company.updateContact(UniqueID.generateNewID(), {
|
||||
const contact = company.updatePerson(UniqueID.generateNewID(), {
|
||||
...inputContact(),
|
||||
active: false,
|
||||
});
|
||||
|
||||
@ -3,13 +3,18 @@ import { AggregateRoot } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyError } from "./company-error";
|
||||
import type { Contact, ContactProps } from "./contact.entity";
|
||||
import type { Department, DepartmentProps } from "./department.entity";
|
||||
import type { AssignmentValues } from "./department-assignment.entity";
|
||||
import { DepartmentAssignment } from "./department-assignment.entity";
|
||||
import type { Person, PersonProps } from "./person.entity";
|
||||
import type { Site, SiteProps } from "./site.entity";
|
||||
|
||||
export type CompanyProps = Readonly<{
|
||||
name: Name;
|
||||
active: boolean;
|
||||
contacts: readonly Contact[];
|
||||
people: readonly Person[];
|
||||
departments: readonly Department[];
|
||||
assignments: readonly DepartmentAssignment[];
|
||||
sites: readonly Site[];
|
||||
}>;
|
||||
export class Company extends AggregateRoot<CompanyProps> {
|
||||
@ -17,7 +22,9 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
super(
|
||||
{
|
||||
...props,
|
||||
contacts: Object.freeze([...props.contacts]),
|
||||
people: Object.freeze([...props.people]),
|
||||
departments: Object.freeze([...props.departments]),
|
||||
assignments: Object.freeze([...props.assignments]),
|
||||
sites: Object.freeze([...props.sites]),
|
||||
},
|
||||
id
|
||||
@ -27,7 +34,9 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
if (props.name.toPrimitive().length > 100) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(new Company({ ...props, contacts: [], sites: [] }));
|
||||
return Result.ok(
|
||||
new Company({ ...props, people: [], departments: [], assignments: [], sites: [] })
|
||||
);
|
||||
}
|
||||
static rehydrate(props: CompanyProps, id: UniqueID): Company {
|
||||
return new Company(props, id);
|
||||
@ -38,8 +47,17 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
get active() {
|
||||
return this.props.active;
|
||||
}
|
||||
get people() {
|
||||
return this.props.people;
|
||||
}
|
||||
get contacts() {
|
||||
return this.props.contacts;
|
||||
return this.people.filter((person) => person.isContact);
|
||||
}
|
||||
get departments() {
|
||||
return this.props.departments;
|
||||
}
|
||||
get assignments() {
|
||||
return this.props.assignments;
|
||||
}
|
||||
get sites() {
|
||||
return this.props.sites;
|
||||
@ -52,24 +70,22 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
? Result.ok(this)
|
||||
: Result.ok(new Company({ ...this.props, ...values }, this.id));
|
||||
}
|
||||
addContact(contact: Contact): Result<Company, CompanyError> {
|
||||
addPerson(contact: Person): Result<Company, CompanyError> {
|
||||
if (!contact.companyId.equals(this.id)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
if (this.contacts.some((existing) => existing.id.equals(contact.id))) {
|
||||
if (this.people.some((existing) => existing.id.equals(contact.id))) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(
|
||||
new Company({ ...this.props, contacts: [...this.contacts, contact] }, this.id)
|
||||
);
|
||||
return Result.ok(new Company({ ...this.props, people: [...this.people, contact] }, this.id));
|
||||
}
|
||||
updateContact(
|
||||
updatePerson(
|
||||
id: UniqueID,
|
||||
values: Omit<ContactProps, "companyId">
|
||||
values: Omit<PersonProps, "companyId">
|
||||
): Result<Company, CompanyError> {
|
||||
const current = this.contacts.find((contact) => contact.id.equals(id));
|
||||
const current = this.people.find((contact) => contact.id.equals(id));
|
||||
if (!current) {
|
||||
return Result.fail(new CompanyError("CONTACT_NOT_FOUND"));
|
||||
return Result.fail(new CompanyError("PERSON_NOT_FOUND"));
|
||||
}
|
||||
const updated = current.update(values);
|
||||
if (!updated.isSuccess) {
|
||||
@ -81,7 +97,7 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
new Company(
|
||||
{
|
||||
...this.props,
|
||||
contacts: this.contacts.map((contact) =>
|
||||
people: this.people.map((contact) =>
|
||||
contact.id.equals(id) ? updated.data : contact
|
||||
),
|
||||
},
|
||||
@ -89,6 +105,153 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
)
|
||||
);
|
||||
}
|
||||
private departmentNameExists(name: string, except?: UniqueID): boolean {
|
||||
const key = name.normalize("NFC").toLowerCase();
|
||||
return this.departments.some(
|
||||
(department) =>
|
||||
!department.id.equals(except) &&
|
||||
department.name.toPrimitive().normalize("NFC").toLowerCase() === key
|
||||
);
|
||||
}
|
||||
addDepartment(department: Department): Result<Company, CompanyError> {
|
||||
if (!department.companyId.equals(this.id)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
if (this.departments.some((current) => current.id.equals(department.id))) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (this.departmentNameExists(department.name.toPrimitive())) {
|
||||
return Result.fail(new CompanyError("DEPARTMENT_NAME_EXISTS"));
|
||||
}
|
||||
return Result.ok(
|
||||
new Company({ ...this.props, departments: [...this.departments, department] }, this.id)
|
||||
);
|
||||
}
|
||||
updateDepartment(
|
||||
id: UniqueID,
|
||||
values: Omit<DepartmentProps, "companyId">
|
||||
): Result<Company, CompanyError> {
|
||||
const current = this.departments.find((department) => department.id.equals(id));
|
||||
if (!current) {
|
||||
return Result.fail(new CompanyError("DEPARTMENT_NOT_FOUND"));
|
||||
}
|
||||
if (this.departmentNameExists(values.name.toPrimitive(), id)) {
|
||||
return Result.fail(new CompanyError("DEPARTMENT_NAME_EXISTS"));
|
||||
}
|
||||
const changed = current.update(values);
|
||||
if (!changed.isSuccess) {
|
||||
return changed;
|
||||
}
|
||||
return changed.data === current
|
||||
? Result.ok(this)
|
||||
: Result.ok(
|
||||
new Company(
|
||||
{
|
||||
...this.props,
|
||||
departments: this.departments.map((department) =>
|
||||
department.id.equals(id) ? changed.data : department
|
||||
),
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
private validateAssignment(
|
||||
candidate: DepartmentAssignment,
|
||||
previous?: DepartmentAssignment
|
||||
): Result<DepartmentAssignment, CompanyError> {
|
||||
const person = this.people.find((item) => item.id.equals(candidate.personId));
|
||||
const department = this.departments.find((item) => item.id.equals(candidate.departmentId));
|
||||
if (!person) {
|
||||
return Result.fail(new CompanyError("PERSON_NOT_FOUND"));
|
||||
}
|
||||
if (!department) {
|
||||
return Result.fail(new CompanyError("DEPARTMENT_NOT_FOUND"));
|
||||
}
|
||||
const end = candidate.values.endDate.getOrUndefined();
|
||||
// Una baja o corrección histórica sigue permitida tras desactivar la persona o el departamento.
|
||||
const createsOrReopens =
|
||||
!previous || (!end && previous.values.endDate.getOrUndefined() !== undefined);
|
||||
if (createsOrReopens && !(person.active && department.active)) {
|
||||
return Result.fail(new CompanyError("INACTIVE_ASSIGNMENT_TARGET"));
|
||||
}
|
||||
const conflict = this.assignments.some((other) => {
|
||||
if (other.id.equals(candidate.id) || !other.personId.equals(candidate.personId)) {
|
||||
return false;
|
||||
}
|
||||
const otherEnd = other.values.endDate.getOrUndefined();
|
||||
if (!(end || otherEnd) && candidate.values.isPrimary && other.values.isPrimary) {
|
||||
return true;
|
||||
}
|
||||
if (!other.departmentId.equals(candidate.departmentId)) {
|
||||
return false;
|
||||
}
|
||||
if (!(end || otherEnd)) {
|
||||
return true;
|
||||
}
|
||||
// Periodos semiabiertos: se puede cerrar un puesto e iniciar otro el mismo día.
|
||||
return (
|
||||
(!otherEnd || candidate.values.startDate.toPrimitive() < otherEnd.toPrimitive()) &&
|
||||
(!end || other.values.startDate.toPrimitive() < end.toPrimitive())
|
||||
);
|
||||
});
|
||||
return conflict ? Result.fail(new CompanyError("ASSIGNMENT_CONFLICT")) : Result.ok(candidate);
|
||||
}
|
||||
addAssignment(
|
||||
personId: UniqueID,
|
||||
departmentId: UniqueID,
|
||||
values: AssignmentValues,
|
||||
recordedAt: string
|
||||
): Result<Company, CompanyError> {
|
||||
const created = DepartmentAssignment.create(
|
||||
{ ...values, companyId: this.id, personId, departmentId },
|
||||
recordedAt
|
||||
);
|
||||
if (!created.isSuccess) {
|
||||
return created;
|
||||
}
|
||||
const validated = this.validateAssignment(created.data);
|
||||
if (!validated.isSuccess) {
|
||||
return validated;
|
||||
}
|
||||
return Result.ok(
|
||||
new Company({ ...this.props, assignments: [...this.assignments, created.data] }, this.id)
|
||||
);
|
||||
}
|
||||
updateAssignment(
|
||||
personId: UniqueID,
|
||||
id: UniqueID,
|
||||
values: AssignmentValues,
|
||||
recordedAt: string
|
||||
): Result<Company, CompanyError> {
|
||||
const current = this.assignments.find(
|
||||
(item) => item.id.equals(id) && item.personId.equals(personId)
|
||||
);
|
||||
if (!current) {
|
||||
return Result.fail(new CompanyError("ASSIGNMENT_NOT_FOUND"));
|
||||
}
|
||||
const changed = current.update(values, recordedAt);
|
||||
if (!changed.isSuccess) {
|
||||
return changed;
|
||||
}
|
||||
const validated = this.validateAssignment(changed.data, current);
|
||||
if (!validated.isSuccess) {
|
||||
return validated;
|
||||
}
|
||||
return changed.data === current
|
||||
? Result.ok(this)
|
||||
: Result.ok(
|
||||
new Company(
|
||||
{
|
||||
...this.props,
|
||||
assignments: this.assignments.map((item) =>
|
||||
item.id.equals(id) ? changed.data : item
|
||||
),
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
addSite(site: Site): Result<Company, CompanyError> {
|
||||
if (!site.companyId.equals(this.id)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
import type { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyError } from "./company-error";
|
||||
import type { PhoneNumber } from "./value-objects/phone-number";
|
||||
|
||||
export type ContactProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
name: Name;
|
||||
position: Name;
|
||||
email: EmailAddress;
|
||||
phone: PhoneNumber;
|
||||
active: boolean;
|
||||
}>;
|
||||
export class Contact extends DomainEntity<ContactProps> {
|
||||
static create(props: ContactProps): Result<Contact, CompanyError> {
|
||||
if (props.name.toPrimitive().length > 100 || props.position.toPrimitive().length > 100) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(new Contact(props));
|
||||
}
|
||||
static rehydrate(props: ContactProps, id: UniqueID): Contact {
|
||||
return new Contact(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
get position() {
|
||||
return this.props.position;
|
||||
}
|
||||
get email() {
|
||||
return this.props.email;
|
||||
}
|
||||
get phone() {
|
||||
return this.props.phone;
|
||||
}
|
||||
get active() {
|
||||
return this.props.active;
|
||||
}
|
||||
update(values: Omit<ContactProps, "companyId">): Result<Contact, CompanyError> {
|
||||
if (values.name.toPrimitive().length > 100 || values.position.toPrimitive().length > 100) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
if (
|
||||
this.name.equals(values.name) &&
|
||||
this.position.equals(values.position) &&
|
||||
this.email.equals(values.email) &&
|
||||
this.phone.equals(values.phone) &&
|
||||
this.active === values.active
|
||||
) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(new Contact({ ...values, companyId: this.companyId }, this.id));
|
||||
}
|
||||
}
|
||||
103
modules/companies/src/api/domain/department-assignment.entity.ts
Normal file
103
modules/companies/src/api/domain/department-assignment.entity.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyError } from "./company-error";
|
||||
import { sameOptional } from "./person.entity";
|
||||
import type { CalendarDate } from "./value-objects/calendar-date";
|
||||
|
||||
export type AssignmentValues = Readonly<{
|
||||
position: Maybe<Name>;
|
||||
startDate: CalendarDate;
|
||||
endDate: Maybe<CalendarDate>;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
export type AssignmentRevisionProps = AssignmentValues & Readonly<{ recordedAt: string }>;
|
||||
export class AssignmentRevision extends DomainEntity<AssignmentRevisionProps> {
|
||||
static create(props: AssignmentRevisionProps) {
|
||||
return new AssignmentRevision(props);
|
||||
}
|
||||
static rehydrate(props: AssignmentRevisionProps, id: UniqueID) {
|
||||
return new AssignmentRevision(props, id);
|
||||
}
|
||||
get values() {
|
||||
return this.props;
|
||||
}
|
||||
}
|
||||
export type AssignmentProps = AssignmentValues &
|
||||
Readonly<{
|
||||
companyId: UniqueID;
|
||||
personId: UniqueID;
|
||||
departmentId: UniqueID;
|
||||
history: readonly AssignmentRevision[];
|
||||
}>;
|
||||
export class DepartmentAssignment extends DomainEntity<AssignmentProps> {
|
||||
protected constructor(props: AssignmentProps, id?: UniqueID) {
|
||||
super({ ...props, history: Object.freeze([...props.history]) }, id);
|
||||
}
|
||||
static isValidValues(values: AssignmentValues) {
|
||||
const end = values.endDate.getOrUndefined();
|
||||
return (
|
||||
(!end || end.toPrimitive() >= values.startDate.toPrimitive()) &&
|
||||
!(end && values.isPrimary) &&
|
||||
(values.position.getOrUndefined()?.toPrimitive().length ?? 0) <= 100
|
||||
);
|
||||
}
|
||||
static create(
|
||||
props: Omit<AssignmentProps, "history">,
|
||||
recordedAt: string
|
||||
): Result<DepartmentAssignment, CompanyError> {
|
||||
if (!DepartmentAssignment.isValidValues(props)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new DepartmentAssignment({
|
||||
...props,
|
||||
history: [AssignmentRevision.create({ ...props, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: AssignmentProps, id: UniqueID) {
|
||||
return new DepartmentAssignment(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get personId() {
|
||||
return this.props.personId;
|
||||
}
|
||||
get departmentId() {
|
||||
return this.props.departmentId;
|
||||
}
|
||||
get values(): AssignmentValues {
|
||||
return this.props;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(values: AssignmentValues, recordedAt: string): Result<DepartmentAssignment, CompanyError> {
|
||||
if (!DepartmentAssignment.isValidValues(values)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
const current = this.values;
|
||||
if (
|
||||
sameOptional(current.position, values.position) &&
|
||||
current.startDate.equals(values.startDate) &&
|
||||
sameOptional(current.endDate, values.endDate) &&
|
||||
current.isPrimary === values.isPrimary
|
||||
) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(
|
||||
new DepartmentAssignment(
|
||||
{
|
||||
...this.props,
|
||||
...values,
|
||||
history: [...this.history, AssignmentRevision.create({ ...values, recordedAt })],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
34
modules/companies/src/api/domain/department.entity.ts
Normal file
34
modules/companies/src/api/domain/department.entity.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyError } from "./company-error";
|
||||
export type DepartmentProps = Readonly<{ companyId: UniqueID; name: Name; active: boolean }>;
|
||||
export class Department extends DomainEntity<DepartmentProps> {
|
||||
static create(props: DepartmentProps): Result<Department, CompanyError> {
|
||||
if (props.name.toPrimitive().length > 100) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(new Department(props));
|
||||
}
|
||||
static rehydrate(props: DepartmentProps, id: UniqueID) {
|
||||
return new Department(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
get active() {
|
||||
return this.props.active;
|
||||
}
|
||||
update(values: Omit<DepartmentProps, "companyId">): Result<Department, CompanyError> {
|
||||
if (values.name.toPrimitive().length > 100) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.name.equals(values.name) && this.active === values.active
|
||||
? Result.ok(this)
|
||||
: Result.ok(new Department({ ...values, companyId: this.companyId }, this.id));
|
||||
}
|
||||
}
|
||||
305
modules/companies/src/api/domain/people.test.ts
Normal file
305
modules/companies/src/api/domain/people.test.ts
Normal file
@ -0,0 +1,305 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { CompanyInputMapper } from "../application/company-input.mapper";
|
||||
import { CompanyPersistenceMapper } from "../infrastructure/persistence/company-persistence.mapper";
|
||||
|
||||
import { Company } from "./company.aggregate";
|
||||
import { Department } from "./department.entity";
|
||||
import { Person } from "./person.entity";
|
||||
|
||||
const input = new CompanyInputMapper();
|
||||
const now = "2026-10-03T10:00:00.000Z";
|
||||
function fixture() {
|
||||
const props = input.company({ name: "Empresa", active: true });
|
||||
assert.ok(props.isSuccess);
|
||||
const created = Company.create(props.data);
|
||||
assert.ok(created.isSuccess);
|
||||
const personProps = input.person({
|
||||
name: "Ana",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
});
|
||||
assert.ok(personProps.isSuccess);
|
||||
const person = Person.create({ ...personProps.data, companyId: created.data.id });
|
||||
assert.ok(person.isSuccess);
|
||||
const company = created.data.addPerson(person.data);
|
||||
assert.ok(company.isSuccess);
|
||||
return { company: company.data, person: person.data };
|
||||
}
|
||||
function addDepartment(company: Company, name: string, active = true) {
|
||||
const props = input.department({ name, active });
|
||||
assert.ok(props.isSuccess);
|
||||
const department = Department.create({ ...props.data, companyId: company.id });
|
||||
assert.ok(department.isSuccess);
|
||||
const added = company.addDepartment(department.data);
|
||||
assert.ok(added.isSuccess);
|
||||
return { company: added.data, department: department.data };
|
||||
}
|
||||
function assignment(
|
||||
position: string | null = null,
|
||||
startDate = "2026-01-01",
|
||||
endDate: string | null = null,
|
||||
isPrimary = false
|
||||
) {
|
||||
const parsed = input.assignment({ position, startDate, endDate, isPrimary });
|
||||
assert.ok(parsed.isSuccess);
|
||||
return parsed.data;
|
||||
}
|
||||
test("personas con datos opcionales no requieren cuenta de acceso y conservan su UUID", () => {
|
||||
const { company, person } = fixture();
|
||||
assert.equal(person.email.getOrUndefined(), undefined);
|
||||
assert.equal(company.contacts.length, 0);
|
||||
const props = input.person({
|
||||
name: "Ana",
|
||||
position: "Responsable",
|
||||
email: "SHARED@example.test",
|
||||
phone: null,
|
||||
active: false,
|
||||
isContact: true,
|
||||
});
|
||||
assert.ok(props.isSuccess);
|
||||
const changed = company.updatePerson(person.id, props.data);
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.equal(changed.data.people[0]?.id.toPrimitive(), person.id.toPrimitive());
|
||||
assert.equal(changed.data.contacts.length, 1);
|
||||
assert.equal(
|
||||
input.person({
|
||||
name: " ",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
}).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("departamentos únicos por compañía, ignorando mayúsculas y espacios exteriores", () => {
|
||||
const first = addDepartment(fixture().company, " Administración ");
|
||||
const props = input.department({ name: "ADMINISTRACIÓN", active: false });
|
||||
assert.ok(props.isSuccess);
|
||||
const duplicate = Department.create({ ...props.data, companyId: first.company.id });
|
||||
assert.ok(duplicate.isSuccess);
|
||||
const conflict = first.company.addDepartment(duplicate.data);
|
||||
assert.equal(conflict.isSuccess, false);
|
||||
if (!conflict.isSuccess) {
|
||||
assert.equal(conflict.error.code, "DEPARTMENT_NAME_EXISTS");
|
||||
}
|
||||
const unchanged = first.company.updateDepartment(first.department.id, {
|
||||
name: first.department.name,
|
||||
active: true,
|
||||
});
|
||||
assert.ok(unchanged.isSuccess);
|
||||
assert.equal(unchanged.data, first.company);
|
||||
assert.ok(addDepartment(fixture().company, "Administración").company.departments.length);
|
||||
});
|
||||
test("varios departamentos, un único principal y sin periodos solapados", () => {
|
||||
const base = fixture();
|
||||
const first = addDepartment(base.company, "Compras");
|
||||
const second = addDepartment(first.company, "Administración");
|
||||
const added = second.company.addAssignment(
|
||||
base.person.id,
|
||||
first.department.id,
|
||||
assignment("Responsable", undefined, undefined, true),
|
||||
now
|
||||
);
|
||||
assert.ok(added.isSuccess);
|
||||
const other = added.data.addAssignment(
|
||||
base.person.id,
|
||||
second.department.id,
|
||||
assignment("Supervisor"),
|
||||
now
|
||||
);
|
||||
assert.ok(other.isSuccess);
|
||||
assert.equal(other.data.assignments.length, 2);
|
||||
assert.equal(
|
||||
other.data.addAssignment(base.person.id, first.department.id, assignment(), now).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
other.data.addAssignment(
|
||||
base.person.id,
|
||||
second.department.id,
|
||||
assignment(null, "2026-03-01", null, true),
|
||||
now
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
const current = other.data.assignments[0];
|
||||
assert.ok(current);
|
||||
const closed = other.data.updateAssignment(
|
||||
base.person.id,
|
||||
current.id,
|
||||
assignment("Responsable", "2026-01-01", "2026-03-01"),
|
||||
now
|
||||
);
|
||||
assert.ok(closed.isSuccess);
|
||||
assert.ok(
|
||||
closed.data.addAssignment(
|
||||
base.person.id,
|
||||
first.department.id,
|
||||
assignment("Técnico", "2026-03-01"),
|
||||
now
|
||||
).isSuccess
|
||||
);
|
||||
assert.equal(
|
||||
closed.data.addAssignment(
|
||||
base.person.id,
|
||||
first.department.id,
|
||||
assignment(null, "2026-02-01", "2026-04-01"),
|
||||
now
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("ediciones conservan revisiones y una repetición semántica no añade historial", () => {
|
||||
const base = fixture();
|
||||
const dept = addDepartment(base.company, "Compras");
|
||||
const added = dept.company.addAssignment(
|
||||
base.person.id,
|
||||
dept.department.id,
|
||||
assignment("Inicial"),
|
||||
now
|
||||
);
|
||||
assert.ok(added.isSuccess);
|
||||
const current = added.data.assignments[0];
|
||||
assert.ok(current);
|
||||
const changed = added.data.updateAssignment(
|
||||
base.person.id,
|
||||
current.id,
|
||||
assignment("Nuevo"),
|
||||
"2026-10-03T11:00:00.000Z"
|
||||
);
|
||||
assert.ok(changed.isSuccess);
|
||||
const saved = changed.data.assignments[0];
|
||||
assert.ok(saved);
|
||||
assert.equal(saved.id.toPrimitive(), current.id.toPrimitive());
|
||||
assert.equal(saved.history.length, 2);
|
||||
assert.equal(current.history.length, 1);
|
||||
assert.equal(saved.history[0]?.values.position.getOrUndefined()?.toPrimitive(), "Inicial");
|
||||
assert.notEqual(saved.history[0]?.id.toPrimitive(), saved.history[1]?.id.toPrimitive());
|
||||
const repeated = changed.data.updateAssignment(
|
||||
base.person.id,
|
||||
current.id,
|
||||
assignment(" Nuevo "),
|
||||
"2026-10-03T12:00:00.000Z"
|
||||
);
|
||||
assert.ok(repeated.isSuccess);
|
||||
assert.equal(repeated.data, changed.data);
|
||||
const mapper = new CompanyPersistenceMapper();
|
||||
const restored = mapper.toDomain(mapper.toPersistence(changed.data));
|
||||
assert.equal(restored.assignments[0]?.history.length, 2);
|
||||
assert.equal(restored.people[0]?.id.toPrimitive(), base.person.id.toPrimitive());
|
||||
});
|
||||
test("inactivos no reciben nuevas asignaciones; se conservan y pueden cerrarse las existentes", () => {
|
||||
const base = fixture();
|
||||
const dept = addDepartment(base.company, "Compras");
|
||||
const added = dept.company.addAssignment(base.person.id, dept.department.id, assignment(), now);
|
||||
assert.ok(added.isSuccess);
|
||||
const props = input.person({
|
||||
name: "Ana",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: false,
|
||||
isContact: false,
|
||||
});
|
||||
assert.ok(props.isSuccess);
|
||||
const inactive = added.data.updatePerson(base.person.id, props.data);
|
||||
assert.ok(inactive.isSuccess);
|
||||
assert.equal(inactive.data.assignments.length, 1);
|
||||
assert.equal(
|
||||
inactive.data.addAssignment(
|
||||
base.person.id,
|
||||
dept.department.id,
|
||||
assignment(null, "2025-01-01", "2025-02-01"),
|
||||
now
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
const existing = inactive.data.assignments[0];
|
||||
assert.ok(existing);
|
||||
const closed = inactive.data.updateAssignment(
|
||||
base.person.id,
|
||||
existing.id,
|
||||
assignment(null, "2026-01-01", "2026-02-01"),
|
||||
now
|
||||
);
|
||||
assert.ok(closed.isSuccess);
|
||||
assert.equal(
|
||||
closed.data.updateAssignment(base.person.id, existing.id, assignment(), now).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("asignaciones rechazan pertenencias cruzadas y fechas inválidas", () => {
|
||||
const base = fixture();
|
||||
const other = addDepartment(fixture().company, "Otra");
|
||||
assert.equal(
|
||||
base.company.addAssignment(base.person.id, other.department.id, assignment(), now).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
input.assignment({ position: null, startDate: "2026-02-30", endDate: null, isPrimary: false })
|
||||
.isSuccess,
|
||||
false
|
||||
);
|
||||
const dept = addDepartment(base.company, "Compras");
|
||||
assert.equal(
|
||||
dept.company.addAssignment(
|
||||
base.person.id,
|
||||
dept.department.id,
|
||||
assignment(null, "2026-02-01", "2026-01-01"),
|
||||
now
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
dept.company.addAssignment(
|
||||
base.person.id,
|
||||
dept.department.id,
|
||||
assignment(null, "2026-01-01", "2026-02-01", true),
|
||||
now
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("la persistencia rechaza periodos inválidos y revisiones que no reflejan el estado actual", () => {
|
||||
const base = fixture();
|
||||
const dept = addDepartment(base.company, "Compras");
|
||||
const added = dept.company.addAssignment(base.person.id, dept.department.id, assignment(), now);
|
||||
assert.ok(added.isSuccess);
|
||||
const mapper = new CompanyPersistenceMapper();
|
||||
const record = mapper.toPersistence(added.data);
|
||||
const row = record.assignments[0];
|
||||
const revision = record.revisions[0];
|
||||
assert.ok(row && revision);
|
||||
assert.throws(
|
||||
() =>
|
||||
mapper.toDomain({
|
||||
...record,
|
||||
assignments: [{ ...row, endDate: "2025-01-01" }],
|
||||
}),
|
||||
/persistida inconsistente/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
mapper.toDomain({
|
||||
...record,
|
||||
revisions: [{ ...revision, endDate: "2025-01-01" }],
|
||||
}),
|
||||
/persistido inconsistente/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
mapper.toDomain({
|
||||
...record,
|
||||
revisions: [{ ...revision, position: "Otro puesto" }],
|
||||
}),
|
||||
/persistido inconsistente/
|
||||
);
|
||||
});
|
||||
76
modules/companies/src/api/domain/person.entity.ts
Normal file
76
modules/companies/src/api/domain/person.entity.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyError } from "./company-error";
|
||||
import type { PhoneNumber } from "./value-objects/phone-number";
|
||||
|
||||
export type PersonProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
name: Name;
|
||||
position: Maybe<Name>;
|
||||
email: Maybe<EmailAddress>;
|
||||
phone: Maybe<PhoneNumber>;
|
||||
active: boolean;
|
||||
isContact: boolean;
|
||||
}>;
|
||||
export function sameOptional<T extends { equals(other: T | undefined): boolean }>(
|
||||
a: Maybe<T>,
|
||||
b: Maybe<T>
|
||||
): boolean {
|
||||
const left = a.getOrUndefined();
|
||||
const right = b.getOrUndefined();
|
||||
return left ? left.equals(right) : right === undefined;
|
||||
}
|
||||
export class Person extends DomainEntity<PersonProps> {
|
||||
static create(props: PersonProps): Result<Person, CompanyError> {
|
||||
if (
|
||||
props.name.toPrimitive().length > 100 ||
|
||||
(props.position.getOrUndefined()?.toPrimitive().length ?? 0) > 100
|
||||
) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(new Person(props));
|
||||
}
|
||||
static rehydrate(props: PersonProps, id: UniqueID): Person {
|
||||
return new Person(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
get position() {
|
||||
return this.props.position;
|
||||
}
|
||||
get email() {
|
||||
return this.props.email;
|
||||
}
|
||||
get phone() {
|
||||
return this.props.phone;
|
||||
}
|
||||
get active() {
|
||||
return this.props.active;
|
||||
}
|
||||
get isContact() {
|
||||
return this.props.isContact;
|
||||
}
|
||||
update(values: Omit<PersonProps, "companyId">): Result<Person, CompanyError> {
|
||||
if (
|
||||
values.name.toPrimitive().length > 100 ||
|
||||
(values.position.getOrUndefined()?.toPrimitive().length ?? 0) > 100
|
||||
) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.name.equals(values.name) &&
|
||||
sameOptional(this.position, values.position) &&
|
||||
sameOptional(this.email, values.email) &&
|
||||
sameOptional(this.phone, values.phone) &&
|
||||
this.active === values.active &&
|
||||
this.isContact === values.isContact
|
||||
? Result.ok(this)
|
||||
: Result.ok(new Person({ ...values, companyId: this.companyId }, this.id));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class CalendarDate extends ValueObject<string> {
|
||||
static create(value: string): Result<CalendarDate, Error> {
|
||||
return z.iso.date().safeParse(value).success
|
||||
? Result.ok(new CalendarDate(value))
|
||||
: Result.fail(new Error("Fecha inválida."));
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,16 @@ import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
AssignmentParamsSchema,
|
||||
AssignmentRequestSchema,
|
||||
AssignmentUpdateRequestSchema,
|
||||
ChildParamsSchema,
|
||||
CompanyParamsSchema,
|
||||
CompanyRequestSchema,
|
||||
ContactRequestSchema,
|
||||
DepartmentRequestSchema,
|
||||
PersonParamsSchema,
|
||||
PersonRequestSchema,
|
||||
SiteRequestSchema,
|
||||
} from "../../../common";
|
||||
import type { CompanyUseCases } from "../../application/company.use-cases";
|
||||
@ -27,7 +33,15 @@ function sendError(response: Response, code: string, status: number): void {
|
||||
}
|
||||
function sendResult(response: Response, result: Result<Company, CompanyError>, status = 200): void {
|
||||
if (!result.isSuccess) {
|
||||
sendError(response, result.error.code, result.error.code === "INVALID_REQUEST" ? 400 : 404);
|
||||
sendError(
|
||||
response,
|
||||
result.error.code,
|
||||
result.error.code === "INVALID_REQUEST"
|
||||
? 400
|
||||
: result.error.code.endsWith("_NOT_FOUND")
|
||||
? 404
|
||||
: 409
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.status(status).json({ company: buildCompanyResponse(result.data) });
|
||||
@ -102,5 +116,41 @@ export function registerCompanyRoutes(
|
||||
post("/:companyId/sites/:id/update", SiteRequestSchema, ChildParamsSchema, (dto, params) =>
|
||||
useCases.updateSite(params.companyId, params.id, dto)
|
||||
);
|
||||
post(
|
||||
"/:companyId/people",
|
||||
PersonRequestSchema,
|
||||
CompanyParamsSchema,
|
||||
(dto, params) => useCases.addPerson(params.companyId, dto),
|
||||
201
|
||||
);
|
||||
post("/:companyId/people/:id/update", PersonRequestSchema, ChildParamsSchema, (dto, params) =>
|
||||
useCases.updatePerson(params.companyId, params.id, dto)
|
||||
);
|
||||
post(
|
||||
"/:companyId/departments",
|
||||
DepartmentRequestSchema,
|
||||
CompanyParamsSchema,
|
||||
(dto, params) => useCases.addDepartment(params.companyId, dto),
|
||||
201
|
||||
);
|
||||
post(
|
||||
"/:companyId/departments/:id/update",
|
||||
DepartmentRequestSchema,
|
||||
ChildParamsSchema,
|
||||
(dto, params) => useCases.updateDepartment(params.companyId, params.id, dto)
|
||||
);
|
||||
post(
|
||||
"/:companyId/people/:personId/assignments",
|
||||
AssignmentRequestSchema,
|
||||
PersonParamsSchema,
|
||||
(dto, params) => useCases.addAssignment(params.companyId, params.personId, dto),
|
||||
201
|
||||
);
|
||||
post(
|
||||
"/:companyId/people/:personId/assignments/:id/update",
|
||||
AssignmentUpdateRequestSchema,
|
||||
AssignmentParamsSchema,
|
||||
(dto, params) => useCases.updateAssignment(params.companyId, params.personId, params.id, dto)
|
||||
);
|
||||
app.use("/api/companies", authenticate, router);
|
||||
}
|
||||
|
||||
@ -1,17 +1,26 @@
|
||||
import type { Result as DomainResult } from "@repo/rdx-utils";
|
||||
import type { InferAttributes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { CompanyInputMapper } from "../../application/company-input.mapper";
|
||||
import { Company } from "../../domain/company.aggregate";
|
||||
import { Contact } from "../../domain/contact.entity";
|
||||
import { Department } from "../../domain/department.entity";
|
||||
import {
|
||||
AssignmentRevision,
|
||||
DepartmentAssignment,
|
||||
} from "../../domain/department-assignment.entity";
|
||||
import { Person } from "../../domain/person.entity";
|
||||
import { Site } from "../../domain/site.entity";
|
||||
|
||||
import type { CompanyModel, ContactModel, SiteModel } from "./company.model";
|
||||
|
||||
import type { CompanyModel, PersonModel, SiteModel } from "./company.model";
|
||||
import type { AssignmentModel, AssignmentRevisionModel, DepartmentModel } from "./people.model";
|
||||
export type CompanyRecord = {
|
||||
company: InferAttributes<CompanyModel>;
|
||||
contacts: InferAttributes<ContactModel>[];
|
||||
people: InferAttributes<PersonModel>[];
|
||||
sites: InferAttributes<SiteModel>[];
|
||||
departments: InferAttributes<DepartmentModel>[];
|
||||
assignments: InferAttributes<AssignmentModel>[];
|
||||
revisions: InferAttributes<AssignmentRevisionModel>[];
|
||||
};
|
||||
function persisted<T>(result: DomainResult<T, Error>): T {
|
||||
if (!result.isSuccess) {
|
||||
@ -24,43 +33,113 @@ export class CompanyPersistenceMapper {
|
||||
toDomain(record: CompanyRecord): Company {
|
||||
const id = persisted(this.input.id(record.company.id));
|
||||
const props = persisted(this.input.company(record.company));
|
||||
const contacts = record.contacts.map((row) => {
|
||||
const companyId = persisted(this.input.id(row.companyId));
|
||||
if (!companyId.equals(id)) {
|
||||
throw new Error("Persistencia de contacto inconsistente.");
|
||||
const companyId = (value: string) => {
|
||||
const parent = persisted(this.input.id(value));
|
||||
if (!parent.equals(id)) {
|
||||
throw new Error("Pertenencia persistida inconsistente.");
|
||||
}
|
||||
return Contact.rehydrate(
|
||||
{ ...persisted(this.input.contact(row)), companyId },
|
||||
return parent;
|
||||
};
|
||||
const people = record.people.map((row) =>
|
||||
Person.rehydrate(
|
||||
{ ...persisted(this.input.person(row)), companyId: companyId(row.companyId) },
|
||||
persisted(this.input.id(row.id))
|
||||
)
|
||||
);
|
||||
const sites = record.sites.map((row) =>
|
||||
Site.rehydrate(
|
||||
{ ...persisted(this.input.site(row)), companyId: companyId(row.companyId) },
|
||||
persisted(this.input.id(row.id))
|
||||
)
|
||||
);
|
||||
const departments = record.departments.map((row) =>
|
||||
Department.rehydrate(
|
||||
{ ...persisted(this.input.department(row)), companyId: companyId(row.companyId) },
|
||||
persisted(this.input.id(row.id))
|
||||
)
|
||||
);
|
||||
const assignments = record.assignments.map((row) => {
|
||||
const personId = persisted(this.input.id(row.personId));
|
||||
const departmentId = persisted(this.input.id(row.departmentId));
|
||||
if (
|
||||
!(
|
||||
people.some((item) => item.id.equals(personId)) &&
|
||||
departments.some((item) => item.id.equals(departmentId))
|
||||
)
|
||||
) {
|
||||
throw new Error("Asignación persistida inconsistente.");
|
||||
}
|
||||
const revisions = record.revisions
|
||||
.filter((revision) => revision.assignmentId === row.id)
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
if (revisions.length === 0) {
|
||||
throw new Error("Historial persistido inconsistente.");
|
||||
}
|
||||
const values = persisted(this.input.assignment(row));
|
||||
if (!DepartmentAssignment.isValidValues(values)) {
|
||||
throw new Error("Asignación persistida inconsistente.");
|
||||
}
|
||||
const history = revisions.map((revision, index) => {
|
||||
if (
|
||||
revision.sequence !== index ||
|
||||
!z.iso.datetime().safeParse(revision.recordedAt).success
|
||||
) {
|
||||
throw new Error("Historial persistido inconsistente.");
|
||||
}
|
||||
const revisionValues = persisted(this.input.assignment(revision));
|
||||
if (!DepartmentAssignment.isValidValues(revisionValues)) {
|
||||
throw new Error("Historial persistido inconsistente.");
|
||||
}
|
||||
return AssignmentRevision.rehydrate(
|
||||
{ ...revisionValues, recordedAt: revision.recordedAt },
|
||||
persisted(this.input.id(revision.id))
|
||||
);
|
||||
});
|
||||
const latest = revisions.at(-1);
|
||||
if (
|
||||
!latest ||
|
||||
latest.position !== row.position ||
|
||||
latest.startDate !== row.startDate ||
|
||||
latest.endDate !== row.endDate ||
|
||||
latest.isPrimary !== row.isPrimary
|
||||
) {
|
||||
throw new Error("Historial persistido inconsistente.");
|
||||
}
|
||||
return DepartmentAssignment.rehydrate(
|
||||
{
|
||||
...values,
|
||||
companyId: companyId(row.companyId),
|
||||
personId,
|
||||
departmentId,
|
||||
history,
|
||||
},
|
||||
persisted(this.input.id(row.id))
|
||||
);
|
||||
});
|
||||
const sites = record.sites.map((row) => {
|
||||
const companyId = persisted(this.input.id(row.companyId));
|
||||
if (!companyId.equals(id)) {
|
||||
throw new Error("Persistencia de sede inconsistente.");
|
||||
}
|
||||
return Site.rehydrate(
|
||||
{ ...persisted(this.input.site(row)), companyId },
|
||||
persisted(this.input.id(row.id))
|
||||
);
|
||||
});
|
||||
return Company.rehydrate({ ...props, contacts, sites }, id);
|
||||
return Company.rehydrate({ ...props, people, sites, departments, assignments }, id);
|
||||
}
|
||||
toPersistence(company: Company): CompanyRecord {
|
||||
const values = (item: DepartmentAssignment["values"]) => ({
|
||||
position: item.position.getOrUndefined()?.toPrimitive() ?? null,
|
||||
startDate: item.startDate.toPrimitive(),
|
||||
endDate: item.endDate.getOrUndefined()?.toPrimitive() ?? null,
|
||||
isPrimary: item.isPrimary,
|
||||
});
|
||||
return {
|
||||
company: {
|
||||
id: company.id.toPrimitive(),
|
||||
name: company.name.toPrimitive(),
|
||||
active: company.active,
|
||||
},
|
||||
contacts: company.contacts.map((contact) => ({
|
||||
id: contact.id.toPrimitive(),
|
||||
companyId: contact.companyId.toPrimitive(),
|
||||
name: contact.name.toPrimitive(),
|
||||
position: contact.position.toPrimitive(),
|
||||
email: contact.email.toPrimitive(),
|
||||
phone: contact.phone.toPrimitive(),
|
||||
active: contact.active,
|
||||
people: company.people.map((person) => ({
|
||||
id: person.id.toPrimitive(),
|
||||
companyId: person.companyId.toPrimitive(),
|
||||
name: person.name.toPrimitive(),
|
||||
position: person.position.getOrUndefined()?.toPrimitive() ?? null,
|
||||
email: person.email.getOrUndefined()?.toPrimitive() ?? null,
|
||||
phone: person.phone.getOrUndefined()?.toPrimitive() ?? null,
|
||||
active: person.active,
|
||||
isContact: person.isContact,
|
||||
})),
|
||||
sites: company.sites.map((site) => ({
|
||||
id: site.id.toPrimitive(),
|
||||
@ -68,6 +147,29 @@ export class CompanyPersistenceMapper {
|
||||
name: site.name.toPrimitive(),
|
||||
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
|
||||
})),
|
||||
departments: company.departments.map((item) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
name: item.name.toPrimitive(),
|
||||
nameKey: item.name.toPrimitive().normalize("NFC").toLowerCase(),
|
||||
active: item.active,
|
||||
})),
|
||||
assignments: company.assignments.map((item) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
personId: item.personId.toPrimitive(),
|
||||
departmentId: item.departmentId.toPrimitive(),
|
||||
...values(item.values),
|
||||
})),
|
||||
revisions: company.assignments.flatMap((item) =>
|
||||
item.history.map((revision, sequence) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
assignmentId: item.id.toPrimitive(),
|
||||
sequence,
|
||||
recordedAt: revision.values.recordedAt,
|
||||
...values(revision.values),
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,16 +9,17 @@ export class CompanyModel extends Model<
|
||||
declare name: string;
|
||||
declare active: boolean;
|
||||
}
|
||||
export class ContactModel extends Model<
|
||||
InferAttributes<ContactModel>,
|
||||
InferCreationAttributes<ContactModel>
|
||||
export class PersonModel extends Model<
|
||||
InferAttributes<PersonModel>,
|
||||
InferCreationAttributes<PersonModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare companyId: string;
|
||||
declare name: string;
|
||||
declare position: string;
|
||||
declare email: string;
|
||||
declare phone: string;
|
||||
declare position: string | null;
|
||||
declare email: string | null;
|
||||
declare phone: string | null;
|
||||
declare isContact: boolean;
|
||||
declare active: boolean;
|
||||
}
|
||||
export class SiteModel extends Model<
|
||||
@ -44,12 +45,13 @@ export function initializeCompanyModels(database: Sequelize): void {
|
||||
companyId: { type: DataTypes.UUID, allowNull: false },
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
};
|
||||
ContactModel.init(
|
||||
PersonModel.init(
|
||||
{
|
||||
...childFields,
|
||||
position: { type: DataTypes.STRING(100), allowNull: false },
|
||||
email: { type: DataTypes.STRING(254), allowNull: false },
|
||||
phone: { type: DataTypes.STRING(50), allowNull: false },
|
||||
position: { type: DataTypes.STRING(100), allowNull: true },
|
||||
email: { type: DataTypes.STRING(254), allowNull: true },
|
||||
phone: { type: DataTypes.STRING(50), allowNull: true },
|
||||
isContact: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
},
|
||||
{ sequelize: database, tableName: "company_contacts", timestamps: false }
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
|
||||
import { assignmentColumns } from "./people.model";
|
||||
export async function migratePeople(database: Sequelize): Promise<void> {
|
||||
const query = database.getQueryInterface();
|
||||
// Evolución de contactos a personas: conservar tabla, UUID y relaciones existentes.
|
||||
const columns = await query.describeTable("company_contacts");
|
||||
for (const [name, length] of [
|
||||
["position", 100],
|
||||
["email", 254],
|
||||
["phone", 50],
|
||||
] as const) {
|
||||
if (columns[name]?.allowNull === false) {
|
||||
await query.changeColumn("company_contacts", name, {
|
||||
type: DataTypes.STRING(length),
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!columns.isContact) {
|
||||
await query.addColumn("company_contacts", "isContact", {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const exists = (name: string) => tables.some((row) => row.name === name);
|
||||
if (!exists("company_departments")) {
|
||||
await query.createTable("company_departments", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: "companies", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
nameKey: { type: DataTypes.STRING(200).BINARY, allowNull: false },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
});
|
||||
}
|
||||
// Las claves compuestas impiden referencias cruzadas entre compañías también en la base de datos.
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='company_contacts'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "person_company_identity_unique")) {
|
||||
await query.addIndex("company_contacts", ["id", "companyId"], {
|
||||
unique: true,
|
||||
name: "person_company_identity_unique",
|
||||
});
|
||||
}
|
||||
const departmentIndexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='company_departments'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!departmentIndexes.some((item) => item.name === "department_company_identity_unique")) {
|
||||
await query.addIndex("company_departments", ["id", "companyId"], {
|
||||
unique: true,
|
||||
name: "department_company_identity_unique",
|
||||
});
|
||||
}
|
||||
if (!departmentIndexes.some((item) => item.name === "department_company_name_unique")) {
|
||||
await query.addIndex("company_departments", ["companyId", "nameKey"], {
|
||||
unique: true,
|
||||
name: "department_company_name_unique",
|
||||
});
|
||||
}
|
||||
if (!exists("person_department_assignments")) {
|
||||
await query.createTable("person_department_assignments", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: "companies", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
personId: { type: DataTypes.UUID, allowNull: false },
|
||||
departmentId: { type: DataTypes.UUID, allowNull: false },
|
||||
...assignmentColumns,
|
||||
});
|
||||
}
|
||||
const constraints = await database.query<{ name: string }>(
|
||||
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='person_department_assignments'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!constraints.some((item) => item.name === "assignment_person_company_fk")) {
|
||||
await database.query(
|
||||
"ALTER TABLE person_department_assignments ADD CONSTRAINT assignment_person_company_fk FOREIGN KEY (personId, companyId) REFERENCES company_contacts (id, companyId) ON DELETE RESTRICT"
|
||||
);
|
||||
}
|
||||
if (!constraints.some((item) => item.name === "assignment_department_company_fk")) {
|
||||
await database.query(
|
||||
"ALTER TABLE person_department_assignments ADD CONSTRAINT assignment_department_company_fk FOREIGN KEY (departmentId, companyId) REFERENCES company_departments (id, companyId) ON DELETE RESTRICT"
|
||||
);
|
||||
}
|
||||
if (!exists("department_assignment_revisions")) {
|
||||
await query.createTable("department_assignment_revisions", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
assignmentId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: "person_department_assignments", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
||||
...assignmentColumns,
|
||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||
});
|
||||
}
|
||||
const revisionIndexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='department_assignment_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!revisionIndexes.some((item) => item.name === "assignment_revision_sequence_unique")) {
|
||||
await query.addIndex("department_assignment_revisions", ["assignmentId", "sequence"], {
|
||||
unique: true,
|
||||
name: "assignment_revision_sequence_unique",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||
import { DataTypes, Model } from "sequelize";
|
||||
export class DepartmentModel extends Model<
|
||||
InferAttributes<DepartmentModel>,
|
||||
InferCreationAttributes<DepartmentModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare companyId: string;
|
||||
declare name: string;
|
||||
declare nameKey: string;
|
||||
declare active: boolean;
|
||||
}
|
||||
export class AssignmentModel extends Model<
|
||||
InferAttributes<AssignmentModel>,
|
||||
InferCreationAttributes<AssignmentModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare companyId: string;
|
||||
declare personId: string;
|
||||
declare departmentId: string;
|
||||
declare position: string | null;
|
||||
declare startDate: string;
|
||||
declare endDate: string | null;
|
||||
declare isPrimary: boolean;
|
||||
}
|
||||
export class AssignmentRevisionModel extends Model<
|
||||
InferAttributes<AssignmentRevisionModel>,
|
||||
InferCreationAttributes<AssignmentRevisionModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare assignmentId: string;
|
||||
declare sequence: number;
|
||||
declare position: string | null;
|
||||
declare startDate: string;
|
||||
declare endDate: string | null;
|
||||
declare isPrimary: boolean;
|
||||
declare recordedAt: string;
|
||||
}
|
||||
export const assignmentColumns = {
|
||||
position: { type: DataTypes.STRING(100), allowNull: true },
|
||||
startDate: { type: DataTypes.DATEONLY, allowNull: false },
|
||||
endDate: { type: DataTypes.DATEONLY, allowNull: true },
|
||||
isPrimary: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
};
|
||||
export function initializePeopleModels(database: Sequelize): void {
|
||||
const key = { type: DataTypes.UUID, primaryKey: true };
|
||||
DepartmentModel.init(
|
||||
{
|
||||
id: key,
|
||||
companyId: { type: DataTypes.UUID, allowNull: false },
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
nameKey: { type: DataTypes.STRING(200).BINARY, allowNull: false },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
},
|
||||
{ sequelize: database, tableName: "company_departments", timestamps: false }
|
||||
);
|
||||
AssignmentModel.init(
|
||||
{
|
||||
id: key,
|
||||
companyId: { type: DataTypes.UUID, allowNull: false },
|
||||
personId: { type: DataTypes.UUID, allowNull: false },
|
||||
departmentId: { type: DataTypes.UUID, allowNull: false },
|
||||
...assignmentColumns,
|
||||
},
|
||||
{ sequelize: database, tableName: "person_department_assignments", timestamps: false }
|
||||
);
|
||||
AssignmentRevisionModel.init(
|
||||
{
|
||||
id: key,
|
||||
assignmentId: { type: DataTypes.UUID, allowNull: false },
|
||||
sequence: { type: DataTypes.INTEGER, allowNull: false },
|
||||
...assignmentColumns,
|
||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||
},
|
||||
{ sequelize: database, tableName: "department_assignment_revisions", timestamps: false }
|
||||
);
|
||||
}
|
||||
@ -8,65 +8,95 @@ import type { ICompanyRepository } from "../../application/company.repository";
|
||||
import type { Company } from "../../domain/company.aggregate";
|
||||
import { CompanyError } from "../../domain/company-error";
|
||||
|
||||
import { CompanyModel, ContactModel, SiteModel } from "./company.model";
|
||||
import { CompanyModel, PersonModel, SiteModel } from "./company.model";
|
||||
import type { CompanyPersistenceMapper } from "./company-persistence.mapper";
|
||||
|
||||
import { AssignmentModel, AssignmentRevisionModel, DepartmentModel } from "./people.model";
|
||||
export class SequelizeCompanyRepository implements ICompanyRepository {
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly mapper: CompanyPersistenceMapper
|
||||
) {}
|
||||
async list(): Promise<readonly Company[]> {
|
||||
const companies = await CompanyModel.findAll({
|
||||
order: [
|
||||
["name", "ASC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
});
|
||||
if (companies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const ids = companies.map((company) => company.id);
|
||||
const [contacts, sites] = await Promise.all([
|
||||
ContactModel.findAll({
|
||||
where: { companyId: { [Op.in]: ids } },
|
||||
private async children(ids: readonly string[], transaction?: Transaction) {
|
||||
const options = {
|
||||
where: { companyId: { [Op.in]: ids } },
|
||||
...(transaction ? { transaction } : {}),
|
||||
};
|
||||
const [people, sites, departments, assignments] = await Promise.all([
|
||||
PersonModel.findAll({
|
||||
...options,
|
||||
order: [
|
||||
["name", "ASC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
}),
|
||||
SiteModel.findAll({
|
||||
where: { companyId: { [Op.in]: ids } },
|
||||
SiteModel.findAll({ ...options, order: [["name", "ASC"]] }),
|
||||
DepartmentModel.findAll({ ...options, order: [["name", "ASC"]] }),
|
||||
AssignmentModel.findAll({
|
||||
...options,
|
||||
order: [
|
||||
["name", "ASC"],
|
||||
["startDate", "DESC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
}),
|
||||
]);
|
||||
return companies.map((company) =>
|
||||
this.mapper.toDomain({
|
||||
company,
|
||||
contacts: contacts.filter((contact) => contact.companyId === company.id),
|
||||
sites: sites.filter((site) => site.companyId === company.id),
|
||||
})
|
||||
);
|
||||
const revisions = assignments.length
|
||||
? await AssignmentRevisionModel.findAll({
|
||||
where: { assignmentId: { [Op.in]: assignments.map((item) => item.id) } },
|
||||
...(transaction ? { transaction } : {}),
|
||||
order: [["sequence", "ASC"]],
|
||||
})
|
||||
: [];
|
||||
return { people, sites, departments, assignments, revisions };
|
||||
}
|
||||
private async load(id: UniqueID, transaction?: Transaction): Promise<Maybe<Company>> {
|
||||
const options = transaction ? { transaction, lock: transaction.LOCK.UPDATE } : {};
|
||||
const company = await CompanyModel.findByPk(id.toPrimitive(), options);
|
||||
async list(): Promise<readonly Company[]> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const companies = await CompanyModel.findAll({
|
||||
transaction,
|
||||
order: [
|
||||
["name", "ASC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
});
|
||||
if (!companies.length) {
|
||||
return [];
|
||||
}
|
||||
const all = await this.children(
|
||||
companies.map((item) => item.id),
|
||||
transaction
|
||||
);
|
||||
return companies.map((company) => {
|
||||
const assignments = all.assignments.filter((item) => item.companyId === company.id);
|
||||
return this.mapper.toDomain({
|
||||
company,
|
||||
people: all.people.filter((item) => item.companyId === company.id),
|
||||
sites: all.sites.filter((item) => item.companyId === company.id),
|
||||
departments: all.departments.filter((item) => item.companyId === company.id),
|
||||
assignments,
|
||||
revisions: all.revisions.filter((item) =>
|
||||
assignments.some((assignment) => assignment.id === item.assignmentId)
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
private async load(
|
||||
id: UniqueID,
|
||||
transaction: Transaction,
|
||||
lock = false
|
||||
): Promise<Maybe<Company>> {
|
||||
const company = await CompanyModel.findByPk(id.toPrimitive(), {
|
||||
transaction,
|
||||
...(lock ? { lock: transaction.LOCK.UPDATE } : {}),
|
||||
});
|
||||
if (!company) {
|
||||
return Maybe.none();
|
||||
}
|
||||
const childOptions = {
|
||||
where: { companyId: company.id },
|
||||
...(transaction ? { transaction } : {}),
|
||||
};
|
||||
const contacts = await ContactModel.findAll({ ...childOptions, order: [["name", "ASC"]] });
|
||||
const sites = await SiteModel.findAll({ ...childOptions, order: [["name", "ASC"]] });
|
||||
return Maybe.some(this.mapper.toDomain({ company, contacts, sites }));
|
||||
return Maybe.some(
|
||||
this.mapper.toDomain({ company, ...(await this.children([company.id], transaction)) })
|
||||
);
|
||||
}
|
||||
findById(id: UniqueID) {
|
||||
return this.load(id);
|
||||
return this.database.transaction((transaction) => this.load(id, transaction));
|
||||
}
|
||||
async create(company: Company): Promise<Company> {
|
||||
await CompanyModel.create(this.mapper.toPersistence(company).company);
|
||||
@ -77,7 +107,7 @@ export class SequelizeCompanyRepository implements ICompanyRepository {
|
||||
change: (company: Company) => DomainResult<Company, CompanyError>
|
||||
): Promise<DomainResult<Company, CompanyError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const current = (await this.load(id, transaction)).getOrUndefined();
|
||||
const current = (await this.load(id, transaction, true)).getOrUndefined();
|
||||
if (!current) {
|
||||
return Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
@ -87,18 +117,39 @@ export class SequelizeCompanyRepository implements ICompanyRepository {
|
||||
}
|
||||
const record = this.mapper.toPersistence(changed.data);
|
||||
await CompanyModel.update(record.company, { where: { id: id.toPrimitive() }, transaction });
|
||||
if (record.contacts.length > 0) {
|
||||
await ContactModel.bulkCreate(record.contacts, {
|
||||
if (record.people.length) {
|
||||
await PersonModel.bulkCreate(record.people, {
|
||||
transaction,
|
||||
updateOnDuplicate: ["name", "position", "email", "phone", "active"],
|
||||
updateOnDuplicate: ["name", "position", "email", "phone", "active", "isContact"],
|
||||
});
|
||||
}
|
||||
if (record.sites.length > 0) {
|
||||
if (record.sites.length) {
|
||||
await SiteModel.bulkCreate(record.sites, {
|
||||
transaction,
|
||||
updateOnDuplicate: ["name", "address"],
|
||||
});
|
||||
}
|
||||
if (record.departments.length) {
|
||||
await DepartmentModel.bulkCreate(record.departments, {
|
||||
transaction,
|
||||
updateOnDuplicate: ["name", "nameKey", "active"],
|
||||
});
|
||||
}
|
||||
if (record.assignments.length) {
|
||||
await AssignmentModel.bulkCreate(record.assignments, {
|
||||
transaction,
|
||||
updateOnDuplicate: ["position", "startDate", "endDate", "isPrimary"],
|
||||
});
|
||||
}
|
||||
const persistedIds = new Set(
|
||||
current.assignments.flatMap((item) =>
|
||||
item.history.map((revision) => revision.id.toPrimitive())
|
||||
)
|
||||
);
|
||||
const newRevisions = record.revisions.filter((item) => !persistedIds.has(item.id));
|
||||
if (newRevisions.length) {
|
||||
await AssignmentRevisionModel.bulkCreate(newRevisions, { transaction });
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,11 +7,15 @@ import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
||||
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
||||
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
||||
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||
|
||||
export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) {
|
||||
await migrateCompanies(database);
|
||||
await migratePeople(database);
|
||||
initializeCompanyModels(database);
|
||||
initializePeopleModels(database);
|
||||
const useCases = new CompanyUseCases(
|
||||
new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()),
|
||||
new CompanyInputMapper()
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
AssignmentFieldsSchema,
|
||||
DepartmentFieldsSchema,
|
||||
PersonFieldsSchema,
|
||||
} from "../shared/people.dto";
|
||||
export const PersonRequestSchema = PersonFieldsSchema;
|
||||
export type PersonRequestDTO = z.infer<typeof PersonRequestSchema>;
|
||||
export const DepartmentRequestSchema = DepartmentFieldsSchema;
|
||||
export type DepartmentRequestDTO = z.infer<typeof DepartmentRequestSchema>;
|
||||
export const AssignmentRequestSchema = AssignmentFieldsSchema.extend({ departmentId: z.uuid() });
|
||||
export type AssignmentRequestDTO = z.infer<typeof AssignmentRequestSchema>;
|
||||
export const AssignmentUpdateRequestSchema = AssignmentFieldsSchema;
|
||||
export type AssignmentUpdateRequestDTO = z.infer<typeof AssignmentUpdateRequestSchema>;
|
||||
export const PersonParamsSchema = z.strictObject({ companyId: z.uuid(), personId: z.uuid() });
|
||||
export const AssignmentParamsSchema = PersonParamsSchema.extend({ id: z.uuid() });
|
||||
@ -9,6 +9,12 @@ export const CompanyErrorResponseSchema = z.strictObject({
|
||||
"COMPANY_NOT_FOUND",
|
||||
"CONTACT_NOT_FOUND",
|
||||
"SITE_NOT_FOUND",
|
||||
"PERSON_NOT_FOUND",
|
||||
"DEPARTMENT_NOT_FOUND",
|
||||
"DEPARTMENT_NAME_EXISTS",
|
||||
"ASSIGNMENT_NOT_FOUND",
|
||||
"ASSIGNMENT_CONFLICT",
|
||||
"INACTIVE_ASSIGNMENT_TARGET",
|
||||
"UNAUTHENTICATED",
|
||||
"FORBIDDEN",
|
||||
"INTERNAL_ERROR",
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
DepartmentAssignmentSchema,
|
||||
DepartmentSchema,
|
||||
PersonFieldsSchema,
|
||||
PersonSchema,
|
||||
} from "./people.dto";
|
||||
|
||||
const RequiredTextSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@ -9,25 +16,22 @@ export const CompanyFieldsSchema = z.strictObject({
|
||||
name: RequiredTextSchema,
|
||||
active: z.boolean(),
|
||||
});
|
||||
export const ContactFieldsSchema = z.strictObject({
|
||||
name: RequiredTextSchema,
|
||||
position: RequiredTextSchema,
|
||||
email: z.email().max(254),
|
||||
phone: z.string().min(3).max(50),
|
||||
active: z.boolean(),
|
||||
});
|
||||
export const ContactFieldsSchema = PersonFieldsSchema.omit({ isContact: true });
|
||||
export const SiteFieldsSchema = z.strictObject({
|
||||
name: RequiredTextSchema,
|
||||
address: z.string().max(10000).nullable(),
|
||||
});
|
||||
export const CompanySummarySchema = CompanyFieldsSchema.extend({ id: z.uuid() });
|
||||
export type CompanySummaryDTO = z.infer<typeof CompanySummarySchema>;
|
||||
export const ContactSchema = ContactFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() });
|
||||
export const ContactSchema = PersonSchema;
|
||||
export type ContactDTO = z.infer<typeof ContactSchema>;
|
||||
export const SiteSchema = SiteFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() });
|
||||
export type SiteDTO = z.infer<typeof SiteSchema>;
|
||||
export const CompanySchema = CompanySummarySchema.extend({
|
||||
contacts: z.array(ContactSchema),
|
||||
sites: z.array(SiteSchema),
|
||||
people: z.array(PersonSchema),
|
||||
departments: z.array(DepartmentSchema),
|
||||
assignments: z.array(DepartmentAssignmentSchema),
|
||||
});
|
||||
export type CompanyDTO = z.infer<typeof CompanySchema>;
|
||||
|
||||
48
modules/companies/src/common/dto/shared/people.dto.ts
Normal file
48
modules/companies/src/common/dto/shared/people.dto.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const OptionalText = z.string().max(100).nullable();
|
||||
export const PersonFieldsSchema = z.strictObject({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine((value) => value.trim().length > 0),
|
||||
position: OptionalText,
|
||||
email: z.email().max(254).nullable(),
|
||||
phone: z.string().min(3).max(50).nullable(),
|
||||
active: z.boolean(),
|
||||
isContact: z.boolean(),
|
||||
});
|
||||
export const PersonSchema = PersonFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() });
|
||||
export type PersonDTO = z.infer<typeof PersonSchema>;
|
||||
export const DepartmentFieldsSchema = z.strictObject({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine((value) => value.trim().length > 0),
|
||||
active: z.boolean(),
|
||||
});
|
||||
export const DepartmentSchema = DepartmentFieldsSchema.extend({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
});
|
||||
export type DepartmentDTO = z.infer<typeof DepartmentSchema>;
|
||||
export const AssignmentFieldsSchema = z.strictObject({
|
||||
position: OptionalText,
|
||||
startDate: z.iso.date(),
|
||||
endDate: z.iso.date().nullable(),
|
||||
isPrimary: z.boolean(),
|
||||
});
|
||||
export const AssignmentRevisionSchema = AssignmentFieldsSchema.extend({
|
||||
id: z.uuid(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
});
|
||||
export const DepartmentAssignmentSchema = AssignmentFieldsSchema.extend({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
personId: z.uuid(),
|
||||
departmentId: z.uuid(),
|
||||
history: z.array(AssignmentRevisionSchema),
|
||||
});
|
||||
export type DepartmentAssignmentDTO = z.infer<typeof DepartmentAssignmentSchema>;
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./dto/request/company.request.dto";
|
||||
export * from "./dto/request/people.request.dto";
|
||||
export * from "./dto/response/company.response.dto";
|
||||
export * from "./dto/shared/company.dto";
|
||||
export * from "./dto/shared/people.dto";
|
||||
|
||||
@ -43,9 +43,29 @@ export const companyApi = {
|
||||
get: (id: string) => companyRequest(`/${id}`, CompanyResponseSchema),
|
||||
save: (id: string | undefined, body: unknown, token: string) =>
|
||||
companyRequest(id ? `/${id}/update` : "", CompanyResponseSchema, body, token),
|
||||
contact: (companyId: string, id: string | undefined, body: unknown, token: string) =>
|
||||
person: (companyId: string, id: string | undefined, body: unknown, token: string) =>
|
||||
companyRequest(
|
||||
`/${companyId}/contacts${id ? `/${id}/update` : ""}`,
|
||||
`/${companyId}/people${id ? `/${id}/update` : ""}`,
|
||||
CompanyResponseSchema,
|
||||
body,
|
||||
token
|
||||
),
|
||||
department: (companyId: string, id: string | undefined, body: unknown, token: string) =>
|
||||
companyRequest(
|
||||
`/${companyId}/departments${id ? `/${id}/update` : ""}`,
|
||||
CompanyResponseSchema,
|
||||
body,
|
||||
token
|
||||
),
|
||||
assignment: (
|
||||
companyId: string,
|
||||
personId: string,
|
||||
id: string | undefined,
|
||||
body: unknown,
|
||||
token: string
|
||||
) =>
|
||||
companyRequest(
|
||||
`/${companyId}/people/${personId}/assignments${id ? `/${id}/update` : ""}`,
|
||||
CompanyResponseSchema,
|
||||
body,
|
||||
token
|
||||
|
||||
141
modules/companies/src/web/components/assignment-form.tsx
Normal file
141
modules/companies/src/web/components/assignment-form.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CompanyDTO, DepartmentAssignmentDTO } from "../../common";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
|
||||
const FormSchema = z.strictObject({
|
||||
departmentId: z.uuid(),
|
||||
position: z.string().max(100),
|
||||
startDate: z.iso.date(),
|
||||
endDate: z.union([z.iso.date(), z.literal("")]),
|
||||
isPrimary: z.boolean(),
|
||||
});
|
||||
type FormValues = z.infer<typeof FormSchema>;
|
||||
function today() {
|
||||
const date = new Date();
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
}
|
||||
export function AssignmentForm({
|
||||
company,
|
||||
personId,
|
||||
assignment,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
personId: string;
|
||||
assignment?: DepartmentAssignmentDTO | undefined;
|
||||
csrfToken: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const id = useId();
|
||||
const client = useQueryClient();
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
departmentId: assignment?.departmentId ?? "",
|
||||
position: assignment?.position ?? "",
|
||||
startDate: assignment?.startDate ?? today(),
|
||||
endDate: assignment?.endDate ?? "",
|
||||
isPrimary: assignment?.isPrimary ?? false,
|
||||
},
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
companyApi.assignment(
|
||||
company.id,
|
||||
personId,
|
||||
assignment?.id,
|
||||
{
|
||||
...(assignment ? {} : { departmentId: values.departmentId }),
|
||||
position: values.position.trim() || null,
|
||||
startDate: values.startDate,
|
||||
endDate: values.endDate || null,
|
||||
isPrimary: values.endDate ? false : values.isPrimary,
|
||||
},
|
||||
csrfToken
|
||||
),
|
||||
onSuccess: (response) => {
|
||||
client.setQueryData(companyKeys.detail(company.id), response);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-department`}>{t("department")}</Label>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
disabled={assignment !== undefined}
|
||||
id={`${id}-department`}
|
||||
required
|
||||
{...form.register("departmentId")}
|
||||
>
|
||||
<option value="">{t("chooseDepartment")}</option>
|
||||
{company.departments
|
||||
.filter((item) => item.active || item.id === assignment?.departmentId)
|
||||
.map((department) => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.name}
|
||||
{department.active ? "" : ` · ${t("inactive")}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("departmentPosition")}</Label>
|
||||
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-start`}>{t("startDate")}</Label>
|
||||
<Input id={`${id}-start`} required type="date" {...form.register("startDate")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-end`}>{t("endDate")}</Label>
|
||||
<Input
|
||||
id={`${id}-end`}
|
||||
min={form.watch("startDate")}
|
||||
type="date"
|
||||
{...form.register("endDate")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={!form.watch("endDate") && form.watch("isPrimary")}
|
||||
disabled={!!form.watch("endDate")}
|
||||
id={`${id}-primary`}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("isPrimary", checked === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-primary`}>{t("primaryDepartment")}</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("assignmentHelp")}</p>
|
||||
<CompanyFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
|
||||
import { CompaniesPage } from "./companies-page";
|
||||
import { CompanyDetail } from "./company-detail";
|
||||
import { PersonForm } from "./person-form";
|
||||
import { SiteForm } from "./site-form";
|
||||
|
||||
const companyId = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4";
|
||||
@ -66,7 +67,9 @@ test("la ficha presenta datos de la compañía, contactos y acceso a sedes", asy
|
||||
id: companyId,
|
||||
name: "Empresa test",
|
||||
active: true,
|
||||
contacts: [
|
||||
departments: [],
|
||||
assignments: [],
|
||||
people: [
|
||||
{
|
||||
id: companyId,
|
||||
companyId,
|
||||
@ -75,17 +78,19 @@ test("la ficha presenta datos de la compañía, contactos y acceso a sedes", asy
|
||||
email: "ana@example.test",
|
||||
phone: "900123456",
|
||||
active: false,
|
||||
isContact: true,
|
||||
},
|
||||
],
|
||||
contacts: [],
|
||||
sites: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.ok(markup.includes("Empresa test"));
|
||||
assert.ok(markup.includes("Responsable IT"));
|
||||
assert.ok(markup.includes("ana@example.test"));
|
||||
assert.ok(markup.includes("Personas (1)"));
|
||||
assert.ok(markup.includes("Sedes (0)"));
|
||||
assert.ok(markup.includes("Nuevo contacto"));
|
||||
assert.ok(markup.includes("Nueva persona"));
|
||||
assert.ok(markup.includes("Editar compañía"));
|
||||
assert.equal(markup.includes("<form"), false);
|
||||
});
|
||||
@ -102,3 +107,19 @@ test("el formulario de sede ofrece dirección multilínea optativa", async () =>
|
||||
assert.ok(textarea);
|
||||
assert.equal(textarea.includes("required"), false);
|
||||
});
|
||||
|
||||
test("el formulario de persona distingue contactos de cuentas de acceso y permite datos opcionales", async () => {
|
||||
const markup = await renderPage(
|
||||
"es",
|
||||
<PersonForm companyId={companyId} csrfToken="test" onSaved={() => undefined} />,
|
||||
() => undefined
|
||||
);
|
||||
assert.ok(markup.includes("Esta persona no tiene acceso a Metis Desk"));
|
||||
assert.ok(markup.includes("Contacto de la compañía"));
|
||||
for (const name of ["position", "email", "phone"]) {
|
||||
const field = markup.match(new RegExp(`<input[^>]*name="${name}"[^>]*>`))?.[0];
|
||||
assert.ok(field);
|
||||
assert.equal(field.includes("required"), false);
|
||||
}
|
||||
assert.equal(markup.includes('name="password"'), false);
|
||||
});
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -18,93 +10,10 @@ import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
import { CompanyForm } from "./company-form";
|
||||
import { ContactForm } from "./contact-form";
|
||||
import { DepartmentSection } from "./department-section";
|
||||
import { PeopleSection } from "./people-section";
|
||||
import { SiteForm } from "./site-form";
|
||||
|
||||
function ContactSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const selected = company.contacts.find((contact) => contact.id === selectedId);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("contacts")}</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("newContact")}</Button>
|
||||
</div>
|
||||
{company.contacts.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noContacts")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("position")}</TableHead>
|
||||
<TableHead>{t("email")}</TableHead>
|
||||
<TableHead>{t("phone")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{company.contacts.map((contact) => (
|
||||
<TableRow key={contact.id}>
|
||||
<TableCell>{contact.name}</TableCell>
|
||||
<TableCell>{contact.position}</TableCell>
|
||||
<TableCell>{contact.email}</TableCell>
|
||||
<TableCell>{contact.phone}</TableCell>
|
||||
<TableCell>{t(contact.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => setSelectedId(contact.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={setCreateOpen}
|
||||
open={createOpen}
|
||||
title={t("newContact")}
|
||||
>
|
||||
<ContactForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={() => setCreateOpen(false)}
|
||||
showTitle={false}
|
||||
/>
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedId(undefined);
|
||||
}
|
||||
}}
|
||||
open={selected !== undefined}
|
||||
title={t("editContact")}
|
||||
>
|
||||
{selected && (
|
||||
<ContactForm
|
||||
companyId={company.id}
|
||||
contact={selected}
|
||||
csrfToken={csrfToken}
|
||||
key={selected.id}
|
||||
onSaved={() => setSelectedId(undefined)}
|
||||
showTitle={false}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@ -188,7 +97,7 @@ export function CompanyDetail({
|
||||
const { t } = useTranslation("companies");
|
||||
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"contacts" | "sites">("contacts");
|
||||
const [tab, setTab] = useState<"people" | "departments" | "sites">("people");
|
||||
const company = query.data?.company;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -236,11 +145,18 @@ export function CompanyDetail({
|
||||
</FormDialog>
|
||||
<nav aria-label={t("companySections")} className="flex gap-2">
|
||||
<Button
|
||||
aria-pressed={tab === "contacts"}
|
||||
onClick={() => setTab("contacts")}
|
||||
variant={tab === "contacts" ? "secondary" : "outline"}
|
||||
aria-pressed={tab === "people"}
|
||||
onClick={() => setTab("people")}
|
||||
variant={tab === "people" ? "secondary" : "outline"}
|
||||
>
|
||||
{t("contacts")} ({company.contacts.length})
|
||||
{t("people")} ({company.people.length})
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={tab === "departments"}
|
||||
onClick={() => setTab("departments")}
|
||||
variant={tab === "departments" ? "secondary" : "outline"}
|
||||
>
|
||||
{t("departments")} ({company.departments.length})
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={tab === "sites"}
|
||||
@ -250,8 +166,10 @@ export function CompanyDetail({
|
||||
{t("sites")} ({company.sites.length})
|
||||
</Button>
|
||||
</nav>
|
||||
{tab === "contacts" ? (
|
||||
<ContactSection company={company} csrfToken={csrfToken} />
|
||||
{tab === "people" ? (
|
||||
<PeopleSection company={company} csrfToken={csrfToken} />
|
||||
) : tab === "departments" ? (
|
||||
<DepartmentSection company={company} csrfToken={csrfToken} />
|
||||
) : (
|
||||
<SiteSection company={company} csrfToken={csrfToken} />
|
||||
)}
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ContactDTO, ContactRequestDTO } from "../../common";
|
||||
import { ContactRequestSchema } from "../../common";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
|
||||
export function ContactForm({
|
||||
contact,
|
||||
companyId,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
showTitle = true,
|
||||
}: {
|
||||
contact?: ContactDTO;
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
onSaved?: () => void;
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const id = useId();
|
||||
const client = useQueryClient();
|
||||
const form = useForm<ContactRequestDTO>({
|
||||
resolver: zodResolver(ContactRequestSchema),
|
||||
defaultValues: {
|
||||
name: contact?.name ?? "",
|
||||
position: contact?.position ?? "",
|
||||
email: contact?.email ?? "",
|
||||
phone: contact?.phone ?? "",
|
||||
active: contact?.active ?? true,
|
||||
},
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ContactRequestDTO) =>
|
||||
companyApi.contact(companyId, contact?.id, values, csrfToken),
|
||||
onSuccess: (response) => {
|
||||
client.setQueryData(companyKeys.detail(companyId), response);
|
||||
if (!contact) {
|
||||
form.reset();
|
||||
}
|
||||
onSaved?.();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
{showTitle && (
|
||||
<h3 className="text-lg font-semibold">{t(contact ? "editContact" : "newContact")}</h3>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("position")}</Label>
|
||||
<Input id={`${id}-position`} maxLength={100} required {...form.register("position")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("email")}</Label>
|
||||
<Input
|
||||
id={`${id}-email`}
|
||||
maxLength={254}
|
||||
required
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-phone`}>{t("phone")}</Label>
|
||||
<Input
|
||||
id={`${id}-phone`}
|
||||
maxLength={50}
|
||||
minLength={3}
|
||||
required
|
||||
type="tel"
|
||||
{...form.register("phone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.watch("active")}
|
||||
id={`${id}-active`}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("active", checked === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-active`}>{t("activeContact")}</Label>
|
||||
</div>
|
||||
<CompanyFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
success={mutation.isSuccess}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
154
modules/companies/src/web/components/department-section.tsx
Normal file
154
modules/companies/src/web/components/department-section.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, DepartmentDTO, DepartmentRequestDTO } from "../../common";
|
||||
import { DepartmentRequestSchema } from "../../common";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
|
||||
function DepartmentForm({
|
||||
companyId,
|
||||
department,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
department?: DepartmentDTO | undefined;
|
||||
csrfToken: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const id = useId();
|
||||
const client = useQueryClient();
|
||||
const form = useForm<DepartmentRequestDTO>({
|
||||
resolver: zodResolver(DepartmentRequestSchema),
|
||||
defaultValues: { name: department?.name ?? "", active: department?.active ?? true },
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: DepartmentRequestDTO) =>
|
||||
companyApi.department(companyId, department?.id, values, csrfToken),
|
||||
onSuccess: (response) => {
|
||||
client.setQueryData(companyKeys.detail(companyId), response);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<Input id={id} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.watch("active")}
|
||||
id={`${id}-active`}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("active", checked === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-active`}>{t("activeDepartment")}</Label>
|
||||
</div>
|
||||
{department && (
|
||||
<p className="text-sm text-muted-foreground">{t("departmentDeactivationHelp")}</p>
|
||||
)}
|
||||
<CompanyFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
export function DepartmentSection({
|
||||
company,
|
||||
csrfToken,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string | undefined>();
|
||||
const selected = company.departments.find((item) => item.id === editor);
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("departments")}</h2>
|
||||
<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>
|
||||
</div>
|
||||
{company.departments.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("people")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{company.departments.map((department) => (
|
||||
<TableRow key={department.id}>
|
||||
<TableCell>{department.name}</TableCell>
|
||||
<TableCell>{t(department.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
company.assignments.filter(
|
||||
(item) => item.departmentId === department.id && item.endDate === null
|
||||
).length
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => setEditor(department.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("noDepartments")}</p>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open={editor !== undefined}
|
||||
title={t(selected ? "editDepartment" : "newDepartment")}
|
||||
>
|
||||
<DepartmentForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
department={selected}
|
||||
key={editor}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
327
modules/companies/src/web/components/people-section.tsx
Normal file
327
modules/companies/src/web/components/people-section.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, PersonDTO } from "../../common";
|
||||
|
||||
import { AssignmentForm } from "./assignment-form";
|
||||
import { PersonForm } from "./person-form";
|
||||
|
||||
function PersonDetails({
|
||||
company,
|
||||
person,
|
||||
csrfToken,
|
||||
onBack,
|
||||
onEdit,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
person: PersonDTO;
|
||||
csrfToken: string;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string | undefined>();
|
||||
const [historyId, setHistoryId] = useState<string | undefined>();
|
||||
const assignments = company.assignments.filter((item) => item.personId === person.id);
|
||||
const selected = assignments.find((item) => item.id === editor);
|
||||
const history = assignments.find((item) => item.id === historyId);
|
||||
const departmentName = (id: string) =>
|
||||
company.departments.find((item) => item.id === id)?.name ?? "—";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
{t("backToPeople")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{person.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t(person.active ? "activePerson" : "inactivePerson")}
|
||||
{person.isContact ? ` · ${t("isContact")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onEdit} variant="outline">
|
||||
{t("editPerson")}
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 pt-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("generalPosition")}</p>
|
||||
<p>{person.position ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("email")}</p>
|
||||
<p className="break-all">{person.email ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("phone")}</p>
|
||||
<p>{person.phone ?? "—"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
|
||||
{!person.active && assignments.some((item) => item.endDate === null) && (
|
||||
<p className="rounded-md border p-3" role="status">
|
||||
{t("inactiveMembershipWarning")}
|
||||
</p>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h3 className="text-lg font-semibold">{t("departmentAssignments")}</h3>
|
||||
<Button
|
||||
disabled={!(person.active && company.departments.some((item) => item.active))}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("addAssignment")}
|
||||
</Button>
|
||||
</div>
|
||||
{assignments.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("department")}</TableHead>
|
||||
<TableHead>{t("position")}</TableHead>
|
||||
<TableHead>{t("startDate")}</TableHead>
|
||||
<TableHead>{t("endDate")}</TableHead>
|
||||
<TableHead>{t("primary")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
{departmentName(item.departmentId)}
|
||||
{company.departments.find((department) => department.id === item.departmentId)
|
||||
?.active === false && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("inactiveDepartment")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.position ?? "—"}</TableCell>
|
||||
<TableCell>{item.startDate}</TableCell>
|
||||
<TableCell>{item.endDate ?? t("currentAssignment")}</TableCell>
|
||||
<TableCell>{item.isPrimary ? t("yes") : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button onClick={() => setHistoryId(item.id)} size="sm" variant="ghost">
|
||||
{t("history")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("noAssignments")}</p>
|
||||
)}
|
||||
{!company.departments.some((item) => item.active) && (
|
||||
<p className="text-sm text-muted-foreground">{t("createDepartmentFirst")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open={editor !== undefined}
|
||||
title={t(selected ? "editAssignment" : "addAssignment")}
|
||||
>
|
||||
<AssignmentForm
|
||||
assignment={selected}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
key={editor}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
personId={person.id}
|
||||
/>
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setHistoryId(undefined);
|
||||
}
|
||||
}}
|
||||
open={history !== undefined}
|
||||
title={t("assignmentHistory")}
|
||||
>
|
||||
{history && (
|
||||
<div className="space-y-4">
|
||||
<p>{departmentName(history.departmentId)}</p>
|
||||
{[...history.history].reverse().map((revision) => (
|
||||
<div className="space-y-1 rounded-md border p-3" key={revision.id}>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(revision.recordedAt).toLocaleString(i18n.language)}
|
||||
</p>
|
||||
<p>
|
||||
{t("position")}: {revision.position ?? "—"}
|
||||
</p>
|
||||
<p>
|
||||
{revision.startDate} → {revision.endDate ?? t("currentAssignment")}
|
||||
</p>
|
||||
<p>
|
||||
{t("primaryDepartment")}: {t(revision.isPrimary ? "yes" : "no")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const filterId = useId();
|
||||
const [selection, setSelection] = useState<
|
||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||
>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [contactsOnly, setContactsOnly] = useState(false);
|
||||
const selected = company.people.find((item) => item.id === selection?.id);
|
||||
if (selection?.mode === "new" || (selection?.mode === "edit" && selected)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
onClick={() => setSelection(selected ? { mode: "detail", id: selected.id } : undefined)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("backToPeople")}
|
||||
</Button>
|
||||
<h2 className="text-2xl font-semibold">{t(selected ? "editPerson" : "newPerson")}</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<PersonForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
key={selected?.id ?? "new"}
|
||||
onSaved={(id) => setSelection({ mode: "detail", id })}
|
||||
person={selected}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selected) {
|
||||
return (
|
||||
<PersonDetails
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
key={selected.id}
|
||||
onBack={() => setSelection(undefined)}
|
||||
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
|
||||
person={selected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const people = company.people.filter(
|
||||
(person) =>
|
||||
(!contactsOnly || person.isContact) &&
|
||||
person.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("people")}</h2>
|
||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={filterId}>{t("searchPerson")}</Label>
|
||||
<Input id={filterId} onChange={(event) => setSearch(event.target.value)} value={search} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={contactsOnly}
|
||||
id={`${filterId}-contact`}
|
||||
onCheckedChange={(checked) => setContactsOnly(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{people.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("position")}</TableHead>
|
||||
<TableHead>{t("departments")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{people.map((person) => (
|
||||
<TableRow key={person.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">{person.name}</span>
|
||||
{person.isContact && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("isContact")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{person.position ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{company.assignments
|
||||
.filter((item) => item.personId === person.id && item.endDate === null)
|
||||
.map(
|
||||
(item) =>
|
||||
(company.departments.find(
|
||||
(department) => department.id === item.departmentId
|
||||
)?.name ?? "—") + (item.isPrimary ? " ★" : "")
|
||||
)
|
||||
.join(", ") || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{t(person.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
onClick={() => setSelection({ mode: "detail", id: person.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("open")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t(search || contactsOnly ? "noResults" : "noPeople")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
modules/companies/src/web/components/person-form.tsx
Normal file
130
modules/companies/src/web/components/person-form.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { PersonDTO } from "../../common";
|
||||
import { PersonRequestSchema } from "../../common";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
|
||||
const PersonFormSchema = PersonRequestSchema.extend({
|
||||
position: z.string().max(100),
|
||||
email: z.union([z.email().max(254), z.literal("")]),
|
||||
phone: z.string().max(50),
|
||||
});
|
||||
type PersonFormValues = z.infer<typeof PersonFormSchema>;
|
||||
export function PersonForm({
|
||||
person,
|
||||
companyId,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
}: {
|
||||
person?: PersonDTO | undefined;
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const id = useId();
|
||||
const client = useQueryClient();
|
||||
const form = useForm<PersonFormValues>({
|
||||
resolver: zodResolver(PersonFormSchema),
|
||||
defaultValues: {
|
||||
name: person?.name ?? "",
|
||||
position: person?.position ?? "",
|
||||
email: person?.email ?? "",
|
||||
phone: person?.phone ?? "",
|
||||
active: person?.active ?? true,
|
||||
isContact: person?.isContact ?? false,
|
||||
},
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: PersonFormValues) =>
|
||||
companyApi.person(
|
||||
companyId,
|
||||
person?.id,
|
||||
{
|
||||
...values,
|
||||
position: values.position.trim() || null,
|
||||
email: values.email.trim() || null,
|
||||
phone: values.phone.trim() || null,
|
||||
},
|
||||
csrfToken
|
||||
),
|
||||
onSuccess: (response) => {
|
||||
client.setQueryData(companyKeys.detail(companyId), response);
|
||||
// El alta devuelve el agregado con la nueva persona añadida al final.
|
||||
const saved = person
|
||||
? response.company.people.find((item) => item.id === person.id)
|
||||
: response.company.people.at(-1);
|
||||
if (saved) {
|
||||
onSaved(saved.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="max-w-2xl space-y-6"
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("generalPosition")}</Label>
|
||||
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("emailOptional")}</Label>
|
||||
<Input id={`${id}-email`} maxLength={254} type="email" {...form.register("email")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
|
||||
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.watch("active")}
|
||||
id={`${id}-active`}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("active", checked === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-active`}>{t("activePerson")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.watch("isContact")}
|
||||
id={`${id}-contact`}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("isContact", checked === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-contact`}>{t("isContact")}</Label>
|
||||
</div>
|
||||
{person?.active && !form.watch("active") && (
|
||||
<p className="text-sm text-muted-foreground">{t("deactivationHelp")}</p>
|
||||
)}
|
||||
<CompanyFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,51 @@
|
||||
export const companiesSpanish = {
|
||||
people: "Personas",
|
||||
newPerson: "Nueva persona",
|
||||
editPerson: "Editar persona",
|
||||
backToPeople: "Volver a personas",
|
||||
generalPosition: "Puesto general (opcional)",
|
||||
emailOptional: "Email (opcional)",
|
||||
phoneOptional: "Teléfono (opcional)",
|
||||
activePerson: "Persona activa",
|
||||
inactivePerson: "Persona inactiva",
|
||||
isContact: "Contacto de la compañía",
|
||||
personHelp:
|
||||
"Esta persona no tiene acceso a Metis Desk. Sus cuentas informáticas se gestionarán por separado.",
|
||||
deactivationHelp:
|
||||
"Se conservarán sus asignaciones e historial. Revisa las pertenencias que continúen abiertas.",
|
||||
inactiveMembershipWarning:
|
||||
"Esta persona está inactiva y conserva asignaciones a departamentos pendientes de revisar.",
|
||||
contactsOnly: "Solo contactos",
|
||||
searchPerson: "Buscar persona",
|
||||
noPeople: "Todavía no hay personas en esta compañía.",
|
||||
departments: "Departamentos",
|
||||
department: "Departamento",
|
||||
newDepartment: "Nuevo departamento",
|
||||
editDepartment: "Editar departamento",
|
||||
activeDepartment: "Departamento activo",
|
||||
inactiveDepartment: "Departamento inactivo",
|
||||
noDepartments: "Todavía no hay departamentos.",
|
||||
departmentDeactivationHelp:
|
||||
"Al desactivarlo se conservan las asignaciones existentes, pero no se admiten nuevas.",
|
||||
departmentAssignments: "Asignaciones a departamentos",
|
||||
addAssignment: "Asignar departamento",
|
||||
editAssignment: "Editar asignación",
|
||||
noAssignments: "Esta persona todavía no tiene departamentos asignados.",
|
||||
chooseDepartment: "Selecciona un departamento",
|
||||
departmentPosition: "Puesto en el departamento (opcional)",
|
||||
startDate: "Fecha de inicio",
|
||||
endDate: "Fecha de fin (opcional)",
|
||||
primary: "Principal",
|
||||
primaryDepartment: "Departamento principal",
|
||||
currentAssignment: "Abierta",
|
||||
assignmentHelp:
|
||||
"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.",
|
||||
yes: "Sí",
|
||||
no: "No",
|
||||
|
||||
title: "Compañías",
|
||||
help: "Gestiona las empresas clientes, sus personas de contacto y sus sedes.",
|
||||
name: "Nombre",
|
||||
@ -42,6 +89,15 @@ export const companiesSpanish = {
|
||||
retry: "Reintentar",
|
||||
invalidForm: "Revisa los campos del formulario.",
|
||||
errors: {
|
||||
PERSON_NOT_FOUND: "La persona no pertenece a esta compañía o no está disponible.",
|
||||
DEPARTMENT_NOT_FOUND: "El departamento no pertenece a esta compañía o no está disponible.",
|
||||
DEPARTMENT_NAME_EXISTS: "Ya existe un departamento con ese nombre en esta compañía.",
|
||||
ASSIGNMENT_NOT_FOUND: "La asignación no pertenece a esta persona o no está disponible.",
|
||||
ASSIGNMENT_CONFLICT:
|
||||
"Las fechas se solapan con otra asignación o ya existe un departamento principal. Revisa las asignaciones abiertas.",
|
||||
INACTIVE_ASSIGNMENT_TARGET:
|
||||
"Activa la persona y el departamento antes de crear o reabrir una asignación.",
|
||||
|
||||
INVALID_REQUEST: "Revisa los datos enviados. El teléfono debe ser un número válido.",
|
||||
COMPANY_NOT_FOUND: "La compañía no está disponible.",
|
||||
CONTACT_NOT_FOUND: "El contacto no pertenece a esta compañía o no está disponible.",
|
||||
@ -52,6 +108,50 @@ export const companiesSpanish = {
|
||||
},
|
||||
};
|
||||
export const companiesEnglish = {
|
||||
people: "People",
|
||||
newPerson: "New person",
|
||||
editPerson: "Edit person",
|
||||
backToPeople: "Back to people",
|
||||
generalPosition: "General job title (optional)",
|
||||
emailOptional: "Email (optional)",
|
||||
phoneOptional: "Phone (optional)",
|
||||
activePerson: "Active person",
|
||||
inactivePerson: "Inactive person",
|
||||
isContact: "Company contact",
|
||||
personHelp: "This person cannot access Metis Desk. Their IT accounts will be managed separately.",
|
||||
deactivationHelp: "Assignments and history will be retained. Review any open memberships.",
|
||||
inactiveMembershipWarning:
|
||||
"This person is inactive and has department assignments that need review.",
|
||||
contactsOnly: "Contacts only",
|
||||
searchPerson: "Find person",
|
||||
noPeople: "No people in this company yet.",
|
||||
departments: "Departments",
|
||||
department: "Department",
|
||||
newDepartment: "New department",
|
||||
editDepartment: "Edit department",
|
||||
activeDepartment: "Active department",
|
||||
inactiveDepartment: "Inactive department",
|
||||
noDepartments: "No departments yet.",
|
||||
departmentDeactivationHelp: "Deactivation retains existing assignments but prevents new ones.",
|
||||
departmentAssignments: "Department assignments",
|
||||
addAssignment: "Assign department",
|
||||
editAssignment: "Edit assignment",
|
||||
noAssignments: "This person has no department assignments yet.",
|
||||
chooseDepartment: "Choose a department",
|
||||
departmentPosition: "Department job title (optional)",
|
||||
startDate: "Start date",
|
||||
endDate: "End date (optional)",
|
||||
primary: "Primary",
|
||||
primaryDepartment: "Primary department",
|
||||
currentAssignment: "Open",
|
||||
assignmentHelp:
|
||||
"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.",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
|
||||
title: "Companies",
|
||||
help: "Manage client companies, their contact people and their sites.",
|
||||
name: "Name",
|
||||
@ -95,6 +195,15 @@ export const companiesEnglish = {
|
||||
retry: "Try again",
|
||||
invalidForm: "Review the form fields.",
|
||||
errors: {
|
||||
PERSON_NOT_FOUND: "The person does not belong to this company or is unavailable.",
|
||||
DEPARTMENT_NOT_FOUND: "The department does not belong to this company or is unavailable.",
|
||||
DEPARTMENT_NAME_EXISTS: "A department with this name already exists in this company.",
|
||||
ASSIGNMENT_NOT_FOUND: "The assignment does not belong to this person or is unavailable.",
|
||||
ASSIGNMENT_CONFLICT:
|
||||
"Dates overlap with another assignment or a primary department already exists. Review current assignments.",
|
||||
INACTIVE_ASSIGNMENT_TARGET:
|
||||
"Activate the person and department before creating or reopening an assignment.",
|
||||
|
||||
INVALID_REQUEST: "Review the submitted data. Enter a valid phone number.",
|
||||
COMPANY_NOT_FOUND: "The company is not available.",
|
||||
CONTACT_NOT_FOUND: "The contact does not belong to this company or is not available.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user