Posibilidad de incluir la foto de una persona

This commit is contained in:
David Arranz 2026-10-09 23:00:34 +02:00
parent f945e9a996
commit d8fdad11e6
36 changed files with 248 additions and 57 deletions

View File

@ -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.

View File

@ -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),

View File

@ -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<boolean>; get(personId: string): Promise<PersonPhoto | null>; replace(companyId: string, personId: string, photo: PersonPhoto | null): Promise<boolean>; }
export interface IPersonPhotoProcessor { optimize(data: Uint8Array): Promise<Result<PersonPhoto, PersonPhotoError>>; }
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")); }
}

View File

@ -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); } }

View File

@ -14,6 +14,7 @@ export type PersonProps = Readonly<{
phone: Maybe<PhoneNumber>;
active: boolean;
isContact: boolean;
photoVersion?: string | null;
}>;
export function sameOptional<T extends { equals(other: T | undefined): boolean }>(
a: Maybe<T>,
@ -57,6 +58,9 @@ export class Person extends DomainEntity<PersonProps> {
get isContact() {
return this.props.isContact;
}
get photoVersion() {
return this.props.photoVersion ?? null;
}
update(values: Omit<PersonProps, "companyId">): Result<Person, CompanyError> {
if (
values.name.toPrimitive().length > 100 ||

View File

@ -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<RequestHandler>[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);
}

View File

@ -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(),

View File

@ -27,6 +27,7 @@ export class PersonModel extends Model<
declare phone: string | null;
declare isContact: boolean;
declare active: boolean;
declare photoVersion: CreationOptional<string | null>;
}
export class SiteModel extends Model<
InferAttributes<SiteModel>,
@ -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 }
);

View File

@ -6,6 +6,7 @@ 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");
if (!columns.photoVersion) await query.addColumn("company_contacts", "photoVersion", { type: DataTypes.STRING(64), allowNull: true });
for (const [name, length] of [
["position", 100],
["email", 254],

View File

@ -0,0 +1,8 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
export async function migratePersonPhotos(database: Sequelize): Promise<void> {
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");
}

View File

@ -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<InferAttributes<PersonPhotoModel>, InferCreationAttributes<PersonPhotoModel>> { 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<PersonPhoto | null> { 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; }); }
}

View File

@ -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<Result<PersonPhoto, PersonPhotoError>> {
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")); }
}
}

View File

@ -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<typeof registerCompanyRoutes>[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);

View File

@ -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<typeof PersonSchema>;
export const DepartmentFieldsSchema = z.strictObject({
name: z

View File

@ -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?",

View File

@ -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?",

View File

@ -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() });

View File

@ -33,7 +33,7 @@ export function AssignmentHistoryDialog({
{t("position")}: {revision.position ?? "—"}
</p>
<p>
{revision.startDate} → {revision.endDate ?? t("currentAssignment")}
{revision.startDate} → {revision.endDate ?? "—"}
</p>
<p>
{t("primaryDepartment")}: {t(revision.isPrimary ? "yes" : "no")}

View File

@ -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 (
<div className={layout.root}>
{!embedded && (
<Button onClick={onBack} variant="outline">
<button
className="app-link inline-flex cursor-pointer items-center gap-1 text-sm font-medium"
onClick={onBack}
type="button"
>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("back")}
</Button>
</button>
)}
{query.isPending && <p role="status">{t("loading")}</p>}
<CompanyFeedback error={query.error} />

View File

@ -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 (
<div className="space-y-6">
<Button onClick={onBack} variant="outline">
<button
className="app-link inline-flex cursor-pointer items-center gap-1 text-sm font-medium"
onClick={onBack}
type="button"
>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("backToPeople")}
</Button>
</button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">{person.name}</h2>
<div className="flex items-center gap-3"><PersonAvatar companyId={company.id} personId={person.id} photoVersion={person.photoVersion} name={person.name} className="size-12" /><h2 className="text-2xl font-semibold">{person.name}</h2></div>
<p className="mt-1 text-muted-foreground">
{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)}
<p>
<span className="font-medium">{departmentName(item.departmentId)}</span>
{company.departments.find((entry) => entry.id === item.departmentId)?.active ===
false && <p className="text-xs">{t("inactiveDepartment")}</p>}
</>
false && <span className="text-xs">{t("inactiveDepartment")}</span>}
</p>
),
},
{
@ -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",

View File

@ -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) => (
<>
<div className="flex items-center gap-3">
<PersonAvatar companyId={company.id} personId={person.id} photoVersion={person.photoVersion} name={person.name} />
<div>
<BrowserLink
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(person.id)}
>
{person.name}
</BrowserLink>
{person.position && (
<p className="text-xs text-muted-foreground">{person.position}</p>
)}
{person.isContact && (
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
)}
</>
</div>
</div>
),
},
{
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 ? (
<div className="space-y-2">
{assignments.map((item) => (
<div
className="border-b border-border/60 pb-2 last:border-0 last:pb-0"
key={item.id}
>
<p className="font-medium">
{company.departments.find(
(department) => department.id === item.departmentId
)?.name ?? "—"}
{item.isPrimary && (
<span className="ml-1 text-xs text-muted-foreground">★</span>
)}
</p>
<p className="text-xs text-muted-foreground">{item.position ?? "—"}</p>
</div>
))}
</div>
) : (
"—"
);
},
},
{
id: "state",

View File

@ -1 +1 @@
export { deletePerson, savePerson } from "./save-person.api";
export { deletePerson, savePerson, savePersonPhoto, removePersonPhoto } from "./save-person.api";

View File

@ -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 }> ;
}

View File

@ -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<File | null>(null);
const form = useForm<PersonFormValues>({
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 };
}

View File

@ -1 +1 @@
export { PersonEditorDialog, PersonForm } from "./ui";
export { PersonEditorDialog, PersonForm, PersonAvatar } from "./ui";

View File

@ -1,2 +1,4 @@
export { PersonEditorDialog } from "./person-editor-dialog";
export { PersonForm } from "./person-form";
export { PersonAvatar } from "./person-avatar";

View File

@ -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 <Avatar className={className}><AvatarImage src={src} alt="" /><AvatarFallback>{initials}</AvatarFallback></Avatar>;
}

View File

@ -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 (
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-photo`}>{t("photoOptional")}</Label>
<Input id={`${id}-photo`} accept="image/png,image/jpeg,image/webp" type="file" onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />

View File

@ -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 (
<div className="space-y-5">
{selected && (
<Button onClick={() => setSelected(undefined)} variant="outline">
<button
className="app-link inline-flex cursor-pointer items-center gap-1 text-sm font-medium"
onClick={() => setSelected(undefined)}
type="button"
>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("processes.back")}
</Button>
</button>
)}
{query.isPending && <p role="status">{t("loading")}</p>}
<ProcessFeedback error={query.error} />

View File

@ -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 (
<div className="space-y-4">
<Button onClick={onBack} variant="outline">
<button
className="app-link inline-flex cursor-pointer items-center gap-1 text-sm font-medium"
onClick={onBack}
type="button"
>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t(selection.fromEnvironment ? "environment.back" : "profiles.title")}
</Button>
</button>
{selection.kind === "environment" && (
<WorkEnvironmentPage
company={company}

View File

@ -3,12 +3,10 @@ import { Button } from "@repo/shadcn-ui/components/button";
import { EllipsisIcon } from "lucide-react";
import type { ReactNode } from "react";
import { entityListLabels, entityListPages } from "../lib/entity-list";
import { ENTITY_PAGE_SIZES, entityListLabels, entityListPages } from "../lib/entity-list";
import { SelectWithAction } from "./select-with-action";
const ENTITY_PAGE_SIZES = [10, 25, 50, 100];
export function EntityRowActions({ children, label }: { children: ReactNode; label: string }) {
return (
<Menu.Root>
@ -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);
}
}}

View File

@ -135,8 +135,9 @@ export function EntityTable<T extends { id: string }>({
locale={locale}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
table={table}
/>
/>
</div>
<EntityTableContent
columns={columns}
loading={loading}
@ -146,7 +147,8 @@ export function EntityTable<T extends { id: string }>({
table={table}
title={title}
unavailable={unavailable}
/>
/>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
<p className="text-sm text-muted-foreground">{info}</p>
<EntityListPagination

View File

@ -119,6 +119,6 @@ function entityColumnClass(pinned: boolean, header: boolean) {
: "sticky right-0 z-10 h-14 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))]";
}
return header
? "first:min-w-40 bg-card"
? "first:min-w-40 bg-card font-medium"
: "h-14 first:min-w-40 max-w-lg whitespace-normal break-words px-3 py-3";
}

View File

@ -11,10 +11,24 @@ import {
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { EntityColumn } from "../components/entity-list";
import { entityListRange } from "../lib/entity-list";
import { ENTITY_PAGE_SIZES, entityListRange } from "../lib/entity-list";
const ENTITY_PAGE_SIZE_STORAGE_KEY = "entity-page-size";
function storedPageSize(): number {
if (typeof window === "undefined") {
return ENTITY_PAGE_SIZES[0];
}
try {
const value = Number(window.localStorage.getItem(ENTITY_PAGE_SIZE_STORAGE_KEY));
return (ENTITY_PAGE_SIZES as readonly number[]).includes(value) ? value : ENTITY_PAGE_SIZES[0];
} catch {
return ENTITY_PAGE_SIZES[0];
}
}
export function useEntityTable<T extends { id: string }>({
items,
columns,
@ -32,7 +46,17 @@ export function useEntityTable<T extends { id: string }>({
const currentSearch = search ?? localSearch;
const [sorting, setSorting] = useState<SortingState>([]);
const [visibility, setVisibility] = useState<VisibilityState>({});
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 });
const [pagination, setPagination] = useState<PaginationState>(() => ({
pageIndex: 0,
pageSize: storedPageSize(),
}));
useEffect(() => {
try {
window.localStorage.setItem(ENTITY_PAGE_SIZE_STORAGE_KEY, String(pagination.pageSize));
} catch {
// La paginación sigue funcionando aunque el almacenamiento no esté disponible.
}
}, [pagination.pageSize]);
const resetKey = `${currentSearch}\0${filterKey}`;
const [previousKey, setPreviousKey] = useState(resetKey);
if (previousKey !== resetKey) {

View File

@ -1,3 +1,5 @@
export const ENTITY_PAGE_SIZES = [10, 25, 50, 100] as const;
import english from "../locales/en.json";
import spanish from "../locales/es.json";

View File

@ -5,6 +5,7 @@ export {
Building2Icon,
ChevronDownIcon,
ChevronRightIcon,
ChevronLeftIcon,
ClockIcon,
DatabaseIcon,
FileKeyIcon,