From 8f6d2882f8d0100be8fe7eb19e84aa24d447a6b0 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 23:57:03 +0200 Subject: [PATCH 01/11] feat(teams): add Team model to Prisma schema --- prisma/schema.prisma | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70d0038..2453021 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,6 +130,9 @@ model Person { email String? phone String? + teamId String? @db.Uuid + team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull, onUpdate: Cascade) + userId String? @unique @db.Uuid user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) @@ -141,9 +144,20 @@ model Person { @@index([lastName, firstName]) @@index([department, deletedAt]) + @@index([teamId, deletedAt]) + @@index([teamId]) @@index([deletedAt]) } +model Team { + id String @id @default(uuid(7)) @db.Uuid + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + people Person[] +} + // ====================================================== // CATALOG // ====================================================== -- 2.52.0 From 65f9a6d6a48f9ae3f5abb1ac066ba1637ec49277 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 23:58:08 +0200 Subject: [PATCH 02/11] feat(teams): add migration for Team table and Person.teamId --- .../migration.sql | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 prisma/migrations/20260625215731_add_team_model/migration.sql diff --git a/prisma/migrations/20260625215731_add_team_model/migration.sql b/prisma/migrations/20260625215731_add_team_model/migration.sql new file mode 100644 index 0000000..bc07a8a --- /dev/null +++ b/prisma/migrations/20260625215731_add_team_model/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "Team" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "team_name_lower_unique" ON "Team" (lower("name")); + +-- AlterTable +ALTER TABLE "Person" ADD COLUMN "teamId" UUID; + +-- CreateIndex +CREATE INDEX "Person_teamId_deletedAt_idx" ON "Person"("teamId", "deletedAt"); + +-- CreateIndex +CREATE INDEX "Person_teamId_idx" ON "Person"("teamId"); + +-- AddForeignKey +ALTER TABLE "Person" ADD CONSTRAINT "Person_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- 2.52.0 From f88d831f4cadfe32aa1c301451e36821e0583ce4 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 23:58:59 +0200 Subject: [PATCH 03/11] feat(teams): add team Zod schema and unit tests --- src/schemas/team.schema.ts | 35 +++++++++++ tests/unit/schemas/team.schema.test.ts | 80 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/schemas/team.schema.ts create mode 100644 tests/unit/schemas/team.schema.test.ts diff --git a/src/schemas/team.schema.ts b/src/schemas/team.schema.ts new file mode 100644 index 0000000..d246ae8 --- /dev/null +++ b/src/schemas/team.schema.ts @@ -0,0 +1,35 @@ +import { z } from "zod" + +import type { Dictionary } from "@/i18n/dictionaries" + +export type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"] + +const defaultTeamSchemaCopy: TeamSchemaCopy = { + nameRequired: "Name is required", + nameMaxLength: "Name must be at most 80 characters", + idRequired: "ID is required", +} + +export function buildCreateTeamSchema(copy: TeamSchemaCopy) { + return z.object({ + name: z + .string() + .trim() + .min(1, { error: copy.nameRequired }) + .max(80, { error: copy.nameMaxLength }), + }) +} + +export function buildUpdateTeamSchema(copy: TeamSchemaCopy) { + return buildCreateTeamSchema(copy).extend({ + id: z.string().nonempty(copy.idRequired), + }) +} + +export const createTeamSchema = buildCreateTeamSchema(defaultTeamSchemaCopy) + +export type CreateTeamFormType = z.infer + +export const updateTeamSchema = buildUpdateTeamSchema(defaultTeamSchemaCopy) + +export type UpdateTeamFormType = z.infer diff --git a/tests/unit/schemas/team.schema.test.ts b/tests/unit/schemas/team.schema.test.ts new file mode 100644 index 0000000..b733700 --- /dev/null +++ b/tests/unit/schemas/team.schema.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest" + +import { + buildCreateTeamSchema, + buildUpdateTeamSchema, +} from "@/schemas/team.schema" + +const schemaCopy = { + nameRequired: "El nombre del equipo es obligatorio", + nameMaxLength: "El nombre del equipo no puede superar los 80 caracteres", + idRequired: "El ID es obligatorio", +} + +describe("team schema", () => { + it("rejects blank names", () => { + const result = buildCreateTeamSchema(schemaCopy).safeParse({ name: "" }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.name).toContain( + schemaCopy.nameRequired, + ) + } + }) + + it("rejects whitespace-only names", () => { + const result = buildCreateTeamSchema(schemaCopy).safeParse({ name: " " }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.name).toContain( + schemaCopy.nameRequired, + ) + } + }) + + it("rejects names longer than 80 characters", () => { + const result = buildCreateTeamSchema(schemaCopy).safeParse({ + name: "a".repeat(81), + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.name).toContain( + schemaCopy.nameMaxLength, + ) + } + }) + + it("accepts valid create input", () => { + const result = buildCreateTeamSchema(schemaCopy).safeParse({ + name: "Engineering", + }) + + expect(result.success).toBe(true) + }) + + it("rejects update with empty id", () => { + const result = buildUpdateTeamSchema(schemaCopy).safeParse({ + id: "", + name: "Engineering", + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.id).toContain( + schemaCopy.idRequired, + ) + } + }) + + it("accepts valid update input", () => { + const result = buildUpdateTeamSchema(schemaCopy).safeParse({ + id: "some-id", + name: "Engineering", + }) + + expect(result.success).toBe(true) + }) +}) -- 2.52.0 From 5d303bdb7e6a216bc2924ee11377b663364cb34e Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:03:01 +0200 Subject: [PATCH 04/11] feat(teams): add team service and use-cases with integration tests --- src/services/team.service.ts | 57 ++++++ src/types/index.ts | 1 + src/types/team.ts | 5 + src/use-cases/team.use-cases.ts | 123 ++++++++++++ tests/integration/helpers/factories.ts | 13 ++ tests/integration/helpers/test-db.ts | 1 + .../use-cases/team.use-cases.test.ts | 175 ++++++++++++++++++ 7 files changed, 375 insertions(+) create mode 100644 src/services/team.service.ts create mode 100644 src/types/team.ts create mode 100644 src/use-cases/team.use-cases.ts create mode 100644 tests/integration/use-cases/team.use-cases.test.ts diff --git a/src/services/team.service.ts b/src/services/team.service.ts new file mode 100644 index 0000000..1705154 --- /dev/null +++ b/src/services/team.service.ts @@ -0,0 +1,57 @@ +import type { Prisma } from "@/generated/prisma/client" +import prisma from "@/lib/prisma" +import type { Team, TeamSummary } from "@/types" + +export const TeamService = { + findAll: async ( + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.findMany({ + select: { + id: true, + name: true, + }, + orderBy: { name: "asc" }, + }) + }, + + findById: async ( + id: string, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.findUnique({ where: { id } }) + }, + + findByNameCaseInsensitive: async ( + name: string, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.findFirst({ + where: { + name: { equals: name.trim(), mode: "insensitive" }, + }, + }) + }, + + create: async ( + data: Prisma.TeamCreateInput, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.create({ data }) + }, + + update: async ( + id: string, + data: Prisma.TeamUpdateInput, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.update({ where: { id }, data }) + }, + + delete: async ( + id: string, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + return db.team.delete({ where: { id } }) + }, +} diff --git a/src/types/index.ts b/src/types/index.ts index 805b46c..fad6ccd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,4 +6,5 @@ export * from "./item" export * from "./movement" export * from "./paginate" export * from "./person" +export * from "./team" export * from "./user" diff --git a/src/types/team.ts b/src/types/team.ts new file mode 100644 index 0000000..d7f4fba --- /dev/null +++ b/src/types/team.ts @@ -0,0 +1,5 @@ +import type { Team as PrismaTeam } from "@/generated/prisma/client" + +export type Team = PrismaTeam + +export type TeamSummary = Pick diff --git a/src/use-cases/team.use-cases.ts b/src/use-cases/team.use-cases.ts new file mode 100644 index 0000000..2241ac0 --- /dev/null +++ b/src/use-cases/team.use-cases.ts @@ -0,0 +1,123 @@ +import { Prisma } from "@/generated/prisma/client" +import prisma from "@/lib/prisma" +import type { + CreateTeamFormType, + UpdateTeamFormType, +} from "@/schemas/team.schema" +import { TeamService } from "@/services/team.service" + +type FieldErrors = Record + +type TeamUseCaseResult = + | { + success: true + } + | { + success: false + errors: FieldErrors + } + +function teamError(errors: FieldErrors): TeamUseCaseResult { + return { + success: false, + errors, + } +} + +function isUniqueConstraintError(error: unknown) { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) +} + +export async function listTeamsUseCase() { + return TeamService.findAll() +} + +export async function createTeamUseCase( + input: CreateTeamFormType, +): Promise { + const name = input.name.trim() + + try { + return await prisma.$transaction(async (tx) => { + const existingTeam = await TeamService.findByNameCaseInsensitive(name, tx) + + if (existingTeam) { + return teamError({ name: ["Team already exists"] }) + } + + await TeamService.create({ name }, tx) + + return { success: true } + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + return teamError({ name: ["Team already exists"] }) + } + + throw error + } +} + +export async function updateTeamUseCase( + input: UpdateTeamFormType, +): Promise { + const { id } = input + const name = input.name.trim() + + try { + return await prisma.$transaction(async (tx) => { + const existingTeam = await TeamService.findById(id, tx) + + if (!existingTeam) { + return teamError({ id: ["Team not found"] }) + } + + if (existingTeam.name.toLowerCase() === name.toLowerCase()) { + return teamError({ name: ["Team name is the same"] }) + } + + const teamWithName = await TeamService.findByNameCaseInsensitive(name, tx) + + if (teamWithName) { + return teamError({ name: ["Team already exists"] }) + } + + await TeamService.update(id, { name }, tx) + + return { success: true } + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + return teamError({ name: ["Team already exists"] }) + } + + throw error + } +} + +export async function deleteTeamUseCase( + id: string, +): Promise { + try { + return await prisma.$transaction(async (tx) => { + const team = await TeamService.findById(id, tx) + + if (!team) { + return teamError({ id: ["Team not found"] }) + } + + await TeamService.delete(id, tx) + + return { success: true } + }) + } catch (error) { + if (isUniqueConstraintError(error)) { + return teamError({ name: ["Team already exists"] }) + } + + throw error + } +} diff --git a/tests/integration/helpers/factories.ts b/tests/integration/helpers/factories.ts index 07068c7..e05ca0e 100644 --- a/tests/integration/helpers/factories.ts +++ b/tests/integration/helpers/factories.ts @@ -77,6 +77,19 @@ export async function createTestPerson( }) } +export async function createTestTeam( + prisma: PrismaClient, + overrides: Partial<{ name: string }> = {}, +) { + const suffix = nextSuffix() + + return prisma.team.create({ + data: { + name: overrides.name ?? `Test Team ${suffix}`, + }, + }) +} + export async function createTestItem( prisma: PrismaClient, overrides: Partial<{ diff --git a/tests/integration/helpers/test-db.ts b/tests/integration/helpers/test-db.ts index 3593111..d722a94 100644 --- a/tests/integration/helpers/test-db.ts +++ b/tests/integration/helpers/test-db.ts @@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [ "Asset", "Item", "Category", + "Team", "Person", "User", ] diff --git a/tests/integration/use-cases/team.use-cases.test.ts b/tests/integration/use-cases/team.use-cases.test.ts new file mode 100644 index 0000000..76022ba --- /dev/null +++ b/tests/integration/use-cases/team.use-cases.test.ts @@ -0,0 +1,175 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import type { PrismaClient } from "@/generated/prisma/client" +import { createTestPerson, createTestTeam } from "../helpers/factories" +import { + resetIntegrationTestDatabase, + startIntegrationTestDatabase, + stopIntegrationTestDatabase, +} from "../helpers/test-db" + +let prisma: PrismaClient +let createTeamUseCase: typeof import("@/use-cases/team.use-cases").createTeamUseCase +let updateTeamUseCase: typeof import("@/use-cases/team.use-cases").updateTeamUseCase +let deleteTeamUseCase: typeof import("@/use-cases/team.use-cases").deleteTeamUseCase +let listTeamsUseCase: typeof import("@/use-cases/team.use-cases").listTeamsUseCase +let TeamService: typeof import("@/services/team.service").TeamService + +beforeAll(async () => { + await startIntegrationTestDatabase() + + const prismaModule = await import("@/lib/prisma") + const teamUseCases = await import("@/use-cases/team.use-cases") + const teamService = await import("@/services/team.service") + + prisma = prismaModule.prisma + createTeamUseCase = teamUseCases.createTeamUseCase + updateTeamUseCase = teamUseCases.updateTeamUseCase + deleteTeamUseCase = teamUseCases.deleteTeamUseCase + listTeamsUseCase = teamUseCases.listTeamsUseCase + TeamService = teamService.TeamService +}) + +beforeEach(async () => { + await resetIntegrationTestDatabase(prisma) +}) + +afterAll(async () => { + await prisma?.$disconnect() + await stopIntegrationTestDatabase() +}) + +describe("team use-cases", () => { + it("creates a team and rejects duplicate names", async () => { + expect(await createTeamUseCase({ name: "Engineering" })).toEqual({ + success: true, + }) + + expect( + await prisma.team.findFirst({ where: { name: "Engineering" } }), + ).toMatchObject({ name: "Engineering" }) + + expect(await createTeamUseCase({ name: "Engineering" })).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect(await prisma.team.count()).toBe(1) + }) + + it("rejects duplicate names case-insensitively and trims whitespace", async () => { + await createTestTeam(prisma, { name: "Engineering" }) + + expect(await createTeamUseCase({ name: "engineering" })).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect(await createTeamUseCase({ name: " ENGINEERING " })).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect(await prisma.team.count()).toBe(1) + }) + + it("trims the name before saving", async () => { + await createTeamUseCase({ name: " Engineering " }) + + const team = await prisma.team.findFirst({ + where: { name: "Engineering" }, + }) + + expect(team).not.toBeNull() + expect(team?.name).toBe("Engineering") + }) + + it("updates a team and rejects unchanged or duplicate names", async () => { + const team = await createTestTeam(prisma, { name: "Peripherals" }) + const other = await createTestTeam(prisma, { name: "Networking" }) + + expect( + await updateTeamUseCase({ id: team.id, name: "Accessories" }), + ).toEqual({ success: true }) + + expect( + await prisma.team.findUniqueOrThrow({ where: { id: team.id } }), + ).toMatchObject({ name: "Accessories" }) + + expect( + await updateTeamUseCase({ id: team.id, name: "Accessories" }), + ).toEqual({ + success: false, + errors: { name: ["Team name is the same"] }, + }) + + expect( + await updateTeamUseCase({ id: team.id, name: other.name }), + ).toEqual({ + success: false, + errors: { name: ["Team already exists"] }, + }) + + expect( + await prisma.team.findUniqueOrThrow({ where: { id: team.id } }), + ).toMatchObject({ name: "Accessories" }) + }) + + it("returns not found when updating a missing team", async () => { + expect( + await updateTeamUseCase({ + id: "00000000-0000-0000-0000-000000000000", + name: "Ghost", + }), + ).toEqual({ + success: false, + errors: { id: ["Team not found"] }, + }) + }) + + it("hard deletes a team", async () => { + const team = await createTestTeam(prisma, { name: "Legacy" }) + + expect(await deleteTeamUseCase(team.id)).toEqual({ success: true }) + + expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull() + }) + + it("returns not found when deleting a missing team", async () => { + expect( + await deleteTeamUseCase("00000000-0000-0000-0000-000000000000"), + ).toEqual({ + success: false, + errors: { id: ["Team not found"] }, + }) + }) + + it("nulls linked Person.teamId when a team is deleted", async () => { + const team = await createTestTeam(prisma, { name: "Assigned" }) + const person = await createTestPerson(prisma) + + await prisma.person.update({ + where: { id: person.id }, + data: { teamId: team.id }, + }) + + expect(await deleteTeamUseCase(team.id)).toEqual({ success: true }) + + const updatedPerson = await prisma.person.findUnique({ + where: { id: person.id }, + }) + + expect(updatedPerson).not.toBeNull() + expect(updatedPerson?.teamId).toBeNull() + }) + + it("lists all teams ordered by name", async () => { + await createTestTeam(prisma, { name: "Beta" }) + await createTestTeam(prisma, { name: "Alpha" }) + await createTestTeam(prisma, { name: "Gamma" }) + + const teams = await listTeamsUseCase() + + expect(teams).toHaveLength(3) + expect(teams.map((team) => team.name)).toEqual(["Alpha", "Beta", "Gamma"]) + }) +}) -- 2.52.0 From 7f607be01b0ad4c222e5259c801cd571a4e5ef66 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:04:31 +0200 Subject: [PATCH 05/11] feat(teams): add team i18n keys --- src/i18n/dictionaries/en.ts | 38 +++++++++++++++++++++++++++++++++++++ src/i18n/dictionaries/es.ts | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index c872c36..f430352 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -384,6 +384,44 @@ export const en = { value: "{remaining} of {total}", }, }, + teams: { + list: { + title: "Teams", + addLabel: "Add Team", + empty: "No teams found.", + columns: { + name: "Name", + actions: "Actions", + }, + actions: { + edit: "Edit team", + delete: "Delete team", + }, + }, + form: { + nameLabel: "Team name", + namePlaceholder: "Team name", + createSubmit: "Create Team", + updateSubmit: "Update Team", + cancel: "Cancel", + }, + actions: { + createSuccess: "Team created successfully", + createFailure: "Failed to create team", + updateSuccess: "Team updated successfully", + updateFailure: "Failed to update team", + deleteSuccess: "Team deleted successfully", + deleteFailure: "Failed to delete team", + duplicateName: "Team already exists", + unchangedName: "Team name unchanged", + notFound: "Team not found", + }, + schema: { + nameRequired: "Team name is required", + nameMaxLength: "Team name must be at most 80 characters", + idRequired: "Team ID is required", + }, + }, people: { list: { title: "People", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index d6173b0..f0192c5 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -389,6 +389,44 @@ export const es = { value: "{remaining} de {total}", }, }, + teams: { + list: { + title: "Equipos", + addLabel: "Agregar equipo", + empty: "No se encontraron equipos.", + columns: { + name: "Nombre", + actions: "Acciones", + }, + actions: { + edit: "Editar equipo", + delete: "Eliminar equipo", + }, + }, + form: { + nameLabel: "Nombre del equipo", + namePlaceholder: "Nombre del equipo", + createSubmit: "Crear equipo", + updateSubmit: "Actualizar equipo", + cancel: "Cancelar", + }, + actions: { + createSuccess: "Equipo creado correctamente", + createFailure: "Error al crear el equipo", + updateSuccess: "Equipo actualizado correctamente", + updateFailure: "Error al actualizar el equipo", + deleteSuccess: "Equipo eliminado correctamente", + deleteFailure: "Error al eliminar el equipo", + duplicateName: "El equipo ya existe", + unchangedName: "El nombre del equipo no cambió", + notFound: "Equipo no encontrado", + }, + schema: { + nameRequired: "El nombre del equipo es obligatorio", + nameMaxLength: "El nombre del equipo no puede superar los 80 caracteres", + idRequired: "El ID del equipo es obligatorio", + }, + }, people: { list: { title: "Personas", -- 2.52.0 From 455e26799961e20e78402341f86930a185ef1e88 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:04:35 +0200 Subject: [PATCH 06/11] chore(tests): add teamId to PersonWithUser fixture after schema change --- tests/unit/app/people/edit-person-form-wiring.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/app/people/edit-person-form-wiring.test.ts b/tests/unit/app/people/edit-person-form-wiring.test.ts index 9b93141..236e0e1 100644 --- a/tests/unit/app/people/edit-person-form-wiring.test.ts +++ b/tests/unit/app/people/edit-person-form-wiring.test.ts @@ -55,6 +55,7 @@ const basePerson: PersonWithUser = { firstName: "Ada", lastName: "Lovelace", department: "ENGINEERING", + teamId: null, email: "ada@example.test", phone: "1234", userId: null, -- 2.52.0 From 575900427a8ea621ffea4d6e7e6a0131138ebbc3 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:05:19 +0200 Subject: [PATCH 07/11] feat(teams): add team server actions and message mapping --- src/actions/team.actions.ts | 143 +++++++++++++++++++++++++++++++++++ src/actions/team.messages.ts | 38 ++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/actions/team.actions.ts create mode 100644 src/actions/team.messages.ts diff --git a/src/actions/team.actions.ts b/src/actions/team.actions.ts new file mode 100644 index 0000000..ec0e9b5 --- /dev/null +++ b/src/actions/team.actions.ts @@ -0,0 +1,143 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { flattenError } from "zod" +import { getAuthenticatedSession, requireRole } from "@/services/auth.service" +import { getI18n } from "@/i18n/server" +import { + buildCreateTeamSchema, + buildUpdateTeamSchema, + type CreateTeamFormType, + type UpdateTeamFormType, +} from "@/schemas/team.schema" +import { + createTeamUseCase, + deleteTeamUseCase, + listTeamsUseCase, + updateTeamUseCase, +} from "@/use-cases/team.use-cases" + +import { localizeTeamFieldErrors } from "./team.messages" + +export async function createTeamAction(formData: CreateTeamFormType) { + await requireRole("ADMIN") + + const { dictionary } = await getI18n() + const copy = dictionary.inventory.teams + const validatedFields = buildCreateTeamSchema(copy.schema).safeParse(formData) + + if (!validatedFields.success) { + return { + success: false, + errors: flattenError(validatedFields.error).fieldErrors, + } + } + + try { + const result = await createTeamUseCase(validatedFields.data) + + if (!result.success) { + return { + ...result, + errors: localizeTeamFieldErrors(result.errors, copy.actions), + message: copy.actions.createFailure, + } + } + + revalidatePath("/people") + + return { + success: true, + message: copy.actions.createSuccess, + } + } catch (error) { + console.error("Database error:", error) + return { + success: false, + message: copy.actions.createFailure, + errors: { + name: [copy.actions.duplicateName], + }, + } + } +} + +export async function updateTeamAction(formData: UpdateTeamFormType) { + await requireRole("ADMIN") + + const { dictionary } = await getI18n() + const copy = dictionary.inventory.teams + const validatedFields = buildUpdateTeamSchema(copy.schema).safeParse(formData) + + if (!validatedFields.success) { + return { + success: false, + errors: flattenError(validatedFields.error).fieldErrors, + } + } + + try { + const result = await updateTeamUseCase(validatedFields.data) + + if (!result.success) { + return { + ...result, + errors: localizeTeamFieldErrors(result.errors, copy.actions), + message: copy.actions.updateFailure, + } + } + + revalidatePath("/people") + + return { + success: true, + message: copy.actions.updateSuccess, + } + } catch (error) { + console.error("Database error:", error) + return { + success: false, + message: copy.actions.updateFailure, + } + } +} + +export async function deleteTeamAction(formData: FormData) { + await requireRole("ADMIN") + + const { dictionary } = await getI18n() + const copy = dictionary.inventory.teams + const { id } = Object.fromEntries(formData) as { id: string } + + try { + const result = await deleteTeamUseCase(id) + + if (!result.success) { + return { + ...result, + errors: localizeTeamFieldErrors(result.errors, copy.actions), + message: copy.actions.deleteFailure, + } + } + + revalidatePath("/people") + + return { + success: true as const, + message: copy.actions.deleteSuccess, + } + } catch (error) { + console.error("Database error:", error) + return { + success: false as const, + message: copy.actions.deleteFailure, + errors: {}, + } + } +} + +export async function listTeamsAction() { + await getAuthenticatedSession() + + return listTeamsUseCase() +} diff --git a/src/actions/team.messages.ts b/src/actions/team.messages.ts new file mode 100644 index 0000000..a533117 --- /dev/null +++ b/src/actions/team.messages.ts @@ -0,0 +1,38 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type TeamActionCopy = Dictionary["inventory"]["teams"]["actions"] + +type FieldErrors = Record + +const teamErrorMessageKeys = { + "Team already exists": "duplicateName", + "Team name is the same": "unchangedName", + "Team name unchanged": "unchangedName", + "Team not found": "notFound", +} as const satisfies Record + +function isTeamErrorMessage( + message: string, +): message is keyof typeof teamErrorMessageKeys { + return message in teamErrorMessageKeys +} + +function localizeTeamMessage(message: string, copy: TeamActionCopy): string { + if (!isTeamErrorMessage(message)) return message + + return copy[teamErrorMessageKeys[message]] +} + +export function localizeTeamFieldErrors( + errors: FieldErrors | undefined, + copy: TeamActionCopy, +): FieldErrors | undefined { + if (!errors) return undefined + + return Object.fromEntries( + Object.entries(errors).map(([field, messages]) => [ + field, + messages.map((message) => localizeTeamMessage(message, copy)), + ]), + ) +} -- 2.52.0 From d3114326bb2d64a7ace03ad959ae20a4c4f79ef8 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:07:07 +0200 Subject: [PATCH 08/11] feat(teams): add team tab UI components --- .../people/_components/team.create.form.tsx | 97 ++++++++++++ .../people/_components/team.edit.form.tsx | 143 ++++++++++++++++++ .../people/_components/team.list.table.tsx | 113 ++++++++++++++ .../people/_components/teams.tab.tsx | 34 +++++ 4 files changed, 387 insertions(+) create mode 100644 src/app/(dashboard)/people/_components/team.create.form.tsx create mode 100644 src/app/(dashboard)/people/_components/team.edit.form.tsx create mode 100644 src/app/(dashboard)/people/_components/team.list.table.tsx create mode 100644 src/app/(dashboard)/people/_components/teams.tab.tsx diff --git a/src/app/(dashboard)/people/_components/team.create.form.tsx b/src/app/(dashboard)/people/_components/team.create.form.tsx new file mode 100644 index 0000000..03a7d03 --- /dev/null +++ b/src/app/(dashboard)/people/_components/team.create.form.tsx @@ -0,0 +1,97 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import { useMemo } from "react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { createTeamAction } from "@/actions/team.actions" +import { + SubmitButton, + type SubmitButtonCopy, +} from "@/components/forms/submitButton" +import type { Dictionary } from "@/i18n/dictionaries" +import { + buildCreateTeamSchema, + type CreateTeamFormType, +} from "@/schemas/team.schema" + +type TeamFormCopy = Dictionary["inventory"]["teams"]["form"] +type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"] + +export default function TeamCreateForm({ + formCopy, + schemaCopy, + submitButtonCopy, +}: { + formCopy: TeamFormCopy + schemaCopy: TeamSchemaCopy + submitButtonCopy: SubmitButtonCopy +}) { + const router = useRouter() + const schema = useMemo( + () => buildCreateTeamSchema(schemaCopy), + [schemaCopy], + ) + + const { + register, + handleSubmit, + reset, + setError, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(schema), + }) + + const onSubmit = async (formData: CreateTeamFormType) => { + const response = await createTeamAction(formData) + + if (response?.errors) { + Object.entries(response.errors).forEach(([fieldName, messages]) => { + messages.forEach((msg: string) => { + setError(fieldName as keyof CreateTeamFormType, { + type: "server", + message: msg, + }) + toast.error(msg) + }) + }) + return + } + + if (response?.success) { + toast.success(response.message) + reset() + router.refresh() + } + } + + return ( +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ + {formCopy.createSubmit} + +
+ ) +} diff --git a/src/app/(dashboard)/people/_components/team.edit.form.tsx b/src/app/(dashboard)/people/_components/team.edit.form.tsx new file mode 100644 index 0000000..28c6795 --- /dev/null +++ b/src/app/(dashboard)/people/_components/team.edit.form.tsx @@ -0,0 +1,143 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { Pencil } from "lucide-react" +import { useRouter } from "next/navigation" +import { useMemo, useState } from "react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { updateTeamAction } from "@/actions/team.actions" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + SubmitButton, + type SubmitButtonCopy, +} from "@/components/forms/submitButton" +import type { Dictionary } from "@/i18n/dictionaries" +import { + buildUpdateTeamSchema, + type UpdateTeamFormType, +} from "@/schemas/team.schema" +import type { TeamSummary } from "@/types" + +type TeamFormCopy = Dictionary["inventory"]["teams"]["form"] +type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"] +type TeamListCopy = Dictionary["inventory"]["teams"]["list"] + +export default function TeamEditForm({ + team, + formCopy, + schemaCopy, + listCopy, + submitButtonCopy, +}: { + team: TeamSummary + formCopy: TeamFormCopy + schemaCopy: TeamSchemaCopy + listCopy: TeamListCopy + submitButtonCopy: SubmitButtonCopy +}) { + const router = useRouter() + const [open, setOpen] = useState(false) + const schema = useMemo( + () => buildUpdateTeamSchema(schemaCopy), + [schemaCopy], + ) + + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + id: team.id, + name: team.name, + }, + }) + + const onSubmit = async (formData: UpdateTeamFormType) => { + const response = await updateTeamAction(formData) + + if (response?.errors) { + Object.entries(response.errors).forEach(([fieldName, messages]) => { + messages.forEach((msg: string) => { + setError(fieldName as keyof UpdateTeamFormType, { + type: "server", + message: msg, + }) + toast.error(msg) + }) + }) + return + } + + if (response?.success) { + toast.success(response.message) + setOpen(false) + router.refresh() + } + } + + return ( + + + + + +
+ + {formCopy.updateSubmit} + {team.name} + + +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ + + + + + {formCopy.updateSubmit} + + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/people/_components/team.list.table.tsx b/src/app/(dashboard)/people/_components/team.list.table.tsx new file mode 100644 index 0000000..a9f3e74 --- /dev/null +++ b/src/app/(dashboard)/people/_components/team.list.table.tsx @@ -0,0 +1,113 @@ +"use client" + +import { Trash } from "lucide-react" +import { useRouter } from "next/navigation" +import { useTransition } from "react" +import { toast } from "sonner" +import { deleteTeamAction } from "@/actions/team.actions" +import { Button } from "@/components/ui/button" +import type { Dictionary } from "@/i18n/dictionaries" +import type { TeamSummary } from "@/types" + +import TeamEditForm from "./team.edit.form" + +type TeamFormCopy = Dictionary["inventory"]["teams"]["form"] +type TeamSchemaCopy = Dictionary["inventory"]["teams"]["schema"] +type TeamListCopy = Dictionary["inventory"]["teams"]["list"] +type SubmitButtonCopy = Dictionary["common"]["submitButton"] + +function DeleteTeamButton({ + team, + copy, +}: { + team: TeamSummary + copy: TeamListCopy +}) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const handleDelete = (formData: FormData) => { + startTransition(async () => { + const response = await deleteTeamAction(formData) + + if (!response.success && response.errors?.id) { + toast.error(response.errors.id[0]) + return + } + + if (response.success) { + toast.success(response.message) + router.refresh() + } else { + toast.error(response.message ?? copy.actions.delete) + } + }) + } + + return ( +
+ + +
+ ) +} + +export default function TeamListTable({ + teams, + formCopy, + schemaCopy, + listCopy, + submitButtonCopy, +}: { + teams: TeamSummary[] + formCopy: TeamFormCopy + schemaCopy: TeamSchemaCopy + listCopy: TeamListCopy + submitButtonCopy: SubmitButtonCopy +}) { + if (teams.length === 0) { + return
{listCopy.empty}
+ } + + return ( +
+ + + + + + + + + {teams.map((team) => ( + + + + + ))} + +
+ {listCopy.columns.name} + + {listCopy.columns.actions} +
{team.name} + + +
+
+ ) +} diff --git a/src/app/(dashboard)/people/_components/teams.tab.tsx b/src/app/(dashboard)/people/_components/teams.tab.tsx new file mode 100644 index 0000000..0e6548e --- /dev/null +++ b/src/app/(dashboard)/people/_components/teams.tab.tsx @@ -0,0 +1,34 @@ +import PageHeader from "@/components/common/pageheader" +import { getI18n } from "@/i18n/server" +import { listTeamsUseCase } from "@/use-cases/team.use-cases" + +import TeamCreateForm from "./team.create.form" +import TeamListTable from "./team.list.table" + +export default async function TeamsTab() { + const teams = await listTeamsUseCase() + const { dictionary } = await getI18n() + const copy = dictionary.inventory.teams + + return ( +
+ + + +
+ ) +} -- 2.52.0 From a0dcf85f5c9d6174bb4c3b35bf1a41d3aff46ba5 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:13:37 +0200 Subject: [PATCH 09/11] feat(teams): add tab routing to people page --- src/app/(dashboard)/people/page.tsx | 47 +++++++++++++++++++++- tests/unit/app/people/person-pages.test.ts | 4 ++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/app/(dashboard)/people/page.tsx b/src/app/(dashboard)/people/page.tsx index dca1681..5ac5308 100644 --- a/src/app/(dashboard)/people/page.tsx +++ b/src/app/(dashboard)/people/page.tsx @@ -13,21 +13,36 @@ import { type PersonDepartmentCopy, type PersonFallbackCopy, } from "./_components/person.copy" +import TeamsTab from "./_components/teams.tab" import { formatUserRole, type UserFallbackCopy, type UserRoleCopy, } from "./_components/user.copy" +const VALID_TABS = ["people", "teams"] as const + +type Tab = (typeof VALID_TABS)[number] + +function resolveTab(raw: string | undefined): Tab { + if (raw && VALID_TABS.includes(raw as Tab)) { + return raw as Tab + } + + return "people" +} + export default async function PeoplePage(props: { searchParams?: Promise<{ page?: string search?: string + tab?: string }> }) { const searchParams = await props.searchParams const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1 const search = searchParams?.search || "" + const activeTab = resolveTab(searchParams?.tab) const { data: people, totalPages } = await PersonService.findAllPaginated({ page: currentPage, pageSize: 10, @@ -35,6 +50,7 @@ export default async function PeoplePage(props: { }) const { dictionary } = await getI18n() const copy = dictionary.inventory.people + const teamCopy = dictionary.inventory.teams const userCopy = dictionary.admin.users const userStatusCopy = userCopy.status const userRoleLabels = userCopy.roles as UserRoleCopy @@ -42,7 +58,7 @@ export default async function PeoplePage(props: { const departmentCopy = copy.departments as PersonDepartmentCopy const personFallbackCopy = copy.fallback as PersonFallbackCopy - return ( + const peopleList = (
) + + return ( +
+ + {activeTab === "teams" && } + {activeTab === "people" && peopleList} +
+ ) } diff --git a/tests/unit/app/people/person-pages.test.ts b/tests/unit/app/people/person-pages.test.ts index c64a4c4..aecbcc3 100644 --- a/tests/unit/app/people/person-pages.test.ts +++ b/tests/unit/app/people/person-pages.test.ts @@ -44,6 +44,10 @@ vi.mock("@/components/common/pagination", () => ({ createElement("nav", { "aria-label": "Pagination" }, totalPages), })) +vi.mock("@/app/(dashboard)/people/_components/teams.tab", () => ({ + default: () => null, +})) + describe("person pages", () => { beforeEach(() => { vi.clearAllMocks() -- 2.52.0 From cd38621f8bb363dda0bc7d41dea26f62305c7cb3 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:16:26 +0200 Subject: [PATCH 10/11] style(teams): apply biome formatting and remove unused test import --- src/actions/team.actions.ts | 2 +- .../people/_components/team.create.form.tsx | 5 +---- .../people/_components/team.edit.form.tsx | 22 +++++++++---------- src/i18n/dictionaries/es.ts | 3 ++- .../use-cases/team.use-cases.test.ts | 7 +----- 5 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/actions/team.actions.ts b/src/actions/team.actions.ts index ec0e9b5..cb049f1 100644 --- a/src/actions/team.actions.ts +++ b/src/actions/team.actions.ts @@ -2,7 +2,6 @@ import { revalidatePath } from "next/cache" import { flattenError } from "zod" -import { getAuthenticatedSession, requireRole } from "@/services/auth.service" import { getI18n } from "@/i18n/server" import { buildCreateTeamSchema, @@ -10,6 +9,7 @@ import { type CreateTeamFormType, type UpdateTeamFormType, } from "@/schemas/team.schema" +import { getAuthenticatedSession, requireRole } from "@/services/auth.service" import { createTeamUseCase, deleteTeamUseCase, diff --git a/src/app/(dashboard)/people/_components/team.create.form.tsx b/src/app/(dashboard)/people/_components/team.create.form.tsx index 03a7d03..a9f8b8c 100644 --- a/src/app/(dashboard)/people/_components/team.create.form.tsx +++ b/src/app/(dashboard)/people/_components/team.create.form.tsx @@ -29,10 +29,7 @@ export default function TeamCreateForm({ submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() - const schema = useMemo( - () => buildCreateTeamSchema(schemaCopy), - [schemaCopy], - ) + const schema = useMemo(() => buildCreateTeamSchema(schemaCopy), [schemaCopy]) const { register, diff --git a/src/app/(dashboard)/people/_components/team.edit.form.tsx b/src/app/(dashboard)/people/_components/team.edit.form.tsx index 28c6795..2cc8eb6 100644 --- a/src/app/(dashboard)/people/_components/team.edit.form.tsx +++ b/src/app/(dashboard)/people/_components/team.edit.form.tsx @@ -7,6 +7,10 @@ import { useMemo, useState } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { updateTeamAction } from "@/actions/team.actions" +import { + SubmitButton, + type SubmitButtonCopy, +} from "@/components/forms/submitButton" import { Button } from "@/components/ui/button" import { Dialog, @@ -18,10 +22,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog" -import { - SubmitButton, - type SubmitButtonCopy, -} from "@/components/forms/submitButton" import type { Dictionary } from "@/i18n/dictionaries" import { buildUpdateTeamSchema, @@ -48,10 +48,7 @@ export default function TeamEditForm({ }) { const router = useRouter() const [open, setOpen] = useState(false) - const schema = useMemo( - () => buildUpdateTeamSchema(schemaCopy), - [schemaCopy], - ) + const schema = useMemo(() => buildUpdateTeamSchema(schemaCopy), [schemaCopy]) const { register, @@ -108,7 +105,10 @@ export default function TeamEditForm({
-
diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index f0192c5..48983d4 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -423,7 +423,8 @@ export const es = { }, schema: { nameRequired: "El nombre del equipo es obligatorio", - nameMaxLength: "El nombre del equipo no puede superar los 80 caracteres", + nameMaxLength: + "El nombre del equipo no puede superar los 80 caracteres", idRequired: "El ID del equipo es obligatorio", }, }, diff --git a/tests/integration/use-cases/team.use-cases.test.ts b/tests/integration/use-cases/team.use-cases.test.ts index 76022ba..342d455 100644 --- a/tests/integration/use-cases/team.use-cases.test.ts +++ b/tests/integration/use-cases/team.use-cases.test.ts @@ -12,21 +12,18 @@ let createTeamUseCase: typeof import("@/use-cases/team.use-cases").createTeamUse let updateTeamUseCase: typeof import("@/use-cases/team.use-cases").updateTeamUseCase let deleteTeamUseCase: typeof import("@/use-cases/team.use-cases").deleteTeamUseCase let listTeamsUseCase: typeof import("@/use-cases/team.use-cases").listTeamsUseCase -let TeamService: typeof import("@/services/team.service").TeamService beforeAll(async () => { await startIntegrationTestDatabase() const prismaModule = await import("@/lib/prisma") const teamUseCases = await import("@/use-cases/team.use-cases") - const teamService = await import("@/services/team.service") prisma = prismaModule.prisma createTeamUseCase = teamUseCases.createTeamUseCase updateTeamUseCase = teamUseCases.updateTeamUseCase deleteTeamUseCase = teamUseCases.deleteTeamUseCase listTeamsUseCase = teamUseCases.listTeamsUseCase - TeamService = teamService.TeamService }) beforeEach(async () => { @@ -102,9 +99,7 @@ describe("team use-cases", () => { errors: { name: ["Team name is the same"] }, }) - expect( - await updateTeamUseCase({ id: team.id, name: other.name }), - ).toEqual({ + expect(await updateTeamUseCase({ id: team.id, name: other.name })).toEqual({ success: false, errors: { name: ["Team already exists"] }, }) -- 2.52.0 From 428dd0482d5d86d5ca675e37637334dc91c0e629 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 26 Jun 2026 00:29:09 +0000 Subject: [PATCH 11/11] feat(teams): add Team entity and cutover Person.department to Person.teamId Co-authored-by: Asis Ferrer Co-committed-by: Asis Ferrer --- .../migration.sql | 41 +++++ prisma/schema.prisma | 19 +-- src/actions/assignment.actions.ts | 20 ++- src/actions/import.actions.ts | 1 - src/actions/person.messages.ts | 1 + src/actions/user.messages.ts | 2 +- .../inventory/assets/[assetId]/page.tsx | 23 ++- .../people/[personId]/edit/page.tsx | 6 +- .../(dashboard)/people/[personId]/page.tsx | 13 +- .../people/_components/edit.person.form.tsx | 47 +++--- .../people/_components/new.person.form.tsx | 47 +++--- .../people/_components/person.copy.ts | 16 -- .../people/_components/user.copy.ts | 16 -- src/app/(dashboard)/people/new/page.tsx | 5 +- src/app/(dashboard)/people/page.tsx | 16 +- src/i18n/dictionaries/en.ts | 27 ++-- src/i18n/dictionaries/es.ts | 27 ++-- src/lib/auth.ts | 4 +- src/lib/constants.ts | 11 -- src/schemas/item.schema.ts | 28 ++-- src/schemas/person.schema.ts | 21 +-- src/schemas/user.schema.ts | 17 ++- src/services/movement.service.ts | 6 +- src/services/person.service.ts | 6 + src/types/asset.ts | 2 +- src/use-cases/item.use-cases.ts | 7 +- src/use-cases/person.use-cases.ts | 103 +++++++++++-- tests/e2e/assignments.spec.ts | 2 +- tests/e2e/people.spec.ts | 142 ++++++++++++++++++ tests/integration/helpers/factories.ts | 36 ++--- .../use-cases/person-user.use-cases.test.ts | 70 +++++++-- .../use-cases/person.use-cases.test.ts | 71 +++++++-- .../update-person-user.use-cases.test.ts | 44 ++++-- .../use-cases/user.use-cases.test.ts | 20 ++- tests/unit/actions/person.actions.test.ts | 13 +- tests/unit/actions/person.messages.test.ts | 14 ++ .../actions/update-person-user.action.test.ts | 39 +++-- .../inventory/assets/[assetId]/page.test.ts | 6 +- tests/unit/app/inventory/assets/page.test.ts | 2 +- .../people/edit-person-form-wiring.test.ts | 19 ++- .../unit/app/people/person-form-pages.test.ts | 15 +- tests/unit/app/people/person-pages.test.ts | 22 ++- .../unit/app/users/unified-form-pages.test.ts | 25 ++- tests/unit/app/users/user.copy.test.ts | 50 +----- .../unit/i18n/admin-users-dictionary.test.ts | 8 +- tests/unit/i18n/dictionaries.test.ts | 46 ++---- .../unit/i18n/unified-form-dictionary.test.ts | 8 +- tests/unit/schemas/core-schemas.test.ts | 6 +- tests/unit/schemas/person.schema.test.ts | 40 +++-- .../schemas/unified-create.schema.test.ts | 57 +++---- .../schemas/unified-update.schema.test.ts | 35 ++++- tests/unit/services/user.service.test.ts | 2 +- 52 files changed, 836 insertions(+), 488 deletions(-) create mode 100644 prisma/migrations/20260625230055_drop_person_department_enum/migration.sql create mode 100644 tests/e2e/people.spec.ts diff --git a/prisma/migrations/20260625230055_drop_person_department_enum/migration.sql b/prisma/migrations/20260625230055_drop_person_department_enum/migration.sql new file mode 100644 index 0000000..01f2c9c --- /dev/null +++ b/prisma/migrations/20260625230055_drop_person_department_enum/migration.sql @@ -0,0 +1,41 @@ +BEGIN; + +-- Seed legacy teams from the old PersonDepartment enum English display names. +INSERT INTO "Team" ("id", "name", "createdAt", "updatedAt") +VALUES + (gen_random_uuid(), 'IT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Engineering', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Logistics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Traffic', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Driver', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Administration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Sales', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (lower("name")) DO NOTHING; + +-- Backfill Person.teamId from the legacy Person.department enum values. +UPDATE "Person" +SET "teamId" = ( + SELECT "id" FROM "Team" WHERE lower("name") = lower(CASE "department" + WHEN 'IT' THEN 'IT' + WHEN 'ENGINEERING' THEN 'Engineering' + WHEN 'LOGISTICS' THEN 'Logistics' + WHEN 'TRAFFIC' THEN 'Traffic' + WHEN 'DRIVER' THEN 'Driver' + WHEN 'ADMINISTRATION' THEN 'Administration' + WHEN 'SALES' THEN 'Sales' + WHEN 'OTHER' THEN 'Other' + END) +) +WHERE "department" IS NOT NULL; + +-- Drop the legacy department index. +DROP INDEX "Person_department_deletedAt_idx"; + +-- Drop the legacy department column. +ALTER TABLE "Person" DROP COLUMN "department"; + +-- Drop the legacy enum type. +DROP TYPE "PersonDepartment"; + +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2453021..99e1765 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,22 +110,10 @@ model UserInvitation { // PEOPLE // ====================================================== -enum PersonDepartment { - IT - ENGINEERING - LOGISTICS - TRAFFIC - DRIVER - ADMINISTRATION - SALES - OTHER -} - model Person { - id String @id @default(uuid(7)) @db.Uuid - firstName String - lastName String - department PersonDepartment? + id String @id @default(uuid(7)) @db.Uuid + firstName String + lastName String email String? phone String? @@ -143,7 +131,6 @@ model Person { assignments Assignment[] @@index([lastName, firstName]) - @@index([department, deletedAt]) @@index([teamId, deletedAt]) @@index([teamId]) @@index([deletedAt]) diff --git a/src/actions/assignment.actions.ts b/src/actions/assignment.actions.ts index 4c50ba4..54f3aae 100644 --- a/src/actions/assignment.actions.ts +++ b/src/actions/assignment.actions.ts @@ -35,14 +35,18 @@ export async function createAssignment(formData: CreateAssignmentFormType) { try { const createdBy = await getAuthenticatedUserId() + const { itemId, quantity, notes } = validatedFields.data + if (!itemId || quantity == null) { + throw new Error("Missing required assignment fields") + } const result = await createAssignmentUseCase({ ...validatedFields.data, lines: [ { - itemId: validatedFields.data.itemId!, - quantity: validatedFields.data.quantity!, - notes: validatedFields.data.notes, + itemId, + quantity, + notes, }, ], actorId: createdBy, @@ -86,14 +90,18 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) { try { const createdBy = await getAuthenticatedUserId() + const { itemId, quantity, notes } = validatedFields.data + if (!itemId || quantity == null) { + throw new Error("Missing required assignment fields") + } const result = await updateAssignmentUseCase({ ...validatedFields.data, lines: [ { - itemId: validatedFields.data.itemId!, - quantity: validatedFields.data.quantity!, - notes: validatedFields.data.notes, + itemId, + quantity, + notes, }, ], actorId: createdBy, diff --git a/src/actions/import.actions.ts b/src/actions/import.actions.ts index e0f1ca0..b1b4b21 100644 --- a/src/actions/import.actions.ts +++ b/src/actions/import.actions.ts @@ -303,7 +303,6 @@ export async function importItems(formData: ImportFormType) { lastName, email: undefined, phone: "", - department: "OTHER", }) } else { newPerson = existingPerson.data[0] diff --git a/src/actions/person.messages.ts b/src/actions/person.messages.ts index fc60ab6..d42adc8 100644 --- a/src/actions/person.messages.ts +++ b/src/actions/person.messages.ts @@ -6,6 +6,7 @@ type FieldErrors = Record const personErrorMessageKeys = { "Email already exists": "duplicateEmail", + "Team not found": "teamNotFound", } as const satisfies Record function isPersonErrorMessage( diff --git a/src/actions/user.messages.ts b/src/actions/user.messages.ts index 9d39261..f43f466 100644 --- a/src/actions/user.messages.ts +++ b/src/actions/user.messages.ts @@ -76,7 +76,7 @@ export function localizeUnifiedCreateFieldErrors( return message if (field === "lastName" && message === schemaCopy.lastNameRequired) return message - if (field === "department" && message === schemaCopy.departmentRequired) + if (field === "teamId" && message === schemaCopy.teamIdInvalid) return message if (field === "email" && message === schemaCopy.emailInvalid) return message diff --git a/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx b/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx index 1bbb435..97fb846 100644 --- a/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx +++ b/src/app/(dashboard)/inventory/assets/[assetId]/page.tsx @@ -7,7 +7,10 @@ import { Button } from "@/components/ui/button" import { getI18n } from "@/i18n/server" import { AssetService } from "@/services/asset.service" -import type { AssetDetailCopy, AssetStatusCopy } from "../_components/asset.copy" +import type { + AssetDetailCopy, + AssetStatusCopy, +} from "../_components/asset.copy" function formatAssetStatus( status: string, @@ -77,7 +80,9 @@ export default async function AssetDetailPage({
{asset.serialNumber}
-
{copy.labels.assetTag}
+
+ {copy.labels.assetTag} +
{asset.assetTag ?? missingValue}
@@ -119,11 +124,19 @@ export default async function AssetDetailPage({
{asset.notes ?? missingValue}
-
{copy.labels.status}
-
{formatAssetStatus(asset.status, statusCopy, { unknownStatus: missingValue })}
+
+ {copy.labels.status} +
+
+ {formatAssetStatus(asset.status, statusCopy, { + unknownStatus: missingValue, + })} +
-
{copy.labels.person}
+
+ {copy.labels.person} +
{formatPersonName(asset.assignment?.person, missingValue)}
diff --git a/src/app/(dashboard)/people/[personId]/edit/page.tsx b/src/app/(dashboard)/people/[personId]/edit/page.tsx index 99c94f6..a49690a 100644 --- a/src/app/(dashboard)/people/[personId]/edit/page.tsx +++ b/src/app/(dashboard)/people/[personId]/edit/page.tsx @@ -1,5 +1,6 @@ import { getI18n } from "@/i18n/server" import { PersonService } from "@/services/person.service" +import { listTeamsUseCase } from "@/use-cases/team.use-cases" import EditPersonForm from "../../_components/edit.person.form" @@ -13,6 +14,7 @@ export default async function PersonEditPage({ const personCopy = dictionary.inventory.people const userCopy = dictionary.admin.users const person = await PersonService.findByIdWithUser(personId) + const teams = await listTeamsUseCase() if (!person) { return
{personCopy.edit.notFound}
@@ -28,10 +30,8 @@ export default async function PersonEditPage({ formCopy={userCopy.form} schemaCopy={{ ...userCopy.schema, ...personCopy.schema }} roleLabels={userCopy.roles} - userFallbackCopy={userCopy.fallback} - departmentCopy={personCopy.departments} - fallbackCopy={personCopy.fallback} submitButtonCopy={dictionary.common.submitButton} + teams={teams} /> ) diff --git a/src/app/(dashboard)/people/[personId]/page.tsx b/src/app/(dashboard)/people/[personId]/page.tsx index fd462d3..9434eba 100644 --- a/src/app/(dashboard)/people/[personId]/page.tsx +++ b/src/app/(dashboard)/people/[personId]/page.tsx @@ -4,7 +4,6 @@ import { getI18n } from "@/i18n/server" import { AssignmentService } from "@/services/assignment.service" import { PersonService } from "@/services/person.service" -import { formatPersonDepartment } from "../_components/person.copy" import { formatUserRole, type UserFallbackCopy, @@ -45,16 +44,8 @@ export default async function PersonInfoPage({ {person.phone}
- - {copy.detail.labels.department} - - - {formatPersonDepartment( - person.department, - copy.departments, - copy.fallback, - )} - + {copy.detail.labels.team} + {person.team?.name ?? copy.fallback.noTeam}
{person.user ? ( <> diff --git a/src/app/(dashboard)/people/_components/edit.person.form.tsx b/src/app/(dashboard)/people/_components/edit.person.form.tsx index d7594da..e6953fe 100644 --- a/src/app/(dashboard)/people/_components/edit.person.form.tsx +++ b/src/app/(dashboard)/people/_components/edit.person.form.tsx @@ -12,20 +12,16 @@ import { type SubmitButtonCopy, } from "@/components/forms/submitButton" import { UserStatus } from "@/generated/prisma/client" -import { PERSON_DEPARTMENTS } from "@/lib/constants" import { buildUnifiedUpdateSchema, type UnifiedSchemaCopy, type UnifiedUpdateFormType, } from "@/schemas/user.schema" import type { PersonWithUser } from "@/services/person.service" +import type { TeamSummary } from "@/types" import { - formatPersonDepartment, formatUserRole, - type PersonDepartmentCopy, - type PersonFallbackCopy, - type UserFallbackCopy, type UserFormCopy, type UserRoleCopy, } from "./user.copy" @@ -35,19 +31,15 @@ export default function EditPersonForm({ formCopy, schemaCopy, roleLabels, - userFallbackCopy, - departmentCopy, - fallbackCopy, submitButtonCopy, + teams, }: { person: PersonWithUser formCopy: UserFormCopy schemaCopy: UnifiedSchemaCopy roleLabels: UserRoleCopy - userFallbackCopy: UserFallbackCopy - departmentCopy: PersonDepartmentCopy - fallbackCopy: PersonFallbackCopy submitButtonCopy: SubmitButtonCopy + teams: TeamSummary[] }) { const router = useRouter() const schema = useMemo( @@ -68,7 +60,7 @@ export default function EditPersonForm({ id: person.id, firstName: person.firstName, lastName: person.lastName, - department: person.department ?? "OTHER", + teamId: person.teamId ?? null, email: person.email ?? "", phone: person.phone ?? "", ...(hasUser && user @@ -116,12 +108,11 @@ export default function EditPersonForm({ placeholder={formCopy.lastNamePlaceholder} register={register("lastName")} /> - -