refactor(api): extract timeline cost support
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
import { VacationType } from "@capakraken/db";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildTimelineAbsenceDays,
|
||||||
|
loadTimelineCalculationRules,
|
||||||
|
} from "../router/timeline-cost-support.js";
|
||||||
|
|
||||||
|
describe("timeline cost support", () => {
|
||||||
|
it("falls back to default calculation rules when the optional model is unavailable", async () => {
|
||||||
|
const rules = await loadTimelineCalculationRules({
|
||||||
|
vacation: {
|
||||||
|
findMany: async () => [],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(rules.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands approved vacations, sickness, and public holidays into absence days", async () => {
|
||||||
|
const result = await buildTimelineAbsenceDays({
|
||||||
|
vacation: {
|
||||||
|
findMany: async () => [
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-03T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-04T00:00:00.000Z"),
|
||||||
|
type: VacationType.VACATION,
|
||||||
|
isHalfDay: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
type: VacationType.SICK,
|
||||||
|
isHalfDay: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
type: VacationType.PUBLIC_HOLIDAY,
|
||||||
|
isHalfDay: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never, "resource_1", new Date("2026-04-01T00:00:00.000Z"), new Date("2026-04-10T00:00:00.000Z"));
|
||||||
|
|
||||||
|
expect(result.absenceDays).toEqual([
|
||||||
|
{
|
||||||
|
date: new Date("2026-04-03T00:00:00.000Z"),
|
||||||
|
type: "VACATION",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: new Date("2026-04-04T00:00:00.000Z"),
|
||||||
|
type: "VACATION",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
type: "SICK",
|
||||||
|
isHalfDay: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
type: "PUBLIC_HOLIDAY",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.legacyVacationDates).toEqual([
|
||||||
|
new Date("2026-04-03T00:00:00.000Z"),
|
||||||
|
new Date("2026-04-04T00:00:00.000Z"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats missing optional vacation tables as no absences", async () => {
|
||||||
|
const result = await buildTimelineAbsenceDays({
|
||||||
|
vacation: {
|
||||||
|
findMany: async () => {
|
||||||
|
throw {
|
||||||
|
code: "P2021",
|
||||||
|
message: "The table `vacations` does not exist.",
|
||||||
|
meta: { table: "vacations" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never, "resource_1", new Date("2026-04-01T00:00:00.000Z"), new Date("2026-04-10T00:00:00.000Z"));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
absenceDays: [],
|
||||||
|
legacyVacationDates: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,10 +5,7 @@ import {
|
|||||||
loadAllocationEntry,
|
loadAllocationEntry,
|
||||||
updateAllocationEntry,
|
updateAllocationEntry,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import { Prisma, VacationType } from "@capakraken/db";
|
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
|
||||||
import type { AbsenceDay, CalculationRule } from "@capakraken/shared";
|
|
||||||
import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -16,119 +13,8 @@ import {
|
|||||||
emitAllocationCreated,
|
emitAllocationCreated,
|
||||||
emitAllocationUpdated,
|
emitAllocationUpdated,
|
||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
import { logger } from "../lib/logger.js";
|
|
||||||
import { managerProcedure, requirePermission } from "../trpc.js";
|
import { managerProcedure, requirePermission } from "../trpc.js";
|
||||||
|
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
||||||
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
if (error.code !== "P2021") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
|
|
||||||
const message = error.message.toLowerCase();
|
|
||||||
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = error as {
|
|
||||||
code?: unknown;
|
|
||||||
message?: unknown;
|
|
||||||
meta?: { table?: unknown };
|
|
||||||
};
|
|
||||||
const code = typeof candidate.code === "string" ? candidate.code : "";
|
|
||||||
if (code !== "P2021") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
|
|
||||||
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
|
|
||||||
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
|
||||||
const calculationRuleModel = (db as PrismaClient & {
|
|
||||||
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
|
|
||||||
}).calculationRule;
|
|
||||||
|
|
||||||
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
|
|
||||||
return DEFAULT_CALCULATION_RULES;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rules = await calculationRuleModel.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: [{ priority: "desc" }],
|
|
||||||
});
|
|
||||||
if (rules.length > 0) {
|
|
||||||
return rules as unknown as CalculationRule[];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
|
|
||||||
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DEFAULT_CALCULATION_RULES;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildAbsenceDays(
|
|
||||||
db: PrismaClient,
|
|
||||||
resourceId: string,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date,
|
|
||||||
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
|
|
||||||
const absenceDays: AbsenceDay[] = [];
|
|
||||||
const legacyVacationDates: Date[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const vacations = await db.vacation.findMany({
|
|
||||||
where: {
|
|
||||||
resourceId,
|
|
||||||
status: "APPROVED",
|
|
||||||
startDate: { lte: endDate },
|
|
||||||
endDate: { gte: startDate },
|
|
||||||
},
|
|
||||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const vacation of vacations) {
|
|
||||||
const cur = new Date(vacation.startDate);
|
|
||||||
cur.setHours(0, 0, 0, 0);
|
|
||||||
const vacationEnd = new Date(vacation.endDate);
|
|
||||||
vacationEnd.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const triggerType = vacation.type === VacationType.SICK
|
|
||||||
? "SICK" as const
|
|
||||||
: vacation.type === VacationType.PUBLIC_HOLIDAY
|
|
||||||
? "PUBLIC_HOLIDAY" as const
|
|
||||||
: "VACATION" as const;
|
|
||||||
|
|
||||||
while (cur <= vacationEnd) {
|
|
||||||
absenceDays.push({
|
|
||||||
date: new Date(cur),
|
|
||||||
type: triggerType,
|
|
||||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
|
||||||
});
|
|
||||||
if (triggerType === "VACATION") {
|
|
||||||
legacyVacationDates.push(new Date(cur));
|
|
||||||
}
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
|
|
||||||
logger.error(
|
|
||||||
{ err: error, resourceId, startDate, endDate },
|
|
||||||
"Failed to load timeline absence days",
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { absenceDays, legacyVacationDates };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const timelineAllocationMutationProcedures = {
|
export const timelineAllocationMutationProcedures = {
|
||||||
updateAllocationInline: managerProcedure
|
updateAllocationInline: managerProcedure
|
||||||
@@ -174,13 +60,9 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
const availability =
|
const availability =
|
||||||
existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;
|
existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;
|
||||||
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
|
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
|
||||||
|
newDailyCostCents = await calculateTimelineAllocationDailyCost({
|
||||||
const [absenceData, calculationRules] = await Promise.all([
|
db: ctx.db as PrismaClient,
|
||||||
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
|
resourceId: resolved.resourceId,
|
||||||
loadCalculationRules(ctx.db as PrismaClient),
|
|
||||||
]);
|
|
||||||
|
|
||||||
newDailyCostCents = calculateAllocation({
|
|
||||||
lcrCents: existingResource.lcrCents,
|
lcrCents: existingResource.lcrCents,
|
||||||
hoursPerDay: newHoursPerDay,
|
hoursPerDay: newHoursPerDay,
|
||||||
startDate: newStartDate,
|
startDate: newStartDate,
|
||||||
@@ -188,10 +70,7 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
availability,
|
availability,
|
||||||
includeSaturday,
|
includeSaturday,
|
||||||
...(recurrence ? { recurrence } : {}),
|
...(recurrence ? { recurrence } : {}),
|
||||||
vacationDates: absenceData.legacyVacationDates,
|
});
|
||||||
absenceDays: absenceData.absenceDays,
|
|
||||||
calculationRules,
|
|
||||||
}).dailyCostCents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await ctx.db.$transaction(async (tx) => {
|
const updated = await ctx.db.$transaction(async (tx) => {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Prisma, VacationType, type PrismaClient } from "@capakraken/db";
|
||||||
|
import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
||||||
|
import type {
|
||||||
|
AbsenceDay,
|
||||||
|
CalculationRule,
|
||||||
|
RecurrencePattern,
|
||||||
|
WeekdayAvailability,
|
||||||
|
} from "@capakraken/shared";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
|
||||||
|
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code !== "P2021") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = error as {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
meta?: { table?: unknown };
|
||||||
|
};
|
||||||
|
const code = typeof candidate.code === "string" ? candidate.code : "";
|
||||||
|
if (code !== "P2021") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
|
||||||
|
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
|
||||||
|
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTimelineCalculationRules(
|
||||||
|
db: PrismaClient,
|
||||||
|
): Promise<CalculationRule[]> {
|
||||||
|
const calculationRuleModel = (db as PrismaClient & {
|
||||||
|
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
|
||||||
|
}).calculationRule;
|
||||||
|
|
||||||
|
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
|
||||||
|
return DEFAULT_CALCULATION_RULES;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rules = await calculationRuleModel.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ priority: "desc" }],
|
||||||
|
});
|
||||||
|
if (rules.length > 0) {
|
||||||
|
return rules as unknown as CalculationRule[];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
|
||||||
|
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_CALCULATION_RULES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTimelineAbsenceDays(
|
||||||
|
db: PrismaClient,
|
||||||
|
resourceId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
|
||||||
|
const absenceDays: AbsenceDay[] = [];
|
||||||
|
const legacyVacationDates: Date[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vacations = await db.vacation.findMany({
|
||||||
|
where: {
|
||||||
|
resourceId,
|
||||||
|
status: "APPROVED",
|
||||||
|
startDate: { lte: endDate },
|
||||||
|
endDate: { gte: startDate },
|
||||||
|
},
|
||||||
|
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const vacation of vacations) {
|
||||||
|
const cur = new Date(vacation.startDate);
|
||||||
|
cur.setUTCHours(0, 0, 0, 0);
|
||||||
|
const vacationEnd = new Date(vacation.endDate);
|
||||||
|
vacationEnd.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const triggerType = vacation.type === VacationType.SICK
|
||||||
|
? "SICK" as const
|
||||||
|
: vacation.type === VacationType.PUBLIC_HOLIDAY
|
||||||
|
? "PUBLIC_HOLIDAY" as const
|
||||||
|
: "VACATION" as const;
|
||||||
|
|
||||||
|
while (cur <= vacationEnd) {
|
||||||
|
absenceDays.push({
|
||||||
|
date: new Date(cur),
|
||||||
|
type: triggerType,
|
||||||
|
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||||
|
});
|
||||||
|
if (triggerType === "VACATION") {
|
||||||
|
legacyVacationDates.push(new Date(cur));
|
||||||
|
}
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, resourceId, startDate, endDate },
|
||||||
|
"Failed to load timeline absence days",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { absenceDays, legacyVacationDates };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calculateTimelineAllocationDailyCost(input: {
|
||||||
|
db: PrismaClient;
|
||||||
|
resourceId: string;
|
||||||
|
lcrCents: number;
|
||||||
|
hoursPerDay: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
availability: WeekdayAvailability;
|
||||||
|
includeSaturday?: boolean | undefined;
|
||||||
|
recurrence?: RecurrencePattern | undefined;
|
||||||
|
}): Promise<number> {
|
||||||
|
const [absenceData, calculationRules] = await Promise.all([
|
||||||
|
buildTimelineAbsenceDays(input.db, input.resourceId, input.startDate, input.endDate),
|
||||||
|
loadTimelineCalculationRules(input.db),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return calculateAllocation({
|
||||||
|
lcrCents: input.lcrCents,
|
||||||
|
hoursPerDay: input.hoursPerDay,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
availability: input.availability,
|
||||||
|
includeSaturday: input.includeSaturday ?? false,
|
||||||
|
...(input.recurrence ? { recurrence: input.recurrence } : {}),
|
||||||
|
vacationDates: absenceData.legacyVacationDates,
|
||||||
|
absenceDays: absenceData.absenceDays,
|
||||||
|
calculationRules,
|
||||||
|
}).dailyCostCents;
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
type SplitDemandRequirementRecord,
|
type SplitDemandRequirementRecord,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import { Prisma, type PrismaClient } from "@capakraken/db";
|
import { Prisma, type PrismaClient } from "@capakraken/db";
|
||||||
import { calculateAllocation, validateShift } from "@capakraken/engine";
|
import { validateShift } from "@capakraken/engine";
|
||||||
import type { ShiftValidationResult, WeekdayAvailability } from "@capakraken/shared";
|
import type { ShiftValidationResult } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { buildAbsenceDays, loadCalculationRules } from "./timeline-allocation-mutations.js";
|
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
||||||
import type { TimelineShiftPlan } from "./timeline-shift-planning.js";
|
import type { TimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||||
|
|
||||||
export interface TimelineShiftProjectRecord {
|
export interface TimelineShiftProjectRecord {
|
||||||
@@ -123,27 +123,16 @@ async function recalculateShiftedAssignmentDailyCost(input: {
|
|||||||
|
|
||||||
const metadata = (input.assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
|
const metadata = (input.assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
|
||||||
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
|
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
|
||||||
const [shiftAbsenceData, shiftRules] = await Promise.all([
|
return calculateTimelineAllocationDailyCost({
|
||||||
buildAbsenceDays(
|
db: input.db,
|
||||||
input.db,
|
resourceId: input.assignment.resourceId,
|
||||||
input.assignment.resourceId,
|
|
||||||
input.newStartDate,
|
|
||||||
input.newEndDate,
|
|
||||||
),
|
|
||||||
loadCalculationRules(input.db),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return calculateAllocation({
|
|
||||||
lcrCents: input.assignment.resource.lcrCents,
|
lcrCents: input.assignment.resource.lcrCents,
|
||||||
hoursPerDay: input.assignment.hoursPerDay,
|
hoursPerDay: input.assignment.hoursPerDay,
|
||||||
startDate: input.newStartDate,
|
startDate: input.newStartDate,
|
||||||
endDate: input.newEndDate,
|
endDate: input.newEndDate,
|
||||||
availability: input.assignment.resource.availability as WeekdayAvailability,
|
availability: input.assignment.resource.availability as import("@capakraken/shared").WeekdayAvailability,
|
||||||
includeSaturday,
|
includeSaturday,
|
||||||
vacationDates: shiftAbsenceData.legacyVacationDates,
|
});
|
||||||
absenceDays: shiftAbsenceData.absenceDays,
|
|
||||||
calculationRules: shiftRules,
|
|
||||||
}).dailyCostCents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) {
|
export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) {
|
||||||
|
|||||||
Reference in New Issue
Block a user