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 (
-
+
+
{t("backToPeople")}
-
+
-
{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 (