feat(timeline): add pulse animation for in-flight drag mutations

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>
This commit is contained in:
2026-04-09 13:28:46 +02:00
parent 7a5e98e2e9
commit 1df208dbcc
386 changed files with 657 additions and 81650 deletions
+1 -14
View File
@@ -1,4 +1,5 @@
import { AllocationStatus, UpdateAllocationSchema } from "@capakraken/shared";
export { toIsoDate, round1, averagePerWorkingDay } from "@capakraken/shared";
import { z } from "zod";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
@@ -64,17 +65,3 @@ export type CreateDemandDraftInput = {
metadata?: Record<string, unknown> | undefined;
};
export function toIsoDate(value: Date) {
return value.toISOString().slice(0, 10);
}
export function round1(value: number) {
return Math.round(value * 10) / 10;
}
export function averagePerWorkingDay(totalHours: number, workingDays: number) {
if (workingDays <= 0) {
return 0;
}
return round1(totalHours / workingDays);
}
+3 -4
View File
@@ -9,6 +9,7 @@ import {
AllocationStatus,
PermissionKey,
SystemRole,
toIsoDateOrNull,
} from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
@@ -269,9 +270,7 @@ const createStaffingCaller = createCallerFactory(staffingRouter);
// ─── Helpers ────────────────────────────────────────────────────────────────
function fmtDate(d: Date | null | undefined): string | null {
return d ? d.toISOString().slice(0, 10) : null;
}
const fmtDate = toIsoDateOrNull;
class AssistantVisibleError extends Error {
constructor(message: string) {
@@ -301,7 +300,7 @@ function formatHolidayCalendarEntry(entry: {
}) {
return {
id: entry.id,
date: fmtDate(entry.date),
date: toIsoDateOrNull(entry.date),
name: entry.name,
isRecurringAnnual: entry.isRecurringAnnual ?? false,
source: entry.source ?? null,
@@ -1,7 +1,7 @@
import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
import type { TRPCContext } from "../trpc.js";
import {
buildBlueprintCreateData,
@@ -32,10 +32,6 @@ type BlueprintDetailReadModel = {
isActive: boolean;
};
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
async function getBlueprintOrThrow(ctx: BlueprintProcedureContext, id: string) {
return findUniqueOrThrow(ctx.db.blueprint.findUnique({ where: { id } }), "Blueprint");
}
@@ -131,25 +127,24 @@ export async function getBlueprintDetailByIdentifier(
}
export async function createBlueprint(ctx: BlueprintProcedureContext, input: BlueprintCreateInput) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const blueprint = await ctx.db.blueprint.create({
data: buildBlueprintCreateData(input),
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "Blueprint",
entityId: blueprint.id,
entityName: blueprint.name,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: { name: input.name, target: input.target, description: input.description },
source: "ui",
});
return blueprint;
}
export async function updateBlueprint(ctx: BlueprintProcedureContext, input: BlueprintUpdateInput) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await getBlueprintOrThrow(ctx, input.id);
const updated = await ctx.db.blueprint.update({
@@ -157,16 +152,13 @@ export async function updateBlueprint(ctx: BlueprintProcedureContext, input: Blu
data: buildBlueprintUpdateData(input.data),
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "Blueprint",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
@@ -176,22 +168,20 @@ export async function updateBlueprintRolePresets(
ctx: BlueprintProcedureContext,
input: BlueprintRolePresetsInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await getBlueprintOrThrow(ctx, input.id);
const updated = await ctx.db.blueprint.update({
where: { id: input.id },
data: buildBlueprintRolePresetsUpdateData(input.rolePresets),
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "Blueprint",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: { rolePresets: before.rolePresets },
after: { rolePresets: input.rolePresets },
source: "ui",
summary: "Updated role presets",
});
@@ -199,19 +189,17 @@ export async function updateBlueprintRolePresets(
}
export async function deleteBlueprint(ctx: BlueprintProcedureContext, input: BlueprintIdInput) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const deleted = await ctx.db.blueprint.update({
where: { id: input.id },
data: { isActive: false },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "Blueprint",
entityId: input.id,
entityName: deleted.name,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
});
return deleted;
@@ -221,6 +209,7 @@ export async function batchDeleteBlueprints(
ctx: BlueprintProcedureContext,
input: BlueprintBatchDeleteInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.blueprint.update({
@@ -231,14 +220,11 @@ export async function batchDeleteBlueprints(
);
for (const blueprint of updated) {
void createAuditEntry({
db: ctx.db,
audit({
entityType: "Blueprint",
entityId: blueprint.id,
entityName: blueprint.name,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
});
}
@@ -261,20 +247,18 @@ export async function setBlueprintGlobal(
ctx: BlueprintProcedureContext,
input: BlueprintSetGlobalInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const updated = await ctx.db.blueprint.update({
where: { id: input.id },
data: { isGlobal: input.isGlobal },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "Blueprint",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
after: { isGlobal: input.isGlobal },
source: "ui",
summary: input.isGlobal ? "Set blueprint as global" : "Removed global flag from blueprint",
});
@@ -10,6 +10,7 @@ import {
getDashboardSkillGapSummary,
getDashboardTopValueResources,
} from "@capakraken/application";
import { round1 } from "@capakraken/shared";
import { z } from "zod";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
@@ -76,10 +77,6 @@ type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
type DashboardChargeabilityOverviewRead = Awaited<ReturnType<typeof getDashboardChargeabilityOverview>>;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function formatPct(value: number): string {
return `${Math.round(value)}%`;
}
@@ -1,4 +1,5 @@
import { VacationType, VacationStatus } from "@capakraken/db";
import { toIsoDate } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
@@ -236,10 +237,6 @@ async function readBalanceSnapshot(
};
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function buildEntitlementHolidayDateUnion(vacations: EntitlementVacationExplainability[]): string[] {
return [...new Set(vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)))].sort();
}
@@ -6,7 +6,7 @@ import {
} from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
import type { TRPCContext } from "../trpc.js";
import { asHolidayCalendarDb } from "./holiday-calendar-shared.js";
import {
@@ -26,10 +26,6 @@ import {
type HolidayCalendarProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export const holidayCalendarIdInputSchema = z.object({
id: z.string(),
});
@@ -54,6 +50,7 @@ export async function createHolidayCalendar(
ctx: HolidayCalendarProcedureContext,
input: HolidayCalendarCreateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const db = asHolidayCalendarDb(ctx.db);
await findUniqueOrThrow(
@@ -83,15 +80,12 @@ export async function createHolidayCalendar(
include: holidayCalendarDetailInclude,
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "HolidayCalendar",
entityId: created.id,
entityName: created.name,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
@@ -101,6 +95,7 @@ export async function updateHolidayCalendar(
ctx: HolidayCalendarProcedureContext,
input: HolidayCalendarUpdateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendar.findUnique({ where: { id: input.id } }),
@@ -131,16 +126,13 @@ export async function updateHolidayCalendar(
include: holidayCalendarDetailInclude,
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "HolidayCalendar",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
@@ -150,6 +142,7 @@ export async function deleteHolidayCalendar(
ctx: HolidayCalendarProcedureContext,
input: HolidayCalendarIdInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendar.findUnique({
@@ -161,15 +154,12 @@ export async function deleteHolidayCalendar(
await db.holidayCalendar.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
audit({
entityType: "HolidayCalendar",
entityId: existing.id,
entityName: existing.name,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
before: existing as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true, id: existing.id, name: existing.name };
@@ -179,6 +169,7 @@ export async function createHolidayCalendarEntry(
ctx: HolidayCalendarProcedureContext,
input: HolidayCalendarEntryCreateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const db = asHolidayCalendarDb(ctx.db);
await findUniqueOrThrow(
@@ -201,15 +192,12 @@ export async function createHolidayCalendarEntry(
}),
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "HolidayCalendarEntry",
entityId: created.id,
entityName: created.name,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
@@ -219,6 +207,7 @@ export async function updateHolidayCalendarEntry(
ctx: HolidayCalendarProcedureContext,
input: HolidayCalendarEntryUpdateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
@@ -241,16 +230,13 @@ export async function updateHolidayCalendarEntry(
}),
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "HolidayCalendarEntry",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
@@ -260,6 +246,7 @@ export async function deleteHolidayCalendarEntry(
ctx: HolidayCalendarProcedureContext,
input: HolidayCalendarIdInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
@@ -268,15 +255,12 @@ export async function deleteHolidayCalendarEntry(
await db.holidayCalendarEntry.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
audit({
entityType: "HolidayCalendarEntry",
entityId: existing.id,
entityName: existing.name,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
before: existing as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true, id: existing.id, name: existing.name };
+9 -13
View File
@@ -1,4 +1,4 @@
import { ProjectStatus } from "@capakraken/shared";
import { ProjectStatus, toIsoDateOrNull } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { fmtEur } from "../lib/format-utils.js";
import type { TRPCContext } from "../trpc.js";
@@ -47,10 +47,6 @@ export const PROJECT_DETAIL_SELECT = {
_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;
@@ -65,8 +61,8 @@ export function mapProjectSummary(project: {
code: project.shortCode,
name: project.name,
status: project.status,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
start: toIsoDateOrNull(project.startDate),
end: toIsoDateOrNull(project.endDate),
client: project.client?.name ?? null,
};
}
@@ -90,8 +86,8 @@ export function mapProjectSummaryDetail(project: {
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),
start: toIsoDateOrNull(project.startDate),
end: toIsoDateOrNull(project.endDate),
client: project.client?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
@@ -134,8 +130,8 @@ export function mapProjectDetail(
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),
start: toIsoDateOrNull(project.startDate),
end: toIsoDateOrNull(project.endDate),
responsible: project.responsiblePerson,
client: project.client?.name ?? null,
category: project.utilizationCategory?.name ?? null,
@@ -147,8 +143,8 @@ export function mapProjectDetail(
role: assignment.role ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
start: formatDate(assignment.startDate),
end: formatDate(assignment.endDate),
start: toIsoDateOrNull(assignment.startDate),
end: toIsoDateOrNull(assignment.endDate),
})),
};
}
@@ -6,7 +6,7 @@ import {
} from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
import type { TRPCContext } from "../trpc.js";
import {
lookupBestRateMatch,
@@ -31,10 +31,6 @@ import {
type RateCardProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export const rateCardListInputSchema = z.object({
isActive: z.boolean().optional(),
search: z.string().optional(),
@@ -144,6 +140,7 @@ export async function createRateCard(
ctx: RateCardProcedureContext,
input: RateCardCreateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const { lines, name, currency } = input;
const rateCard = await ctx.db.rateCard.create({
@@ -151,15 +148,12 @@ export async function createRateCard(
include: rateCardCreateInclude,
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCard",
entityId: rateCard.id,
entityName: rateCard.name,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: buildRateCardCreateAuditAfter({ name, currency, lines }),
source: "ui",
});
return rateCard;
@@ -169,6 +163,7 @@ export async function updateRateCard(
ctx: RateCardProcedureContext,
input: RateCardUpdateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.id } }),
"Rate card",
@@ -180,16 +175,13 @@ export async function updateRateCard(
include: rateCardSummaryInclude,
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCard",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
@@ -199,19 +191,17 @@ export async function deactivateRateCard(
ctx: RateCardProcedureContext,
input: RateCardDeactivateInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const deactivated = await ctx.db.rateCard.update({
where: { id: input.id },
data: { isActive: false },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCard",
entityId: input.id,
entityName: deactivated.name,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "Deactivated rate card",
});
@@ -222,6 +212,7 @@ export async function addRateCardLine(
ctx: RateCardProcedureContext,
input: RateCardAddLineInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const rateCard = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
"Rate card",
@@ -232,18 +223,15 @@ export async function addRateCardLine(
select: rateCardLineSelect,
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCardLine",
entityId: line.id,
entityName: `${rateCard.name} - ${input.line.chapter ?? "line"}`,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: buildRateCardLineCreateAuditAfter({
rateCardId: input.rateCardId,
line: input.line,
}),
source: "ui",
});
return line;
@@ -253,6 +241,7 @@ export async function updateRateCardLine(
ctx: RateCardProcedureContext,
input: RateCardUpdateLineInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
"Rate card line",
@@ -264,15 +253,12 @@ export async function updateRateCardLine(
select: rateCardLineSelect,
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCardLine",
entityId: input.lineId,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
@@ -282,6 +268,7 @@ export async function deleteRateCardLine(
ctx: RateCardProcedureContext,
input: RateCardDeleteLineInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const line = await findUniqueOrThrow(
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
"Rate card line",
@@ -289,14 +276,11 @@ export async function deleteRateCardLine(
await ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCardLine",
entityId: input.lineId,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
before: line as unknown as Record<string, unknown>,
source: "ui",
});
return { deleted: true };
@@ -306,6 +290,7 @@ export async function replaceRateCardLines(
ctx: RateCardProcedureContext,
input: RateCardReplaceLinesInput,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const rateCard = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
"Rate card",
@@ -327,15 +312,12 @@ export async function replaceRateCardLines(
);
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "RateCard",
entityId: input.rateCardId,
entityName: rateCard.name,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
after: buildRateCardReplaceLinesAuditAfter(result.length),
source: "ui",
summary: `Replaced all lines with ${result.length} new lines`,
});
@@ -1,4 +1,5 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import { toIsoDate, type WeekdayAvailability } from "@capakraken/shared";
export { toIsoDate } from "@capakraken/shared";
import { calculateEffectiveBookedHours } from "../lib/resource-capacity.js";
type BookingForCapacity = {
@@ -7,10 +8,6 @@ type BookingForCapacity = {
hoursPerDay: number;
};
export function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
export function buildDailyBookedHoursMap(
bookings: BookingForCapacity[],
availability: WeekdayAvailability,
@@ -1,11 +1,11 @@
import { TRPCError } from "@trpc/server";
import { isAdminOrManager } from "@capakraken/shared";
import type { TRPCContext } from "../trpc.js";
export type OwnedResourceReadContext = Pick<TRPCContext, "db" | "dbUser">;
export function canManageOwnedResourceReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
return isAdminOrManager(ctx.dbUser?.systemRole);
}
export async function findOwnedReadResourceId(
@@ -1,58 +1,11 @@
import { fmtEur } from "../lib/format-utils.js";
export const RESOURCE_SUMMARY_SELECT = {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
areaRole: { select: { name: true } },
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
orgUnit: { select: { name: true } },
} as const;
export const RESOURCE_SUMMARY_DETAIL_SELECT = {
...RESOURCE_SUMMARY_SELECT,
fte: true,
lcrCents: true,
chargeabilityTarget: true,
} as const;
export const RESOURCE_IDENTIFIER_SELECT = {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
} as const;
export const RESOURCE_IDENTIFIER_DETAIL_SELECT = {
...RESOURCE_IDENTIFIER_SELECT,
email: true,
fte: true,
lcrCents: true,
ucrCents: true,
chargeabilityTarget: true,
availability: true,
skills: true,
postalCode: true,
federalState: true,
areaRole: { select: { name: true, color: true } },
country: { select: { code: true, name: true, dailyWorkingHours: true } },
metroCity: { select: { name: true } },
managementLevelGroup: { select: { name: true, targetPercentage: true } },
orgUnit: { select: { name: true, level: true } },
_count: { select: { assignments: true, vacations: true } },
} as const;
export const RESOURCE_DIRECTORY_SELECT = {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
} as const;
export {
RESOURCE_DIRECTORY_SELECT,
RESOURCE_IDENTIFIER_DETAIL_SELECT,
RESOURCE_IDENTIFIER_SELECT,
RESOURCE_SUMMARY_DETAIL_SELECT,
RESOURCE_SUMMARY_SELECT,
} from "../db/selects.js";
export function mapResourceSummary(resource: {
id: string;
@@ -1,7 +1,14 @@
import { FieldType, ResourceType } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import {
ROLE_BRIEF_SELECT,
RESOURCE_DIRECTORY_SELECT,
RESOURCE_IDENTIFIER_DETAIL_SELECT,
RESOURCE_IDENTIFIER_SELECT,
RESOURCE_SUMMARY_DETAIL_SELECT,
RESOURCE_SUMMARY_SELECT,
} from "../db/selects.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
anonymizeResources,
@@ -15,13 +22,6 @@ import {
canReadAllResources,
type ResourceReadContext,
} from "../lib/resource-access.js";
import {
RESOURCE_DIRECTORY_SELECT,
RESOURCE_IDENTIFIER_DETAIL_SELECT,
RESOURCE_IDENTIFIER_SELECT,
RESOURCE_SUMMARY_DETAIL_SELECT,
RESOURCE_SUMMARY_SELECT,
} from "./resource-read-models.js";
function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null {
if (!cursor) return null;
@@ -6,6 +6,7 @@ import {
type AssignmentSlice,
} from "@capakraken/engine";
import { VacationStatus } from "@capakraken/db";
import { round1, averagePerWorkingDay } from "@capakraken/shared";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import {
@@ -106,13 +107,6 @@ function summarizeResolvedHolidaySummary(holidays: Array<ReturnType<typeof forma
};
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function averagePerWorkingDay(totalHours: number, workingDays: number): number {
return workingDays > 0 ? round1(totalHours / workingDays) : 0;
}
export async function resolveResponsiblePersonName(
ctx: ResourceSummaryReadContext,
@@ -1,5 +1,5 @@
import { listAssignmentBookings } from "@capakraken/application";
import { type WeekdayAvailability } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY, type WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
@@ -378,7 +378,7 @@ export const staffingCapacityReadProcedures = {
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
closeWindow(new Date(end.getTime() + 86_400_000));
closeWindow(new Date(end.getTime() + MILLISECONDS_PER_DAY));
return windows;
}),
+5 -35
View File
@@ -1,22 +1,9 @@
import { type WeekdayAvailability } from "@capakraken/shared";
import { type ResourceDailyAvailabilityContext } from "../lib/resource-capacity.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
import { toIsoDate, type WeekdayAvailability } from "@capakraken/shared";
export { toIsoDate, round1, averagePerWorkingDay } from "@capakraken/shared";
import { getAvailabilityHoursForDate, calculateEffectiveDayAvailability as _calcEffective, type ResourceDailyAvailabilityContext } from "../lib/resource-capacity.js";
export const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
export function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function createUtcDate(year: number, monthIndex: number, day: number): Date {
return new Date(Date.UTC(year, monthIndex, day));
}
@@ -48,16 +35,11 @@ export function createDateRange(input: {
return { startDate, endDate };
}
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}
export function getBaseDayAvailability(
availability: WeekdayAvailability,
date: Date,
): number {
const key = DAY_KEYS[date.getUTCDay()];
return key ? (availability[key] ?? 0) : 0;
return getAvailabilityHoursForDate(availability, date);
}
export function getEffectiveDayAvailability(
@@ -65,25 +47,13 @@ export function getEffectiveDayAvailability(
date: Date,
context: ResourceDailyAvailabilityContext | undefined,
): number {
const key = DAY_KEYS[date.getUTCDay()];
const baseHours = key ? (availability[key] ?? 0) : 0;
if (baseHours <= 0) {
return 0;
}
const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0;
return Math.max(0, baseHours * (1 - fraction));
return _calcEffective({ availability, date, context });
}
function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean {
return date >= startDate && date <= endDate;
}
export function averagePerWorkingDay(totalHours: number, workingDays: number): number {
if (workingDays <= 0) {
return 0;
}
return round1(totalHours / workingDays);
}
export function createLocationLabel(input: {
countryCode?: string | null;
@@ -1,6 +1,6 @@
import { rankResources } from "@capakraken/staffing";
import { listAssignmentBookings } from "@capakraken/application";
import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
import { PermissionKey, toIsoDateOrNull, type WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
@@ -17,10 +17,6 @@ import {
} from "./staffing-shared.js";
import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js";
function fmtDate(value: Date | null | undefined): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
type StaffingSuggestionInput = {
requiredSkills: string[];
preferredSkills?: string[] | undefined;
@@ -415,7 +411,7 @@ export const staffingSuggestionsReadProcedures = {
});
return {
project: `${project.name} (${project.shortCode})`,
period: `${fmtDate(startDate)} to ${fmtDate(endDate)}`,
period: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(endDate)}`,
suggestions: suggestions
.filter((suggestion) => {
if (!normalizedRoleFilter) {
@@ -1,5 +1,5 @@
import {
createAssignment,
createAssignmentFragment,
deleteAllocationEntry,
loadAllocationEntry,
updateAssignment,
@@ -162,8 +162,8 @@ export async function carveTimelineAllocationRange(input: {
updatedAllocationIds.push(updated.id);
if (hasLeftFragment && hasRightFragment) {
const created = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
const created = await createAssignmentFragment(
tx as unknown as Parameters<typeof createAssignmentFragment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
@@ -256,46 +256,30 @@ export async function extractTimelineAllocationFragment(input: {
},
);
if (hasLeftFragment) {
const createdLeft = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignmentStart,
endDate: addDays(extractStart, -1),
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(createdLeft.id);
}
const fragmentBase = {
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
};
const txClient = tx as unknown as Parameters<typeof createAssignmentFragment>[0];
if (hasRightFragment) {
const createdRight = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: addDays(extractEnd, 1),
endDate: assignmentEnd,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(createdRight.id);
const fragments = await Promise.all([
hasLeftFragment
? createAssignmentFragment(txClient, { ...fragmentBase, startDate: assignmentStart, endDate: addDays(extractStart, -1) })
: null,
hasRightFragment
? createAssignmentFragment(txClient, { ...fragmentBase, startDate: addDays(extractEnd, 1), endDate: assignmentEnd })
: null,
]);
for (const frag of fragments) {
if (frag) createdAllocationIds.push(frag.id);
}
return {
@@ -45,12 +45,7 @@ export function getAssignmentResourceIds(
];
}
export function fmtDate(value: Date | null | undefined): string | null {
if (!value) {
return null;
}
return value.toISOString().slice(0, 10);
}
export { toIsoDateOrNull as fmtDate } from "@capakraken/shared";
export function createEmptyTimelineEntriesView() {
return buildSplitAllocationReadModel({
@@ -3,7 +3,7 @@ import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
import type { TRPCContext } from "../trpc.js";
export const CreateUserInputSchema = z.object({
@@ -51,10 +51,6 @@ export const UserIdInputSchema = z.object({
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
type UserMutationContext = UserReadContext;
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export async function listAssignableUsers(ctx: UserReadContext) {
return ctx.db.user.findMany({
select: {
@@ -113,6 +109,7 @@ export async function createUser(
ctx: UserMutationContext,
input: z.infer<typeof CreateUserInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
@@ -142,15 +139,12 @@ export async function createUser(
});
}
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
after: user as unknown as Record<string, unknown>,
source: "ui",
});
return user;
@@ -160,6 +154,7 @@ export async function setUserPassword(
ctx: UserMutationContext,
input: z.infer<typeof SetUserPasswordInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
@@ -176,14 +171,11 @@ export async function setUserPassword(
data: { passwordHash },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "Password reset by admin",
});
@@ -194,6 +186,7 @@ export async function updateUserRole(
ctx: UserMutationContext,
input: z.infer<typeof UpdateUserRoleInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.id },
@@ -208,16 +201,13 @@ export async function updateUserRole(
select: { id: true, name: true, email: true, systemRole: true },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: updated.id,
entityName: `${updated.name} (${updated.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`,
});
@@ -228,6 +218,7 @@ export async function updateUserName(
ctx: UserMutationContext,
input: z.infer<typeof UpdateUserNameInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.id },
@@ -242,16 +233,13 @@ export async function updateUserName(
select: { id: true, name: true, email: true },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: updated.id,
entityName: `${updated.name} (${updated.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed name from "${before.name}" to "${updated.name}"`,
});
@@ -381,6 +369,7 @@ export async function setUserPermissions(
ctx: UserMutationContext,
input: z.infer<typeof SetUserPermissionsInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
@@ -394,16 +383,13 @@ export async function setUserPermissions(
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: input.userId,
entityName: `${before.name} (${before.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
source: "ui",
summary: input.overrides
? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})`
: "Cleared permission overrides",
@@ -416,6 +402,7 @@ export async function resetUserPermissions(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
@@ -429,16 +416,13 @@ export async function resetUserPermissions(
data: { permissionOverrides: Prisma.DbNull },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: input.userId,
entityName: `${before.name} (${before.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
source: "ui",
summary: "Reset permission overrides to role defaults",
});
@@ -472,6 +456,7 @@ export async function deactivateUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account." });
}
@@ -493,14 +478,11 @@ export async function deactivateUser(
// Invalidate all existing sessions so the user is logged out immediately
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "User deactivated",
});
@@ -511,6 +493,7 @@ export async function reactivateUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
@@ -525,14 +508,11 @@ export async function reactivateUser(
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true } });
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "User reactivated",
});
@@ -543,6 +523,7 @@ export async function deleteUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot delete your own account." });
}
@@ -563,14 +544,11 @@ export async function deleteUser(
// Unlink resource (nullable FK — belt-and-suspenders)
await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null } });
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "User account permanently deleted",
});
@@ -586,6 +564,7 @@ export async function disableTotp(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
@@ -599,14 +578,11 @@ export async function disableTotp(
data: { totpEnabled: false, totpSecret: null },
});
void createAuditEntry({
db: ctx.db,
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "Disabled TOTP MFA (admin override)",
});
@@ -1,5 +1,6 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { isAdminOrManager } from "@capakraken/shared";
import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated } from "../sse/event-bus.js";
@@ -76,7 +77,7 @@ export async function createVacationRequest(
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
const isManager = isAdminOrManager(userRecord.systemRole);
if (!isManager) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
@@ -1,6 +1,7 @@
import type { Prisma } from "@capakraken/db";
import { VacationStatus } from "@capakraken/db";
import type { UpdateVacationStatusInput } from "@capakraken/shared";
import { isAdminOrManager } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
type VacationApprovalWriteData = Record<string, unknown>;
@@ -39,7 +40,7 @@ export function assertVacationCancelable(status: VacationStatus): void {
}
export function isVacationManagerRole(role: string | null | undefined): boolean {
return role === "ADMIN" || role === "MANAGER";
return isAdminOrManager(role);
}
export function canActorCancelVacation(input: {