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:
@@ -19,7 +19,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
...actual,
|
||||
loadAllocationEntry: loadAllocationEntryMock,
|
||||
updateAssignment: updateAssignmentMock,
|
||||
createAssignment: createAssignmentMock,
|
||||
createAssignmentFragment: createAssignmentMock,
|
||||
deleteAllocationEntry: deleteAllocationEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Button } from "./components/Button.js";
|
||||
export { Badge } from "./components/Badge.js";
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@capakraken/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user