refactor(api): extract project cost read procedures
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
import { listAssignmentBookings } from "@capakraken/application";
|
||||||
|
import { ProjectStatus } from "@capakraken/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { CursorInputSchema, paginateCursor } from "../db/pagination.js";
|
||||||
|
import { controllerProcedure } from "../trpc.js";
|
||||||
|
|
||||||
|
export const projectCostReadProcedures = {
|
||||||
|
listWithCosts: controllerProcedure
|
||||||
|
.input(
|
||||||
|
CursorInputSchema.extend({
|
||||||
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { status, search, cursor } = input;
|
||||||
|
const where = {
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ shortCode: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
||||||
|
|
||||||
|
const result = await paginateCursor(
|
||||||
|
({ take }) =>
|
||||||
|
ctx.db.project.findMany({
|
||||||
|
where: whereWithCursor,
|
||||||
|
take,
|
||||||
|
orderBy: [{ startDate: "asc" }, { id: "asc" }],
|
||||||
|
}),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectIds = result.items.map((project) => project.id);
|
||||||
|
const bookings = projectIds.length
|
||||||
|
? await listAssignmentBookings(ctx.db, {
|
||||||
|
startDate: new Date("1900-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2100-12-31T23:59:59.999Z"),
|
||||||
|
projectIds,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const projects = result.items.map((project) => {
|
||||||
|
const projectBookings = bookings.filter((booking) => booking.projectId === project.id);
|
||||||
|
let totalCostCents = 0;
|
||||||
|
let totalPersonDays = 0;
|
||||||
|
for (const booking of projectBookings) {
|
||||||
|
const days =
|
||||||
|
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24) +
|
||||||
|
1;
|
||||||
|
totalCostCents += booking.dailyCostCents * days;
|
||||||
|
totalPersonDays += (booking.hoursPerDay * days) / 8;
|
||||||
|
}
|
||||||
|
const utilizationPercent = project.budgetCents > 0
|
||||||
|
? Math.round((totalCostCents / project.budgetCents) * 100)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
totalCostCents: Math.round(totalCostCents),
|
||||||
|
totalPersonDays: Math.round(totalPersonDays * 10) / 10,
|
||||||
|
utilizationPercent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { projects, nextCursor: result.nextCursor };
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { countPlanningEntries } from "@capakraken/application";
|
||||||
countPlanningEntries,
|
|
||||||
listAssignmentBookings,
|
|
||||||
} from "@capakraken/application";
|
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -11,6 +8,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
|
|||||||
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
|
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||||
|
import { projectCostReadProcedures } from "./project-cost-read.js";
|
||||||
import { projectCoverProcedures } from "./project-cover.js";
|
import { projectCoverProcedures } from "./project-cover.js";
|
||||||
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
||||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||||
@@ -56,6 +54,7 @@ function dispatchProjectWebhookInBackground(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
|
...projectCostReadProcedures,
|
||||||
...projectCoverProcedures,
|
...projectCoverProcedures,
|
||||||
...projectIdentifierReadProcedures,
|
...projectIdentifierReadProcedures,
|
||||||
|
|
||||||
@@ -385,74 +384,6 @@ export const projectRouter = createTRPCRouter({
|
|||||||
return { count: updated.length };
|
return { count: updated.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listWithCosts: controllerProcedure
|
|
||||||
.input(
|
|
||||||
CursorInputSchema.extend({
|
|
||||||
status: z.nativeEnum(ProjectStatus).optional(),
|
|
||||||
search: z.string().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const { status, search, cursor } = input;
|
|
||||||
const where = {
|
|
||||||
...(status ? { status } : {}),
|
|
||||||
...(search
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ name: { contains: search, mode: "insensitive" as const } },
|
|
||||||
{ shortCode: { contains: search, mode: "insensitive" as const } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
|
||||||
|
|
||||||
const result = await paginateCursor(
|
|
||||||
({ take }) =>
|
|
||||||
ctx.db.project.findMany({
|
|
||||||
where: whereWithCursor,
|
|
||||||
take,
|
|
||||||
orderBy: [{ startDate: "asc" }, { id: "asc" }],
|
|
||||||
}),
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectIds = result.items.map((project) => project.id);
|
|
||||||
const bookings = projectIds.length
|
|
||||||
? await listAssignmentBookings(ctx.db, {
|
|
||||||
startDate: new Date("1900-01-01T00:00:00.000Z"),
|
|
||||||
endDate: new Date("2100-12-31T23:59:59.999Z"),
|
|
||||||
projectIds,
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Compute cost + person days per project
|
|
||||||
const projects = result.items.map((p) => {
|
|
||||||
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
|
|
||||||
let totalCostCents = 0;
|
|
||||||
let totalPersonDays = 0;
|
|
||||||
for (const a of projectBookings) {
|
|
||||||
const days =
|
|
||||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
|
||||||
(1000 * 60 * 60 * 24) +
|
|
||||||
1;
|
|
||||||
totalCostCents += a.dailyCostCents * days;
|
|
||||||
totalPersonDays += (a.hoursPerDay * days) / 8;
|
|
||||||
}
|
|
||||||
const utilizationPercent = p.budgetCents > 0
|
|
||||||
? Math.round((totalCostCents / p.budgetCents) * 100)
|
|
||||||
: 0;
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
totalCostCents: Math.round(totalCostCents),
|
|
||||||
totalPersonDays: Math.round(totalPersonDays * 10) / 10,
|
|
||||||
utilizationPercent,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { projects, nextCursor: result.nextCursor };
|
|
||||||
}),
|
|
||||||
|
|
||||||
delete: adminProcedure
|
delete: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user