From d8fdad11e6efb60ecce95438a450b971b571acb6 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 23:00:34 +0200 Subject: [PATCH] Posibilidad de incluir la foto de una persona --- docs/componentes-de-interfaz.md | 2 + .../application/company-response.builder.ts | 1 + .../api/application/person-photo.use-cases.ts | 13 ++++ .../src/api/domain/person-photo-error.ts | 2 + .../companies/src/api/domain/person.entity.ts | 4 ++ .../http/person-photo.routes.ts | 14 +++++ .../persistence/company-persistence.mapper.ts | 4 +- .../persistence/company.model.ts | 2 + .../persistence/migrate-people.ts | 1 + .../persistence/person-photo-migration.ts | 8 +++ .../sequelize-person-photo.repository.ts | 11 ++++ .../infrastructure/person-photo-processor.ts | 18 ++++++ modules/companies/src/api/module.ts | 8 +++ .../src/common/dto/shared/people.dto.ts | 3 +- modules/companies/src/common/locales/en.json | 5 +- modules/companies/src/common/locales/es.json | 5 +- modules/companies/src/common/person-photo.ts | 4 ++ .../history/ui/assignment-history-dialog.tsx | 2 +- .../companies/detail/ui/company-detail.tsx | 10 +++- .../web/people/detail/ui/person-details.tsx | 25 +++++--- .../src/web/people/list/ui/people-section.tsx | 59 +++++++++++++------ .../src/web/people/shared/api/index.ts | 2 +- .../web/people/shared/api/save-person.api.ts | 11 ++++ .../controllers/use-person-form-controller.ts | 7 ++- .../companies/src/web/people/shared/index.ts | 2 +- .../src/web/people/shared/ui/index.ts | 2 + .../web/people/shared/ui/person-avatar.tsx | 6 ++ .../src/web/people/shared/ui/person-form.tsx | 6 +- .../web/processes/list/ui/processes-page.tsx | 10 +++- .../detail/ui/profile-configurations.tsx | 11 +++- .../src/components/entity-list-controls.tsx | 6 +- .../rdx-ui/src/components/entity-list.tsx | 6 +- .../src/components/entity-table-content.tsx | 2 +- packages/rdx-ui/src/hooks/use-entity-table.ts | 30 +++++++++- packages/rdx-ui/src/lib/entity-list.ts | 2 + packages/rdx-ui/src/lib/icons.ts | 1 + 36 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 modules/companies/src/api/application/person-photo.use-cases.ts create mode 100644 modules/companies/src/api/domain/person-photo-error.ts create mode 100644 modules/companies/src/api/infrastructure/http/person-photo.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/person-photo-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts create mode 100644 modules/companies/src/api/infrastructure/person-photo-processor.ts create mode 100644 modules/companies/src/common/person-photo.ts create mode 100644 modules/companies/src/web/people/shared/ui/person-avatar.tsx diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 9132a63..c646a62 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -317,3 +317,5 @@ Los enlaces de contenido usan `app-link`: color semántico `primary`, cursor de En el menú de secciones de una compañía, los elementos utilizan el color normal de texto y solo destacan su texto al pasar el puntero. Se mantienen el cursor de mano, el foco por teclado y el indicador de la sección actual. Esta excepción no afecta a enlaces dentro de listados, fichas o bloques del dashboard. El cuerpo desplazable `FormBody` reserva 4 px interiores en ambos laterales y al inicio para el anillo exterior de foco (3 px). No debe eliminarse ese espacio: `overflow-y-auto` también recorta horizontalmente y produciría un foco asimétrico en campos pegados al borde. Se conserva el scroll y las acciones fijas. + +Los listados comparten la preferencia entity-page-size en localStorage: el número de elementos por página elegido en un listado se restaura en todos los demás. Solo se aceptan los tamaños disponibles en el selector; si el almacenamiento no está disponible o contiene otro valor, se usa el tamaño inicial. diff --git a/modules/companies/src/api/application/company-response.builder.ts b/modules/companies/src/api/application/company-response.builder.ts index c4eb2d9..bb08bcc 100644 --- a/modules/companies/src/api/application/company-response.builder.ts +++ b/modules/companies/src/api/application/company-response.builder.ts @@ -27,6 +27,7 @@ export function buildCompanyResponse(company: Company): CompanyDTO { phone: person.phone.getOrUndefined()?.toPrimitive() ?? null, active: person.active, isContact: person.isContact, + photoVersion: person.photoVersion, })); return { ...buildCompanySummary(company), diff --git a/modules/companies/src/api/application/person-photo.use-cases.ts b/modules/companies/src/api/application/person-photo.use-cases.ts new file mode 100644 index 0000000..3b89ffc --- /dev/null +++ b/modules/companies/src/api/application/person-photo.use-cases.ts @@ -0,0 +1,13 @@ +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; +import { PERSON_PHOTO_LIMITS } from "../../common/person-photo"; +import { PersonPhotoError } from "../domain/person-photo-error"; +export type PersonPhoto = Readonly<{ data: Uint8Array; version: string }>; +export interface IPersonPhotoRepository { exists(companyId: string, personId: string): Promise; get(personId: string): Promise; replace(companyId: string, personId: string, photo: PersonPhoto | null): Promise; } +export interface IPersonPhotoProcessor { optimize(data: Uint8Array): Promise>; } +export class PersonPhotoUseCases { + constructor(private readonly repository: IPersonPhotoRepository, private readonly processor: IPersonPhotoProcessor) {} + async get(personId: string) { if (!z.uuid().safeParse(personId).success) return Result.fail(new PersonPhotoError("INVALID_REQUEST")); const photo = await this.repository.get(personId); return photo ? Result.ok(photo) : Result.fail(new PersonPhotoError("PHOTO_NOT_FOUND")); } + async save(companyId: string, personId: string, data: Uint8Array) { if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success) return Result.fail(new PersonPhotoError("INVALID_REQUEST")); if (data.length > PERSON_PHOTO_LIMITS.inputBytes) return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE")); if (!(await this.repository.exists(companyId, personId))) return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND")); const processed = await this.processor.optimize(data); if (!processed.isSuccess) return Result.fail(processed.error); return (await this.repository.replace(companyId, personId, processed.data)) ? Result.ok(processed.data.version) : Result.fail(new PersonPhotoError("PERSON_NOT_FOUND")); } + async remove(companyId: string, personId: string) { if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success) return Result.fail(new PersonPhotoError("INVALID_REQUEST")); return (await this.repository.replace(companyId, personId, null)) ? Result.ok(null) : Result.fail(new PersonPhotoError("PERSON_NOT_FOUND")); } +} diff --git a/modules/companies/src/api/domain/person-photo-error.ts b/modules/companies/src/api/domain/person-photo-error.ts new file mode 100644 index 0000000..baab8f3 --- /dev/null +++ b/modules/companies/src/api/domain/person-photo-error.ts @@ -0,0 +1,2 @@ +export type PersonPhotoErrorCode = "INVALID_REQUEST" | "PERSON_NOT_FOUND" | "PHOTO_NOT_FOUND" | "INVALID_PHOTO" | "PHOTO_TOO_LARGE"; +export class PersonPhotoError extends Error { constructor(public readonly code: PersonPhotoErrorCode) { super(code); } } diff --git a/modules/companies/src/api/domain/person.entity.ts b/modules/companies/src/api/domain/person.entity.ts index 4bf3049..8bf1498 100644 --- a/modules/companies/src/api/domain/person.entity.ts +++ b/modules/companies/src/api/domain/person.entity.ts @@ -14,6 +14,7 @@ export type PersonProps = Readonly<{ phone: Maybe; active: boolean; isContact: boolean; + photoVersion?: string | null; }>; export function sameOptional( a: Maybe, @@ -57,6 +58,9 @@ export class Person extends DomainEntity { get isContact() { return this.props.isContact; } + get photoVersion() { + return this.props.photoVersion ?? null; + } update(values: Omit): Result { if ( values.name.toPrimitive().length > 100 || diff --git a/modules/companies/src/api/infrastructure/http/person-photo.routes.ts b/modules/companies/src/api/infrastructure/http/person-photo.routes.ts new file mode 100644 index 0000000..0ac7d24 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/person-photo.routes.ts @@ -0,0 +1,14 @@ +import type { Application, RequestHandler } from "express"; +import { Router, raw } from "express"; +import { z } from "zod/v4"; +import { PERSON_PHOTO_LIMITS, PersonPhotoVersionSchema } from "../../../common/person-photo"; +import type { PersonPhotoUseCases } from "../../application/person-photo.use-cases"; +import type { PersonPhotoErrorCode } from "../../domain/person-photo-error"; +export function registerPersonPhotoRoutes(app: Application, useCases: PersonPhotoUseCases, authenticate: RequestHandler) { + const router = Router(); + router.get("/:companyId/people/:personId/photo", (req, res, next) => { const parsed = z.strictObject({ version: PersonPhotoVersionSchema }).safeParse(req.query); if (!parsed.success) { res.status(400).end(); return; } useCases.get(req.params.personId ?? "").then((result) => { if (!result.isSuccess || result.data.version !== parsed.data.version) { res.status(404).end(); return; } res.setHeader("Content-Type", "image/webp"); res.setHeader("Cache-Control", "private, max-age=86400, immutable"); res.setHeader("ETag", result.data.version); if (req.headers["if-none-match"] === result.data.version) { res.status(304).end(); return; } res.send(Buffer.from(result.data.data)); }).catch(next); }); + const send = (res: Parameters[1], failure: PersonPhotoErrorCode) => res.status(failure === "PHOTO_TOO_LARGE" ? 413 : failure.endsWith("NOT_FOUND") ? 404 : 400).json({ code: failure }); + router.post("/:companyId/people/:personId/photo", raw({ type: "application/octet-stream", limit: PERSON_PHOTO_LIMITS.inputBytes, inflate: false }), (req, res, next) => { if (!Buffer.isBuffer(req.body)) { send(res, "INVALID_PHOTO"); return; } useCases.save(req.params.companyId ?? "", req.params.personId ?? "", req.body).then((result) => result.isSuccess ? res.json({ photoVersion: result.data }) : send(res, result.error.code)).catch(next); }); + router.post("/:companyId/people/:personId/photo/remove", (req, res, next) => { if (!z.strictObject({}).safeParse(req.body).success) { send(res, "INVALID_REQUEST"); return; } useCases.remove(req.params.companyId ?? "", req.params.personId ?? "").then((result) => result.isSuccess ? res.json({ photoVersion: null }) : send(res, result.error.code)).catch(next); }); + app.use("/api/companies", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts index a286c99..c6cde4c 100644 --- a/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts @@ -45,7 +45,7 @@ export class CompanyPersistenceMapper { }; const people = record.people.map((row) => Person.rehydrate( - { ...persisted(this.input.person(row)), companyId: companyId(row.companyId) }, + { ...persisted(this.input.person(row)), companyId: companyId(row.companyId), photoVersion: row.photoVersion ?? null }, persisted(this.input.id(row.id)) ) ); @@ -133,6 +133,7 @@ export class CompanyPersistenceMapper { id: company.id.toPrimitive(), name: company.name.toPrimitive(), active: company.active, + logoVersion: company.logoVersion, }, people: company.people.map((person) => ({ id: person.id.toPrimitive(), @@ -143,6 +144,7 @@ export class CompanyPersistenceMapper { phone: person.phone.getOrUndefined()?.toPrimitive() ?? null, active: person.active, isContact: person.isContact, + photoVersion: person.photoVersion ?? null, })), sites: company.sites.map((site) => ({ id: site.id.toPrimitive(), diff --git a/modules/companies/src/api/infrastructure/persistence/company.model.ts b/modules/companies/src/api/infrastructure/persistence/company.model.ts index ebcd291..2d9a5c6 100644 --- a/modules/companies/src/api/infrastructure/persistence/company.model.ts +++ b/modules/companies/src/api/infrastructure/persistence/company.model.ts @@ -27,6 +27,7 @@ export class PersonModel extends Model< declare phone: string | null; declare isContact: boolean; declare active: boolean; + declare photoVersion: CreationOptional; } export class SiteModel extends Model< InferAttributes, @@ -60,6 +61,7 @@ export function initializeCompanyModels(database: Sequelize): void { phone: { type: DataTypes.STRING(50), allowNull: true }, isContact: { type: DataTypes.BOOLEAN, allowNull: false }, active: { type: DataTypes.BOOLEAN, allowNull: false }, + photoVersion: { type: DataTypes.STRING(64), allowNull: true }, }, { sequelize: database, tableName: "company_contacts", timestamps: false } ); diff --git a/modules/companies/src/api/infrastructure/persistence/migrate-people.ts b/modules/companies/src/api/infrastructure/persistence/migrate-people.ts index 08c3999..186dbc1 100644 --- a/modules/companies/src/api/infrastructure/persistence/migrate-people.ts +++ b/modules/companies/src/api/infrastructure/persistence/migrate-people.ts @@ -6,6 +6,7 @@ export async function migratePeople(database: Sequelize): Promise { const query = database.getQueryInterface(); // Evolución de contactos a personas: conservar tabla, UUID y relaciones existentes. const columns = await query.describeTable("company_contacts"); + if (!columns.photoVersion) await query.addColumn("company_contacts", "photoVersion", { type: DataTypes.STRING(64), allowNull: true }); for (const [name, length] of [ ["position", 100], ["email", 254], diff --git a/modules/companies/src/api/infrastructure/persistence/person-photo-migration.ts b/modules/companies/src/api/infrastructure/persistence/person-photo-migration.ts new file mode 100644 index 0000000..5c8e250 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/person-photo-migration.ts @@ -0,0 +1,8 @@ +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; +export async function migratePersonPhotos(database: Sequelize): Promise { + const rows = await database.query<{ name: string }>("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT }); + if (rows.some((row) => row.name === "person_photos")) return; + await database.getQueryInterface().createTable("person_photos", { personId: { type: DataTypes.UUID, primaryKey: true }, companyId: { type: DataTypes.UUID, allowNull: false }, data: { type: DataTypes.BLOB("medium"), allowNull: false }, version: { type: DataTypes.STRING(64), allowNull: false }, createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW } }); + await database.query("ALTER TABLE person_photos ADD CONSTRAINT person_photos_person_fk FOREIGN KEY (personId, companyId) REFERENCES company_contacts (id, companyId) ON DELETE CASCADE"); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts new file mode 100644 index 0000000..defad3d --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts @@ -0,0 +1,11 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model } from "sequelize"; +import type { IPersonPhotoRepository, PersonPhoto } from "../../application/person-photo.use-cases"; +import { PersonModel } from "./company.model"; +class PersonPhotoModel extends Model, InferCreationAttributes> { declare personId: string; declare companyId: string; declare data: Buffer; declare version: string; } +export class SequelizePersonPhotoRepository implements IPersonPhotoRepository { + constructor(private readonly database: Sequelize) { PersonPhotoModel.init({ personId: { type: DataTypes.UUID, primaryKey: true }, companyId: { type: DataTypes.UUID, allowNull: false }, data: { type: DataTypes.BLOB("medium"), allowNull: false }, version: { type: DataTypes.STRING(64), allowNull: false } }, { sequelize: database, tableName: "person_photos", timestamps: true }); } + async exists(companyId: string, personId: string) { return Boolean(await PersonModel.findOne({ where: { id: personId, companyId }, attributes: ["id"] })); } + async get(personId: string): Promise { const row = await PersonPhotoModel.findByPk(personId); return row ? { data: row.data, version: row.version } : null; } + async replace(companyId: string, personId: string, photo: PersonPhoto | null) { return this.database.transaction(async (transaction) => { const person = await PersonModel.findOne({ where: { id: personId, companyId }, transaction, lock: transaction.LOCK.UPDATE }); if (!person) return false; if (photo) await PersonPhotoModel.upsert({ personId, companyId, data: Buffer.from(photo.data), version: photo.version }, { transaction }); else await PersonPhotoModel.destroy({ where: { personId }, transaction }); await person.update({ photoVersion: photo?.version ?? null }, { transaction }); return true; }); } +} diff --git a/modules/companies/src/api/infrastructure/person-photo-processor.ts b/modules/companies/src/api/infrastructure/person-photo-processor.ts new file mode 100644 index 0000000..8ecbff4 --- /dev/null +++ b/modules/companies/src/api/infrastructure/person-photo-processor.ts @@ -0,0 +1,18 @@ +import { createHash } from "node:crypto"; +import sharp from "sharp"; +import { Result } from "@repo/rdx-utils"; +import { PERSON_PHOTO_LIMITS } from "../../common/person-photo"; +import type { IPersonPhotoProcessor, PersonPhoto } from "../application/person-photo.use-cases"; +import { PersonPhotoError } from "../domain/person-photo-error"; +export class PersonPhotoProcessor implements IPersonPhotoProcessor { + async optimize(data: Uint8Array): Promise> { + try { + const input = Buffer.from(data); + const metadata = await sharp(input, { failOn: "error", limitInputPixels: PERSON_PHOTO_LIMITS.pixels }).metadata(); + if (!metadata.width || !metadata.height || metadata.width > PERSON_PHOTO_LIMITS.inputDimension || metadata.height > PERSON_PHOTO_LIMITS.inputDimension || !["jpeg", "png", "webp"].includes(metadata.format ?? "")) return Result.fail(new PersonPhotoError("INVALID_PHOTO")); + const output = await sharp(input).rotate().resize(PERSON_PHOTO_LIMITS.outputDimension, PERSON_PHOTO_LIMITS.outputDimension, { fit: "cover", position: "centre" }).webp({ quality: 82, effort: 3 }).toBuffer(); + if (output.length > PERSON_PHOTO_LIMITS.outputBytes) return Result.fail(new PersonPhotoError("INVALID_PHOTO")); + return Result.ok({ data: output, version: createHash("sha256").update(output).digest("hex") }); + } catch { return Result.fail(new PersonPhotoError("INVALID_PHOTO")); } + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 207b1f9..76bcaf7 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -29,11 +29,13 @@ import { TicketUseCases } from "./application/ticket.use-cases"; import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.use-cases"; import { CompanyLogoProcessor } from "./infrastructure/company-logo-processor"; +import { PersonPhotoProcessor } from "./infrastructure/person-photo-processor"; import { registerAccessRoutes } from "./infrastructure/http/access.routes"; import { registerApplicationConfigurationRoutes } from "./infrastructure/http/application-configuration.routes"; import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes"; import { registerCompanyRoutes } from "./infrastructure/http/company.routes"; import { registerCompanyLogoRoutes } from "./infrastructure/http/company-logo.routes"; +import { registerPersonPhotoRoutes } from "./infrastructure/http/person-photo.routes"; import { registerDashboardRoutes } from "./infrastructure/http/dashboard.routes"; import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes"; import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes"; @@ -71,6 +73,9 @@ import { SequelizeApplicationConfigurationRepository } from "./infrastructure/pe import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository"; import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository"; import { SequelizeCompanyLogoRepository } from "./infrastructure/persistence/sequelize-company-logo.repository"; +import { SequelizePersonPhotoRepository } from "./infrastructure/persistence/sequelize-person-photo.repository"; +import { migratePersonPhotos } from "./infrastructure/persistence/person-photo-migration"; +import { PersonPhotoUseCases } from "./application/person-photo.use-cases"; import { SequelizeDashboardRepository } from "./infrastructure/persistence/sequelize-dashboard.repository"; import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository"; import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository"; @@ -95,6 +100,7 @@ export async function createCompanyModule( await migrateCompanies(database); await migrateCompanyLogos(database); await migratePeople(database); + await migratePersonPhotos(database); await migrateInterventionTemplates(database); const interventionTemplateRepository = new SequelizeInterventionTemplateRepository(database); const interventionTemplates = new InterventionTemplateUseCases(interventionTemplateRepository); @@ -114,6 +120,7 @@ export async function createCompanyModule( new SequelizeCompanyLogoRepository(database), new CompanyLogoProcessor() ); + const personPhotos = new PersonPhotoUseCases(new SequelizePersonPhotoRepository(database), new PersonPhotoProcessor()); return { initializeDashboard: (assets: IAssetDashboardReader, resources: IInventoryDashboardReader) => { const dashboard = new DashboardUseCases( @@ -202,6 +209,7 @@ export async function createCompanyModule( register: (app: Parameters[0]) => { registerInterventionTemplateRoutes(app, interventionTemplates, authenticate); registerCompanyLogoRoutes(app, logos, authenticate); + registerPersonPhotoRoutes(app, personPhotos, authenticate); registerCompanyRoutes(app, useCases, authenticate); registerTeleworkAuthorizationRoutes(app, telework, authenticate); registerAccessRoutes(app, access, authenticate, legacyResourceWrites); diff --git a/modules/companies/src/common/dto/shared/people.dto.ts b/modules/companies/src/common/dto/shared/people.dto.ts index f8ad8bb..7d58f09 100644 --- a/modules/companies/src/common/dto/shared/people.dto.ts +++ b/modules/companies/src/common/dto/shared/people.dto.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { PersonPhotoVersionSchema } from "../../person-photo"; const OptionalText = z.string().max(100).nullable(); export const PersonFieldsSchema = z.strictObject({ @@ -13,7 +14,7 @@ export const PersonFieldsSchema = z.strictObject({ active: z.boolean(), isContact: z.boolean(), }); -export const PersonSchema = PersonFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() }); +export const PersonSchema = PersonFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid(), photoVersion: PersonPhotoVersionSchema.nullable().optional() }); export type PersonDTO = z.infer; export const DepartmentFieldsSchema = z.strictObject({ name: z diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index f8e4242..7773226 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -828,7 +828,6 @@ "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", @@ -917,7 +916,9 @@ "deleteAssignment": "Delete assignment", "deletingAssignment": "Deleting…", "deleteAssignmentConfirm": "Delete the assignment to {{name}}?", - "deleteAssignmentError": "The assignment could not be deleted." + "deleteAssignmentError": "The assignment could not be deleted.", + "endDateColumn": "End date", + "photoOptional": "Photo (optional)" }, "access": { "helpTitle": "What is it and what is it for?", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 543bfe4..a836bf6 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -828,7 +828,6 @@ "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", @@ -917,7 +916,9 @@ "deleteAssignment": "Eliminar asignación", "deletingAssignment": "Eliminando…", "deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?", - "deleteAssignmentError": "No se puede eliminar la asignación." + "deleteAssignmentError": "No se puede eliminar la asignación.", + "endDateColumn": "Fecha de fin", + "photoOptional": "Foto (opcional)" }, "access": { "helpTitle": "¿Qué es y para qué sirve?", diff --git a/modules/companies/src/common/person-photo.ts b/modules/companies/src/common/person-photo.ts new file mode 100644 index 0000000..e19a412 --- /dev/null +++ b/modules/companies/src/common/person-photo.ts @@ -0,0 +1,4 @@ +import { z } from "zod/v4"; +export const PERSON_PHOTO_LIMITS = { inputBytes: 2 * 1024 * 1024, inputDimension: 4096, pixels: 4096 * 4096, outputDimension: 128, outputBytes: 64 * 1024 } as const; +export const PersonPhotoVersionSchema = z.string().regex(/^[a-f0-9]{64}$/); +export const PersonPhotoResponseSchema = z.strictObject({ photoVersion: PersonPhotoVersionSchema.nullable() }); diff --git a/modules/companies/src/web/assignments/history/ui/assignment-history-dialog.tsx b/modules/companies/src/web/assignments/history/ui/assignment-history-dialog.tsx index 116deaf..98bc68c 100644 --- a/modules/companies/src/web/assignments/history/ui/assignment-history-dialog.tsx +++ b/modules/companies/src/web/assignments/history/ui/assignment-history-dialog.tsx @@ -33,7 +33,7 @@ export function AssignmentHistoryDialog({ {t("position")}: {revision.position ?? "—"}

- {revision.startDate} → {revision.endDate ?? t("currentAssignment")} + {revision.startDate} → {revision.endDate ?? "—"}

{t("primaryDepartment")}: {t(revision.isPrimary ? "yes" : "no")} diff --git a/modules/companies/src/web/companies/detail/ui/company-detail.tsx b/modules/companies/src/web/companies/detail/ui/company-detail.tsx index 487593e..7635d20 100644 --- a/modules/companies/src/web/companies/detail/ui/company-detail.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-detail.tsx @@ -2,6 +2,7 @@ import { BrowserLink } from "@metis/core/client"; import { BoxIcon, BriefcaseIcon, + ChevronLeftIcon, DatabaseIcon, HomeIcon, KeyRoundIcon, @@ -56,9 +57,14 @@ export function CompanyDetail(props: CompanyDetailProps) { return (

{!embedded && ( - + )} {query.isPending &&

{t("loading")}

} diff --git a/modules/companies/src/web/people/detail/ui/person-details.tsx b/modules/companies/src/web/people/detail/ui/person-details.tsx index 48e12ed..7935e29 100644 --- a/modules/companies/src/web/people/detail/ui/person-details.tsx +++ b/modules/companies/src/web/people/detail/ui/person-details.tsx @@ -4,7 +4,9 @@ import { EntityRowActions, EntityTable, } from "@repo/rdx-ui/components/entity-list"; +import { ChevronLeftIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; +import { PersonAvatar } from "../../shared"; import { Card, CardContent } from "@repo/shadcn-ui/components/card"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -39,12 +41,17 @@ export function PersonDetails({ company.departments.find((item) => item.id === id)?.name ?? "—"; return (
- +
-

{person.name}

+

{person.name}

{t(person.active ? "activePerson" : "inactivePerson")} {person.isContact ? ` · ${t("isContact")}` : ""} @@ -117,11 +124,11 @@ export function PersonDetails({ value: (item) => departmentName(item.departmentId), hideable: false, render: (item) => ( - <> - {departmentName(item.departmentId)} +

+ {departmentName(item.departmentId)} {company.departments.find((entry) => entry.id === item.departmentId)?.active === - false &&

{t("inactiveDepartment")}

} - + false && {t("inactiveDepartment")}} +

), }, { @@ -138,9 +145,9 @@ export function PersonDetails({ }, { id: "end", - label: t("endDate"), + label: t("endDateColumn"), value: (item) => item.endDate, - render: (item) => <>{item.endDate ?? t("currentAssignment")}, + render: (item) => <>{item.endDate ?? "—"}, }, { id: "primary", diff --git a/modules/companies/src/web/people/list/ui/people-section.tsx b/modules/companies/src/web/people/list/ui/people-section.tsx index 80e6955..9198bd0 100644 --- a/modules/companies/src/web/people/list/ui/people-section.tsx +++ b/modules/companies/src/web/people/list/ui/people-section.tsx @@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; import { PersonDetails } from "../../detail"; -import { PersonEditorDialog } from "../../shared"; +import { PersonAvatar, PersonEditorDialog } from "../../shared"; export function PeopleSection({ company, @@ -102,40 +102,61 @@ export function PeopleSection({ value: (person) => person.name, hideable: false, render: (person) => ( - <> +
+ +
{person.name} + {person.position && ( +

{person.position}

+ )} {person.isContact && (

{t("isContact")}

)} - +
+
), }, { - id: "position", - label: t("position"), - value: (person) => person.position, - render: (person) => <>{person.position ?? "—"}, + id: "phone", + label: t("phone"), + value: (person) => person.phone, + render: (person) => <>{person.phone ?? "—"}, }, { id: "departments", label: t("departments"), - render: (person) => ( - <> - {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(", ") || "—"} - - ), + render: (person) => { + const assignments = company.assignments.filter( + (item) => item.personId === person.id && item.endDate === null + ); + return assignments.length ? ( +
+ {assignments.map((item) => ( +
+

+ {company.departments.find( + (department) => department.id === item.departmentId + )?.name ?? "—"} + {item.isPrimary && ( + ★ + )} +

+

{item.position ?? "—"}

+
+ ))} +
+ ) : ( + "—" + ); + }, }, { id: "state", diff --git a/modules/companies/src/web/people/shared/api/index.ts b/modules/companies/src/web/people/shared/api/index.ts index 0857bab..1faca66 100644 --- a/modules/companies/src/web/people/shared/api/index.ts +++ b/modules/companies/src/web/people/shared/api/index.ts @@ -1 +1 @@ -export { deletePerson, savePerson } from "./save-person.api"; +export { deletePerson, savePerson, savePersonPhoto, removePersonPhoto } from "./save-person.api"; diff --git a/modules/companies/src/web/people/shared/api/save-person.api.ts b/modules/companies/src/web/people/shared/api/save-person.api.ts index d22fd6f..96de33f 100644 --- a/modules/companies/src/web/people/shared/api/save-person.api.ts +++ b/modules/companies/src/web/people/shared/api/save-person.api.ts @@ -16,3 +16,14 @@ export const savePerson = ( export const deletePerson = (companyId: string, id: string, token: string) => companyRequest(`/${companyId}/people/${id}/delete`, CompanyResponseSchema, {}, token); + +export async function savePersonPhoto(companyId: string, personId: string, file: File, token: string) { + const response = await fetch(`/api/companies/${companyId}/people/${personId}/photo`, { method: "POST", headers: { "Content-Type": "application/octet-stream", "X-CSRF-Token": token }, body: file }); + if (!response.ok) throw new Error("PHOTO_UPLOAD_FAILED"); + return response.json() as Promise<{ photoVersion: string }> ; +} +export async function removePersonPhoto(companyId: string, personId: string, token: string) { + const response = await fetch(`/api/companies/${companyId}/people/${personId}/photo/remove`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": token }, body: "{}" }); + if (!response.ok) throw new Error("PHOTO_REMOVE_FAILED"); + return response.json() as Promise<{ photoVersion: null }> ; +} diff --git a/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts b/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts index eb6b5fe..b2576d4 100644 --- a/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts +++ b/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts @@ -1,13 +1,14 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; +import { useState } from "react"; import { accessKeys } from "../../../access/shared/queries"; import { companyKeys } from "../../../companies/shared/queries"; import { remoteKeys } from "../../../remote-connections/shared/api"; import { teleworkKeys } from "../../../telework-authorizations/shared/api"; import { buildPersonRequest } from "../adapters"; -import { savePerson } from "../api"; +import { savePerson, savePersonPhoto } from "../api"; import { PersonFormSchema } from "../schemas"; import type { PersonFormProps, PersonFormValues } from "../types"; @@ -18,6 +19,7 @@ export function usePersonFormController({ onSaved, }: PersonFormProps) { const client = useQueryClient(); + const [photoFile, setPhotoFile] = useState(null); const form = useForm({ resolver: zodResolver(PersonFormSchema), defaultValues: { @@ -44,10 +46,11 @@ export function usePersonFormController({ ? response.company.people.find((item) => item.id === person.id) : response.company.people.at(-1); if (saved) { + if (photoFile) await savePersonPhoto(companyId, saved.id, photoFile, csrfToken); onSaved(saved.id); } }, }); const submit = form.handleSubmit((values) => mutation.mutate(values)); - return { form, mutation, submit }; + return { form, mutation, submit, photoFile, setPhotoFile }; } diff --git a/modules/companies/src/web/people/shared/index.ts b/modules/companies/src/web/people/shared/index.ts index 8169197..10da975 100644 --- a/modules/companies/src/web/people/shared/index.ts +++ b/modules/companies/src/web/people/shared/index.ts @@ -1 +1 @@ -export { PersonEditorDialog, PersonForm } from "./ui"; +export { PersonEditorDialog, PersonForm, PersonAvatar } from "./ui"; diff --git a/modules/companies/src/web/people/shared/ui/index.ts b/modules/companies/src/web/people/shared/ui/index.ts index 478f8ff..c568f44 100644 --- a/modules/companies/src/web/people/shared/ui/index.ts +++ b/modules/companies/src/web/people/shared/ui/index.ts @@ -1,2 +1,4 @@ export { PersonEditorDialog } from "./person-editor-dialog"; export { PersonForm } from "./person-form"; + +export { PersonAvatar } from "./person-avatar"; diff --git a/modules/companies/src/web/people/shared/ui/person-avatar.tsx b/modules/companies/src/web/people/shared/ui/person-avatar.tsx new file mode 100644 index 0000000..e2d99c4 --- /dev/null +++ b/modules/companies/src/web/people/shared/ui/person-avatar.tsx @@ -0,0 +1,6 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar"; +export function PersonAvatar({ companyId, personId, photoVersion, name, className }: { companyId: string; personId: string; photoVersion?: string | null | undefined; name: string; className?: string }) { + const initials = name.split(" ").map((part) => part[0]).join("").slice(0,2).toUpperCase(); + const src = photoVersion ? `/api/companies/${companyId}/people/${personId}/photo?version=${photoVersion}` : undefined; + return {initials}; +} diff --git a/modules/companies/src/web/people/shared/ui/person-form.tsx b/modules/companies/src/web/people/shared/ui/person-form.tsx index af81bfe..c0e897d 100644 --- a/modules/companies/src/web/people/shared/ui/person-form.tsx +++ b/modules/companies/src/web/people/shared/ui/person-form.tsx @@ -13,13 +13,17 @@ import type { PersonFormProps } from "../types"; export function PersonForm(props: PersonFormProps) { const { person, requireActive = false } = props; - const { form, mutation, submit } = usePersonFormController(props); + const { form, mutation, submit, setPhotoFile } = usePersonFormController(props); const { t } = useTranslation("companies"); const id = useId(); return (
+
+ + setPhotoFile(event.target.files?.[0] ?? null)} /> +
diff --git a/modules/companies/src/web/processes/list/ui/processes-page.tsx b/modules/companies/src/web/processes/list/ui/processes-page.tsx index cac5602..a5b36c4 100644 --- a/modules/companies/src/web/processes/list/ui/processes-page.tsx +++ b/modules/companies/src/web/processes/list/ui/processes-page.tsx @@ -6,6 +6,7 @@ import { EntityStatus, EntityTable, } from "@repo/rdx-ui/components/entity-list"; +import { ChevronLeftIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; @@ -39,9 +40,14 @@ export function ProcessesPage(props: ProcessesPageProps) { return (
{selected && ( - + )} {query.isPending &&

{t("loading")}

} diff --git a/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx b/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx index 1ada6f1..6843fa5 100644 --- a/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx +++ b/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx @@ -1,4 +1,4 @@ -import { Button } from "@repo/shadcn-ui/components/button"; +import { ChevronLeftIcon } from "@repo/rdx-ui/lib/icons"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; @@ -32,9 +32,14 @@ export function ProfileConfigurations({ const props = { company, csrfToken, initialProfileId: selection.profileId }; return (
- + {selection.kind === "environment" && ( @@ -100,7 +98,7 @@ export function EntityListPagination({ label={labels.pageSize} onValueChange={(value) => { const size = Number(value); - if (ENTITY_PAGE_SIZES.includes(size)) { + if ((ENTITY_PAGE_SIZES as readonly number[]).includes(size)) { onPageSize(size); } }} diff --git a/packages/rdx-ui/src/components/entity-list.tsx b/packages/rdx-ui/src/components/entity-list.tsx index 340bbc7..312036b 100644 --- a/packages/rdx-ui/src/components/entity-list.tsx +++ b/packages/rdx-ui/src/components/entity-list.tsx @@ -135,8 +135,9 @@ export function EntityTable({ locale={locale} onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))} table={table} - /> + />
+ ({ table={table} title={title} unavailable={unavailable} - /> + /> +