import { ProjectStatus } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { fmtEur } from "../lib/format-utils.js"; import type { TRPCContext } from "../trpc.js"; export const PROJECT_SUMMARY_SELECT = { id: true, shortCode: true, name: true, status: true, startDate: true, endDate: true, client: { select: { name: true } }, } as const; export const PROJECT_SUMMARY_DETAIL_SELECT = { ...PROJECT_SUMMARY_SELECT, budgetCents: true, winProbability: true, _count: { select: { assignments: true, estimates: true } }, } as const; export const PROJECT_IDENTIFIER_SELECT = { id: true, shortCode: true, name: true, status: true, startDate: true, endDate: true, } as const; export const PROJECT_DETAIL_SELECT = { ...PROJECT_IDENTIFIER_SELECT, id: true, shortCode: true, name: true, status: true, orderType: true, allocationType: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, responsiblePerson: true, client: { select: { name: true } }, utilizationCategory: { select: { code: true, name: true } }, _count: { select: { assignments: true, estimates: true } }, } as const; function formatDate(value: Date | null): string | null { return value ? value.toISOString().slice(0, 10) : null; } export function mapProjectSummary(project: { id: string; shortCode: string; name: string; status: string; startDate: Date | null; endDate: Date | null; client: { name: string } | null; }) { return { id: project.id, code: project.shortCode, name: project.name, status: project.status, start: formatDate(project.startDate), end: formatDate(project.endDate), client: project.client?.name ?? null, }; } export function mapProjectSummaryDetail(project: { id: string; shortCode: string; name: string; status: string; budgetCents: number | null; winProbability: number; startDate: Date | null; endDate: Date | null; client: { name: string } | null; _count: { assignments: number; estimates: number }; }) { return { id: project.id, code: project.shortCode, name: project.name, status: project.status, budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set", winProbability: `${project.winProbability}%`, start: formatDate(project.startDate), end: formatDate(project.endDate), client: project.client?.name ?? null, assignmentCount: project._count.assignments, estimateCount: project._count.estimates, }; } export function mapProjectDetail( project: { id: string; shortCode: string; name: string; status: string; orderType: string; allocationType: string; budgetCents: number | null; winProbability: number; startDate: Date | null; endDate: Date | null; responsiblePerson: string | null; client: { name: string } | null; utilizationCategory: { code: string; name: string } | null; _count: { assignments: number; estimates: number }; }, topAssignments: Array<{ resource: { displayName: string; eid: string }; role: string | null; status: string; hoursPerDay: number; startDate: Date; endDate: Date; }>, ) { return { id: project.id, code: project.shortCode, name: project.name, status: project.status, orderType: project.orderType, allocationType: project.allocationType, budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set", budgetCents: project.budgetCents, winProbability: `${project.winProbability}%`, start: formatDate(project.startDate), end: formatDate(project.endDate), responsible: project.responsiblePerson, client: project.client?.name ?? null, category: project.utilizationCategory?.name ?? null, assignmentCount: project._count.assignments, estimateCount: project._count.estimates, topAllocations: topAssignments.map((assignment) => ({ resource: assignment.resource.displayName, eid: assignment.resource.eid, role: assignment.role ?? null, status: assignment.status, hoursPerDay: assignment.hoursPerDay, start: formatDate(assignment.startDate), end: formatDate(assignment.endDate), })), }; } export async function readProjectSummariesSnapshot( ctx: Pick, input: { search?: string | undefined; status?: ProjectStatus | undefined; limit: number; }, ) { const buildWhere = (search: string | undefined) => ({ ...(input.status ? { status: input.status } : {}), ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { shortCode: { contains: search, mode: "insensitive" as const } }, ], } : {}), }); let projects = await ctx.db.project.findMany({ where: buildWhere(input.search), select: PROJECT_SUMMARY_SELECT, take: input.limit, orderBy: { name: "asc" }, }); if (projects.length === 0 && input.search) { const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); if (words.length > 1) { const candidates = await ctx.db.project.findMany({ where: { ...(input.status ? { status: input.status } : {}), OR: words.flatMap((word) => ([ { name: { contains: word, mode: "insensitive" as const } }, { shortCode: { contains: word, mode: "insensitive" as const } }, ])), }, select: PROJECT_SUMMARY_SELECT, take: input.limit * 2, orderBy: { name: "asc" }, }); projects = candidates .map((project) => { const haystack = `${project.name} ${project.shortCode}`.toLowerCase(); const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length; return { project, matchCount }; }) .sort((left, right) => right.matchCount - left.matchCount) .slice(0, input.limit) .map((entry) => entry.project); } } return { items: projects, exactMatch: input.search ? projects.some((project) => project.name.toLowerCase().includes(input.search!.toLowerCase()) || project.shortCode.toLowerCase().includes(input.search!.toLowerCase())) : true, }; } export async function readProjectSummaryDetailsSnapshot( ctx: Pick, input: { search?: string | undefined; status?: ProjectStatus | undefined; limit: number; }, ) { const buildWhere = (search: string | undefined) => ({ ...(input.status ? { status: input.status } : {}), ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { shortCode: { contains: search, mode: "insensitive" as const } }, ], } : {}), }); let projects = await ctx.db.project.findMany({ where: buildWhere(input.search), select: PROJECT_SUMMARY_DETAIL_SELECT, take: input.limit, orderBy: { name: "asc" }, }); if (projects.length === 0 && input.search) { const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); if (words.length > 1) { const candidates = await ctx.db.project.findMany({ where: { ...(input.status ? { status: input.status } : {}), OR: words.flatMap((word) => ([ { name: { contains: word, mode: "insensitive" as const } }, { shortCode: { contains: word, mode: "insensitive" as const } }, ])), }, select: PROJECT_SUMMARY_DETAIL_SELECT, take: input.limit * 2, orderBy: { name: "asc" }, }); projects = candidates .map((project) => { const haystack = `${project.name} ${project.shortCode}`.toLowerCase(); const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length; return { project, matchCount }; }) .sort((left, right) => right.matchCount - left.matchCount) .slice(0, input.limit) .map((entry) => entry.project); } } return { items: projects, exactMatch: input.search ? projects.some((project) => project.name.toLowerCase().includes(input.search!.toLowerCase()) || project.shortCode.toLowerCase().includes(input.search!.toLowerCase())) : true, }; } export async function resolveProjectIdentifierSnapshot( ctx: Pick, identifier: string, ) { let project = await ctx.db.project.findUnique({ where: { id: identifier }, select: PROJECT_IDENTIFIER_SELECT, }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: identifier }, select: PROJECT_IDENTIFIER_SELECT, }); } if (!project) { project = await ctx.db.project.findFirst({ where: { name: { equals: identifier, mode: "insensitive" } }, select: PROJECT_IDENTIFIER_SELECT, }); } if (!project) { project = await ctx.db.project.findFirst({ where: { name: { contains: identifier, mode: "insensitive" } }, select: PROJECT_IDENTIFIER_SELECT, }); } if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } return project; } export async function readProjectByIdentifierDetailSnapshot( ctx: Pick, identifier: string, ) { const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier); const project = await ctx.db.project.findUnique({ where: { id: projectIdentity.id }, select: PROJECT_DETAIL_SELECT, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const topAssignments = await ctx.db.assignment.findMany({ where: { projectId: project.id, status: { not: "CANCELLED" }, }, select: { resource: { select: { displayName: true, eid: true } }, role: true, status: true, hoursPerDay: true, startDate: true, endDate: true, }, take: 10, orderBy: { startDate: "desc" }, }); return { ...project, topAssignments, }; }