1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
9.9 KiB
TypeScript
352 lines
9.9 KiB
TypeScript
import { ProjectStatus, toIsoDateOrNull } 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;
|
|
|
|
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: toIsoDateOrNull(project.startDate),
|
|
end: toIsoDateOrNull(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: toIsoDateOrNull(project.startDate),
|
|
end: toIsoDateOrNull(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: toIsoDateOrNull(project.startDate),
|
|
end: toIsoDateOrNull(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: toIsoDateOrNull(assignment.startDate),
|
|
end: toIsoDateOrNull(assignment.endDate),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function readProjectSummariesSnapshot(
|
|
ctx: Pick<TRPCContext, "db">,
|
|
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<TRPCContext, "db">,
|
|
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<TRPCContext, "db">,
|
|
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<TRPCContext, "db">,
|
|
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,
|
|
};
|
|
}
|