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
@@ -19,7 +19,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
...actual,
loadAllocationEntry: loadAllocationEntryMock,
updateAssignment: updateAssignmentMock,
createAssignment: createAssignmentMock,
createAssignmentFragment: createAssignmentMock,
deleteAllocationEntry: deleteAllocationEntryMock,
};
});
+54
View File
@@ -1,3 +1,57 @@
export const ROLE_BRIEF_SELECT = { id: true, name: true, color: true } as const;
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true, endDate: true } as const;
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true, chapter: true } as const;
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;
+23 -1
View File
@@ -62,6 +62,10 @@ type StoredAliasMap = Record<string, StoredAliasEntry>;
const ALIAS_NAME_RE = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
const ALIAS_SLUG_RE = /^[a-z]+(?:\.[a-z]+)*$/;
// Module-level TTL cache for the anonymization directory (60 s)
let _directoryCache: { value: AnonymizationDirectory | null; expiresAt: number } | null = null;
const DIRECTORY_TTL_MS = 60_000;
const ICONIC_ALIAS_NAMES = [
"Iron Man",
"Spider Man",
@@ -650,6 +654,10 @@ export async function getAnonymizationConfig(
};
}
export function invalidateAnonymizationDirectoryCache() {
_directoryCache = null;
}
export async function getAnonymizationDirectory(
db: Pick<PrismaClient, "systemSettings" | "resource">,
): Promise<AnonymizationDirectory | null> {
@@ -657,6 +665,11 @@ export async function getAnonymizationDirectory(
return null;
}
const now = Date.now();
if (_directoryCache && _directoryCache.expiresAt > now) {
return _directoryCache.value;
}
const settings = await db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
@@ -736,13 +749,22 @@ export async function getAnonymizationDirectory(
where: { id: "singleton" },
data: { anonymizationAliases: storedAliases },
});
// Invalidate stale cache after a DB write so the next call re-fetches
_directoryCache = null;
}
return {
const directory: AnonymizationDirectory = {
config,
byResourceId,
byAliasEid,
};
// Only cache stable directories (no alias changes = steady state)
if (!aliasesChanged) {
_directoryCache = { value: directory, expiresAt: Date.now() + DIRECTORY_TTL_MS };
}
return directory;
}
export function anonymizeResource<T extends ResourceIdentity>(
+2 -15
View File
@@ -1,4 +1,5 @@
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
import { getPublicHolidays, toIsoDate, normalizeCityName, normalizeStateCode, type AbsenceDay } from "@capakraken/shared";
export { toIsoDate } from "@capakraken/shared";
type VacationLike = {
startDate: Date;
@@ -69,10 +70,6 @@ export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
return db as HolidayResolverDb;
}
export function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
type CityHolidayRule = {
countryCode: string;
cityName: string;
@@ -93,16 +90,6 @@ const SCOPE_WEIGHT: Record<CalendarScope, number> = {
CITY: 3,
};
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function resolveCalendarEntries(
calendars: HolidayCalendarRecord[],
periodStart: Date,
+2 -1
View File
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { getCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
type VacationSpan = {
@@ -59,7 +60,7 @@ export function countCalendarDaysInPeriod(
}
const ms = overlap.end.getTime() - overlap.start.getTime();
return Math.round(ms / 86_400_000) + 1;
return Math.round(ms / MILLISECONDS_PER_DAY) + 1;
}
export function countVacationChargeableDays(
+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: {
+1
View File
@@ -6,6 +6,7 @@ export { updateDemandRequirement } from "./use-cases/allocation/update-demand-re
export {
createAssignment,
createAssignmentFragment,
type AssignmentWithRelations,
} from "./use-cases/allocation/create-assignment.js";
export { updateAssignment } from "./use-cases/allocation/update-assignment.js";
@@ -1,7 +1,5 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
const MILLISECONDS_PER_DAY = 86_400_000;
import { getPublicHolidays, toIsoDate, MILLISECONDS_PER_DAY, DAY_KEYS, normalizeCityName, normalizeStateCode, type WeekdayAvailability } from "@capakraken/shared";
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
@@ -54,16 +52,6 @@ type ResourceCapacityDbClient = {
};
};
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const CITY_HOLIDAY_RULES: Array<{
countryCode: string;
cityName: string;
@@ -76,20 +64,6 @@ const CITY_HOLIDAY_RULES: Array<{
},
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
export function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
import {
calculateEffectiveAllocationCostCents,
@@ -46,20 +46,6 @@ export interface BudgetForecastRow {
derivation?: BudgetForecastDerivationSummary;
}
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
@@ -12,16 +12,6 @@ import {
type DailyAvailabilityContext,
} from "./holiday-capacity.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
export interface GetDashboardChargeabilityOverviewInput {
includeProposed?: boolean;
topN: number;
@@ -72,10 +62,6 @@ export interface DashboardChargeabilityOverview {
month: string;
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { toIsoDate, type WeekdayAvailability } from "@capakraken/shared";
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
import { calculateAllocationHours } from "./shared.js";
import {
@@ -66,10 +66,6 @@ function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function buildLocationKey(input: {
countryCode: string | null | undefined;
countryName: string | null | undefined;
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
import { getMonthBucketKey, getWeekBucketKey } from "./shared.js";
import {
@@ -81,20 +81,6 @@ type PeakTimesCapacityDerivationSummary = Pick<
| "calendarLocations"
>;
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { toIsoDate, MILLISECONDS_PER_DAY, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
import { calculateInclusiveDays } from "./shared.js";
import {
calculateEffectiveAllocationCostCents,
@@ -51,26 +51,12 @@ export interface ProjectHealthRow {
};
}
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
function hasAvailability<T extends { availability?: unknown }>(
resource: T | null | undefined,
): resource is T & { availability: WeekdayAvailability } {
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
@@ -360,7 +346,7 @@ export async function getDashboardProjectHealth(
const endDate = p.endDate ? toUtcDayStart(p.endDate) : null;
const today = toUtcDayStart(now);
const daysUntilEndDate = endDate
? Math.round((endDate.getTime() - today.getTime()) / 86_400_000)
? Math.round((endDate.getTime() - today.getTime()) / MILLISECONDS_PER_DAY)
: null;
const timelineStatus = endDate === null
? "UNSCHEDULED"
@@ -1,4 +1,5 @@
export const MILLISECONDS_PER_DAY = 86_400_000;
export { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
export function calculateInclusiveDays(startDate: Date, endDate: Date): number {
return (endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY + 1;
@@ -1,4 +1,4 @@
import { getPublicHolidays } from "@capakraken/shared";
import { getPublicHolidays, toIsoDate } from "@capakraken/shared";
export type HolidayCalendarSeedScope = "COUNTRY" | "STATE" | "CITY";
@@ -34,10 +34,6 @@ const COUNTRY_PRIORITY = 10;
const STATE_PRIORITY = 20;
const CITY_PRIORITY = 30;
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function dateUtc(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
@@ -1,4 +1,4 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import { DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
export interface ChargeabilityAllocation {
startDate: Date;
@@ -12,11 +12,6 @@ export interface ChargeabilityResult {
chargeability: number; // 0-100, rounded
}
// Maps JS getDay() (0=Sun..6=Sat) to WeekdayAvailability keys
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
];
/** Count working hours a resource has available in [start, end] based on their schedule. */
export function computeAvailableHours(
availability: WeekdayAvailability,
@@ -10,6 +10,8 @@
* - Aggregation by chapter for 4Dispo view
*/
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
export interface WeekDefinition {
weekNumber: number;
year: number;
@@ -42,7 +44,7 @@ function getISOWeekData(date: Date): { year: number; week: number } {
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / MILLISECONDS_PER_DAY + 1) / 7);
return { year: d.getUTCFullYear(), week };
}
+3 -7
View File
@@ -5,6 +5,7 @@
* It is the denominator for chargeability calculations.
*/
import { toIsoDate } from "@capakraken/shared";
import type { SpainScheduleRule } from "@capakraken/shared";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -47,11 +48,6 @@ export interface SAHResult {
// ─── Helpers ────────────────────────────────────────────────────────────────
function toISODate(d: Date | string): string {
if (typeof d === "string") return d.slice(0, 10);
return d.toISOString().slice(0, 10);
}
function isWeekend(date: Date): boolean {
const day = date.getUTCDay();
return day === 0 || day === 6;
@@ -93,8 +89,8 @@ export function getDailyHours(
export function calculateSAH(input: SAHInput): SAHResult {
const { dailyWorkingHours, scheduleRules, fte, periodStart, periodEnd, publicHolidays, absenceDays } = input;
const holidaySet = new Set(publicHolidays.map(toISODate));
const absenceSet = new Set(absenceDays.map(toISODate));
const holidaySet = new Set(publicHolidays.map(toIsoDate));
const absenceSet = new Set(absenceDays.map(toIsoDate));
let calendarDays = 0;
let weekendDays = 0;
+37
View File
@@ -1,5 +1,42 @@
import type { WeekdayAvailability } from "../types/resource.js";
export * from "./germanStates.js";
export * from "./publicHolidays.js";
export const MILLISECONDS_PER_DAY = 86_400_000;
export function toIsoDate(value: Date | string): string {
if (typeof value === "string") return value.slice(0, 10);
return value.toISOString().slice(0, 10);
}
export function toIsoDateOrNull(value: Date | string | null | undefined): string | null {
if (!value) return null;
return toIsoDate(value);
}
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}
export function averagePerWorkingDay(totalHours: number, workingDays: number): number {
if (workingDays <= 0) return 0;
return round1(totalHours / workingDays);
}
export const DAY_KEYS: readonly (keyof WeekdayAvailability)[] = [
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
] as const;
export function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
export function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
export * from "./columns.js";
export * from "./dispo-import.js";
export * from "./data-classification.js";
+1
View File
@@ -19,3 +19,4 @@ export * from "./client.js";
export * from "./management-level.js";
export * from "./dispo-import.js";
export * from "./calculation-rules.js";
export * from "./tool-manifest.js";
+4
View File
@@ -51,6 +51,10 @@ export const ROLE_DEFAULT_PERMISSIONS: Record<SystemRole, PermissionKey[]> = {
VIEWER: [],
};
export function isAdminOrManager(role: string | null | undefined): boolean {
return role === SystemRole.ADMIN || role === SystemRole.MANAGER;
}
export function resolvePermissions(
systemRole: SystemRole,
overrides?: PermissionOverrides | null,
+8 -7
View File
@@ -1,9 +1,10 @@
import type {
Allocation,
CapacityWindow,
Resource,
UtilizationAnalysis,
UtilizationPeriod,
import {
MILLISECONDS_PER_DAY,
type Allocation,
type CapacityWindow,
type Resource,
type UtilizationAnalysis,
type UtilizationPeriod,
} from "@capakraken/shared";
export interface CapacityAnalysisInput {
@@ -186,7 +187,7 @@ export function findCapacityWindows(
current.setDate(current.getDate() + 1);
}
closeWindow(new Date(end.getTime() + 86400000));
closeWindow(new Date(end.getTime() + MILLISECONDS_PER_DAY));
return windows;
}
-26
View File
@@ -1,26 +0,0 @@
{
"name": "@capakraken/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@capakraken/tsconfig": "workspace:*",
"@types/react": "^19.0.6",
"typescript": "^5.6.3"
}
}
-29
View File
@@ -1,29 +0,0 @@
import { clsx } from "clsx";
interface BadgeProps {
children: React.ReactNode;
variant?: "default" | "success" | "warning" | "danger" | "info";
className?: string;
}
const variantClasses = {
default: "bg-gray-100 text-gray-700",
success: "bg-green-100 text-green-700",
warning: "bg-yellow-100 text-yellow-700",
danger: "bg-red-100 text-red-700",
info: "bg-blue-100 text-blue-700",
};
export function Badge({ children, variant = "default", className }: BadgeProps) {
return (
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
variantClasses[variant],
className,
)}
>
{children}
</span>
);
}
-47
View File
@@ -1,47 +0,0 @@
import { clsx } from "clsx";
import type { ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
}
const variantClasses = {
primary: "bg-brand-600 hover:bg-brand-700 text-white",
secondary: "bg-white hover:bg-gray-50 text-gray-700 border border-gray-300",
ghost: "text-gray-600 hover:bg-gray-100",
danger: "bg-red-600 hover:bg-red-700 text-white",
};
const sizeClasses = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
export function Button({
variant = "primary",
size = "md",
className,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={clsx(
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-75",
"focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
"active:scale-[0.97]",
"disabled:opacity-50 disabled:cursor-not-allowed",
variantClasses[variant],
sizeClasses[size],
className,
)}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
-2
View File
@@ -1,2 +0,0 @@
export { Button } from "./components/Button.js";
export { Badge } from "./components/Badge.js";
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@capakraken/tsconfig/nextjs.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}