Posibilidad de incluir la foto de una persona
This commit is contained in:
parent
f945e9a996
commit
d8fdad11e6
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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")); }
|
||||
}
|
||||
2
modules/companies/src/api/domain/person-photo-error.ts
Normal file
2
modules/companies/src/api/domain/person-photo-error.ts
Normal 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); } }
|
||||
@ -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 ||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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");
|
||||
}
|
||||
@ -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; }); }
|
||||
}
|
||||
@ -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")); }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?",
|
||||
|
||||
@ -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?",
|
||||
|
||||
4
modules/companies/src/common/person-photo.ts
Normal file
4
modules/companies/src/common/person-photo.ts
Normal 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() });
|
||||
@ -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")}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1 +1 @@
|
||||
export { deletePerson, savePerson } from "./save-person.api";
|
||||
export { deletePerson, savePerson, savePersonPhoto, removePersonPhoto } from "./save-person.api";
|
||||
|
||||
@ -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 }> ;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { PersonEditorDialog, PersonForm } from "./ui";
|
||||
export { PersonEditorDialog, PersonForm, PersonAvatar } from "./ui";
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
export { PersonEditorDialog } from "./person-editor-dialog";
|
||||
export { PersonForm } from "./person-form";
|
||||
|
||||
export { PersonAvatar } from "./person-avatar";
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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")} />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ export {
|
||||
Building2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
FileKeyIcon,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user