= {
- ANNUAL: "Annual Leave",
- SICK: "Sick Leave",
- PUBLIC_HOLIDAY: "Public Holiday",
- OTHER: "Other",
-};
-
interface VacationModalProps {
resourceId?: string;
onClose: () => void;
@@ -118,7 +112,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
- const resourceList = resources?.resources ?? [];
+ const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? [];
return (
([]);
const future = useRef([]);
- const utils = trpc.useUtils();
+ const invalidateTimeline = useInvalidateTimeline();
// Configurable max steps from system settings
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
@@ -27,21 +28,11 @@ export function useAllocationHistory() {
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
- onSuccess: () => {
- void utils.timeline.getEntries.invalidate();
- void utils.timeline.getEntriesView.invalidate();
- void utils.timeline.getProjectContext.invalidate();
- void utils.timeline.getBudgetStatus.invalidate();
- },
+ onSuccess: invalidateTimeline,
});
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
- onSuccess: () => {
- void utils.timeline.getEntries.invalidate();
- void utils.timeline.getEntriesView.invalidate();
- void utils.timeline.getProjectContext.invalidate();
- void utils.timeline.getBudgetStatus.invalidate();
- },
+ onSuccess: invalidateTimeline,
});
const push = useCallback((snapshot: AllocationMovedSnapshot) => {
@@ -58,13 +49,6 @@ export function useAllocationHistory() {
setCanRedo(false);
}, [maxHistory]);
- const invalidateAll = useCallback(() => {
- void utils.timeline.getEntries.invalidate();
- void utils.timeline.getEntriesView.invalidate();
- void utils.timeline.getProjectContext.invalidate();
- void utils.timeline.getBudgetStatus.invalidate();
- }, [utils]);
-
const undo = useCallback(async () => {
const last = past.current[past.current.length - 1];
if (!last) return;
diff --git a/apps/web/src/hooks/useInvalidatePlanningViews.ts b/apps/web/src/hooks/useInvalidatePlanningViews.ts
index 985e9ad..02c2bdb 100644
--- a/apps/web/src/hooks/useInvalidatePlanningViews.ts
+++ b/apps/web/src/hooks/useInvalidatePlanningViews.ts
@@ -1,8 +1,19 @@
import { trpc } from "~/lib/trpc/client.js";
+/** Invalidates just the 4 timeline queries */
+export function useInvalidateTimeline() {
+ const utils = trpc.useUtils();
+ return () => {
+ void utils.timeline.getEntries.invalidate();
+ void utils.timeline.getEntriesView.invalidate();
+ void utils.timeline.getProjectContext.invalidate();
+ void utils.timeline.getBudgetStatus.invalidate();
+ };
+}
+
+/** Invalidates all 8 planning-related queries (4 timeline + 4 allocation) */
export function useInvalidatePlanningViews() {
const utils = trpc.useUtils();
-
return () => {
void utils.allocation.list.invalidate();
void (
diff --git a/apps/web/src/hooks/useMultiSelectIntersection.ts b/apps/web/src/hooks/useMultiSelectIntersection.ts
new file mode 100644
index 0000000..1182366
--- /dev/null
+++ b/apps/web/src/hooks/useMultiSelectIntersection.ts
@@ -0,0 +1,166 @@
+/**
+ * Computes which allocations/resources fall within the multi-select rectangle
+ * after the user finishes a right-click drag selection.
+ */
+
+import { useEffect } from "react";
+import { LABEL_WIDTH } from "~/components/timeline/timelineConstants.js";
+import type { MultiSelectState, AllocDragMode } from "~/hooks/useTimelineDrag.js";
+import type { TimelineAssignmentEntry } from "~/components/timeline/TimelineContext.js";
+import type { ViewMode, ResourceBrief } from "~/components/timeline/TimelineContext.js";
+
+interface ProjectGroup {
+ id: string;
+ resourceRows: {
+ resource: { id: string };
+ allocs: TimelineAssignmentEntry[];
+ }[];
+}
+
+interface DemandEntry {
+ id: string;
+ startDate: Date | string;
+ endDate: Date | string;
+}
+
+export function useMultiSelectIntersection({
+ multiSelectState,
+ setMultiSelectState,
+ clearMultiSelect,
+ canvasRef,
+ viewMode,
+ resources,
+ allocsByResource,
+ projectGroups,
+ openDemandsByProject,
+ dates,
+ today,
+ CELL_WIDTH,
+ toLeft,
+ toWidth,
+}: {
+ multiSelectState: MultiSelectState;
+ setMultiSelectState: React.Dispatch>;
+ clearMultiSelect: () => void;
+ canvasRef: React.RefObject;
+ viewMode: ViewMode;
+ resources: ResourceBrief[];
+ allocsByResource: Map;
+ projectGroups: ProjectGroup[];
+ openDemandsByProject: Map;
+ dates: Date[];
+ today: Date;
+ CELL_WIDTH: number;
+ toLeft: (d: Date) => number;
+ toWidth: (s: Date, e: Date) => number;
+}) {
+ useEffect(() => {
+ // Only compute when drag just ended (isSelecting false but has coordinates)
+ if (multiSelectState.isSelecting) return;
+ if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
+ if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
+
+ const canvasEl = canvasRef.current;
+ if (!canvasEl) return;
+
+ // Selection rectangle in viewport coordinates
+ const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
+ const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
+ const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
+ const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
+
+ // Convert viewport X to canvas-relative X for allocation matching
+ const canvasRect = canvasEl.getBoundingClientRect();
+ const canvasXOffset = canvasRect.left + LABEL_WIDTH;
+ const toCanvasX = (clientX: number) => clientX - canvasXOffset;
+
+ const selLeftCanvas = toCanvasX(selLeft);
+ const selRightCanvas = toCanvasX(selRight);
+
+ // Derive date range from pixel X positions
+ const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
+ const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
+ const startDate = dates[colIndexStart] ?? today;
+ const endDate = dates[colIndexEnd] ?? today;
+
+ const selectedIds: string[] = [];
+ const selectedResIds: string[] = [];
+
+ // Query all rendered row elements (virtualizer only renders visible + overscan rows)
+ const rowElements = canvasEl.querySelectorAll("[data-index]");
+
+ if (viewMode === "resource") {
+ rowElements.forEach((rowEl) => {
+ const idx = Number(rowEl.dataset.index);
+ const resource = resources[idx];
+ if (!resource) return;
+
+ const rowRect = rowEl.getBoundingClientRect();
+ if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
+ selectedResIds.push(resource.id);
+
+ const allocs = allocsByResource.get(resource.id) ?? [];
+ for (const alloc of allocs) {
+ const allocLeft = toLeft(new Date(alloc.startDate));
+ const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
+ if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
+ selectedIds.push(alloc.id);
+ }
+ }
+ });
+ } else if (viewMode === "project") {
+ const projectRowEls = canvasEl.querySelectorAll("[data-project-resource-row]");
+ projectRowEls.forEach((rowEl) => {
+ const rowRect = rowEl.getBoundingClientRect();
+ if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
+
+ const projectId = rowEl.dataset.projectId;
+ const resourceId = rowEl.dataset.resourceId;
+ if (!projectId || !resourceId) return;
+
+ const group = projectGroups.find((g) => g.id === projectId);
+ if (!group) return;
+ const row = group.resourceRows.find((r) => r.resource.id === resourceId);
+ if (!row) return;
+
+ for (const alloc of row.allocs) {
+ const allocLeft = toLeft(new Date(alloc.startDate));
+ const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
+ if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
+ selectedIds.push(alloc.id);
+ }
+ }
+ });
+
+ // Also check demand rows for open demand selection
+ const demandRowEls = canvasEl.querySelectorAll("[data-project-demand-row]");
+ demandRowEls.forEach((rowEl) => {
+ const rowRect = rowEl.getBoundingClientRect();
+ if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
+
+ const projectId = rowEl.dataset.projectId;
+ if (!projectId) return;
+
+ const demands = openDemandsByProject.get(projectId) ?? [];
+ for (const demand of demands) {
+ const allocLeft = toLeft(new Date(demand.startDate));
+ const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
+ if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
+ selectedIds.push(demand.id);
+ }
+ }
+ });
+ }
+
+ if (selectedIds.length > 0 || selectedResIds.length > 0) {
+ setMultiSelectState(prev => ({
+ ...prev,
+ selectedAllocationIds: selectedIds,
+ selectedResourceIds: selectedResIds,
+ dateRange: { start: startDate, end: endDate },
+ }));
+ } else {
+ clearMultiSelect();
+ }
+ }, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
+}
diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts
index 5d7f195..034f383 100644
--- a/apps/web/src/hooks/useTimelineDrag.ts
+++ b/apps/web/src/hooks/useTimelineDrag.ts
@@ -2,6 +2,8 @@
import { useCallback, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
+import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
+import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
// ─── Project-shift drag state ───────────────────────────────────────────────
@@ -214,6 +216,7 @@ export function useTimelineDrag({
onMultiDragCompleteRef.current = onMultiDragComplete;
const utils = trpc.useUtils();
+ const invalidateTimeline = useInvalidateTimeline();
// Project-shift preview
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
@@ -235,9 +238,7 @@ export function useTimelineDrag({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
onSuccess: (data: { project: { id: string } }) => {
- void utils.timeline.getEntries.invalidate();
- void utils.timeline.getEntriesView.invalidate();
- void utils.timeline.getBudgetStatus.invalidate();
+ invalidateTimeline();
void utils.project.list.invalidate();
onShiftApplied?.(data.project.id);
},
@@ -251,10 +252,7 @@ export function useTimelineDrag({
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
- void utils.timeline.getEntries.invalidate();
- void utils.timeline.getEntriesView.invalidate();
- void utils.timeline.getProjectContext.invalidate();
- void utils.timeline.getBudgetStatus.invalidate();
+ invalidateTimeline();
const snap = pendingSnapshotRef.current;
if (snap) {
onAllocationMovedRef.current?.(snap);
@@ -378,7 +376,7 @@ export function useTimelineDrag({
function handleMultiMove(ev: MouseEvent) {
const deltaX = ev.clientX - startMouseX;
- const daysDelta = Math.round(deltaX / cellWidthRef.current);
+ const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
if (daysDelta === currentDaysDelta) return;
currentDaysDelta = daysDelta;
@@ -432,25 +430,15 @@ export function useTimelineDrag({
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
const deltaX = ev.clientX - alloc.startMouseX;
- const daysDelta = Math.round(deltaX / cellWidthRef.current);
+ const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
if (daysDelta === alloc.daysDelta) return;
- const newStart = new Date(alloc.originalStartDate);
- const newEnd = new Date(alloc.originalEndDate);
-
- if (alloc.mode === "move") {
- newStart.setDate(newStart.getDate() + daysDelta);
- newEnd.setDate(newEnd.getDate() + daysDelta);
- } else if (alloc.mode === "resize-start") {
- newStart.setDate(newStart.getDate() + daysDelta);
- // Allow same-day (single day booking), prevent crossing
- if (newStart > newEnd) newStart.setTime(newEnd.getTime());
- } else {
- // resize-end
- newEnd.setDate(newEnd.getDate() + daysDelta);
- // Allow same-day (single day booking), prevent crossing
- if (newEnd < newStart) newEnd.setTime(newStart.getTime());
- }
+ const { start: newStart, end: newEnd } = computeDragDates(
+ alloc.mode,
+ alloc.originalStartDate,
+ alloc.originalEndDate,
+ daysDelta,
+ );
const updated: AllocDragState = {
...alloc,
@@ -545,12 +533,14 @@ export function useTimelineDrag({
const drag = dragStateRef.current;
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
const deltaX = e.clientX - drag.startMouseX;
- const daysDelta = Math.round(deltaX / cellWidth);
+ const daysDelta = pixelsToDays(deltaX, cellWidth);
if (daysDelta !== drag.daysDelta) {
- const newStart = new Date(drag.originalStartDate);
- newStart.setDate(newStart.getDate() + daysDelta);
- const newEnd = new Date(drag.originalEndDate);
- newEnd.setDate(newEnd.getDate() + daysDelta);
+ const { start: newStart, end: newEnd } = computeDragDates(
+ "move",
+ drag.originalStartDate,
+ drag.originalEndDate,
+ daysDelta,
+ );
const updated: DragState = {
...drag,
currentStartDate: newStart,
@@ -567,7 +557,7 @@ export function useTimelineDrag({
const range = rangeStateRef.current;
if (range.isSelecting && range.startDate) {
const deltaX = e.clientX - range.startClientX;
- const daysDelta = Math.round(deltaX / cellWidth);
+ const daysDelta = pixelsToDays(deltaX, cellWidth);
const currentDate = new Date(range.startDate);
currentDate.setDate(currentDate.getDate() + daysDelta);
diff --git a/apps/web/src/lib/status-styles.ts b/apps/web/src/lib/status-styles.ts
index e8dff18..48ef64d 100644
--- a/apps/web/src/lib/status-styles.ts
+++ b/apps/web/src/lib/status-styles.ts
@@ -46,3 +46,33 @@ export const ORDER_TYPE_BADGE: Record = {
INTERNAL: "bg-blue-100 text-blue-700",
OVERHEAD: "bg-gray-100 text-gray-700",
};
+
+/** Vacation overlay colors for timeline bars */
+export const VACATION_TIMELINE_COLORS: Record = {
+ ANNUAL: "bg-orange-400/40",
+ SICK: "bg-red-500/40",
+ PUBLIC_HOLIDAY: "bg-violet-400/40",
+ OTHER: "bg-amber-400/40",
+};
+
+export const VACATION_TIMELINE_BORDER: Record = {
+ ANNUAL: "border-orange-500",
+ SICK: "border-red-600",
+ PUBLIC_HOLIDAY: "border-violet-500",
+ OTHER: "border-amber-500",
+};
+
+export const VACATION_TYPE_LABELS_SHORT: Record = {
+ ANNUAL: "Annual",
+ SICK: "Sick",
+ PUBLIC_HOLIDAY: "Holiday",
+ OTHER: "Other",
+};
+
+/** Vacation calendar dot/bar colors */
+export const VACATION_CALENDAR_COLORS: Record = {
+ ANNUAL: "bg-brand-500",
+ SICK: "bg-red-400",
+ PUBLIC_HOLIDAY: "bg-emerald-500",
+ OTHER: "bg-purple-400",
+};
diff --git a/packages/api/src/lib/format-utils.ts b/packages/api/src/lib/format-utils.ts
new file mode 100644
index 0000000..71caf55
--- /dev/null
+++ b/packages/api/src/lib/format-utils.ts
@@ -0,0 +1,3 @@
+export function fmtEur(cents: number): string {
+ return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
+}
diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts
index bfe594c..a8fc8bb 100644
--- a/packages/api/src/router/assistant-tools.ts
+++ b/packages/api/src/router/assistant-tools.ts
@@ -9,6 +9,7 @@ import type { PermissionKey } from "@planarchy/shared";
import { parseTaskAction } from "@planarchy/shared";
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
import { getTaskAction } from "../lib/task-actions.js";
+import { fmtEur } from "../lib/format-utils.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import {
emitNotificationCreated,
@@ -41,10 +42,6 @@ type ToolExecutor = (params: any, ctx: ToolContext) => Promise;
// ─── Helpers ────────────────────────────────────────────────────────────────
-function fmtEur(cents: number): string {
- return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
-}
-
function fmtDate(d: Date | null | undefined): string | null {
return d ? d.toISOString().slice(0, 10) : null;
}
diff --git a/packages/api/src/router/calculation-rules.ts b/packages/api/src/router/calculation-rules.ts
index 30b4417..cf8e790 100644
--- a/packages/api/src/router/calculation-rules.ts
+++ b/packages/api/src/router/calculation-rules.ts
@@ -2,29 +2,29 @@ import {
CreateCalculationRuleSchema,
UpdateCalculationRuleSchema,
} from "@planarchy/shared";
-import { TRPCError } from "@trpc/server";
import { z } from "zod";
+import { findUniqueOrThrow } from "../db/helpers.js";
+import { PROJECT_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
export const calculationRuleRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.calculationRule.findMany({
orderBy: [{ priority: "desc" }, { name: "asc" }],
- include: { project: { select: { id: true, name: true, shortCode: true } } },
+ include: { project: { select: PROJECT_BRIEF_SELECT } },
});
}),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
- const rule = await ctx.db.calculationRule.findUnique({
- where: { id: input.id },
- include: { project: { select: { id: true, name: true, shortCode: true } } },
- });
- if (!rule) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
- }
- return rule;
+ return findUniqueOrThrow(
+ ctx.db.calculationRule.findUnique({
+ where: { id: input.id },
+ include: { project: { select: PROJECT_BRIEF_SELECT } },
+ }),
+ "CalculationRule",
+ );
}),
/** Get all active rules (optimized for engine use — no project include) */
@@ -58,10 +58,10 @@ export const calculationRuleRouter = createTRPCRouter({
.input(UpdateCalculationRuleSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
- const existing = await ctx.db.calculationRule.findUnique({ where: { id } });
- if (!existing) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
- }
+ await findUniqueOrThrow(
+ ctx.db.calculationRule.findUnique({ where: { id } }),
+ "CalculationRule",
+ );
// Build update data using exactOptionalPropertyTypes pattern
const updateData: Record = {};
@@ -85,10 +85,10 @@ export const calculationRuleRouter = createTRPCRouter({
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- const existing = await ctx.db.calculationRule.findUnique({ where: { id: input.id } });
- if (!existing) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
- }
+ await findUniqueOrThrow(
+ ctx.db.calculationRule.findUnique({ where: { id: input.id } }),
+ "CalculationRule",
+ );
await ctx.db.calculationRule.delete({ where: { id: input.id } });
return { success: true };
}),
diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts
index d35a78d..c91352a 100644
--- a/packages/api/src/router/computation-graph.ts
+++ b/packages/api/src/router/computation-graph.ts
@@ -12,6 +12,7 @@ import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailabilit
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
+import { fmtEur } from "../lib/format-utils.js";
// ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -50,10 +51,6 @@ function l(source: string, target: string, formula: string, weight = 1): GraphLi
return { source, target, formula, weight };
}
-function fmtEur(cents: number): string {
- return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
-}
-
function fmtPct(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts
index 4477dad..dbed422 100644
--- a/packages/api/src/router/entitlement.ts
+++ b/packages/api/src/router/entitlement.ts
@@ -6,6 +6,7 @@
import { VacationType, VacationStatus } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
+import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
/** Types that consume from annual leave balance */
@@ -266,7 +267,7 @@ export const entitlementRouter = createTRPCRouter({
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
- select: { id: true, displayName: true, eid: true, chapter: true },
+ select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts
index ed2dacb..dc8b40e 100644
--- a/packages/api/src/router/notification.ts
+++ b/packages/api/src/router/notification.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
+import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import {
emitNotificationCreated,
@@ -598,13 +599,10 @@ export const notificationRouter = createTRPCRouter({
assignTask: managerProcedure
.input(z.object({ id: z.string(), assigneeId: z.string() }))
.mutation(async ({ ctx, input }) => {
- const existing = await ctx.db.notification.findUnique({
- where: { id: input.id },
- });
-
- if (!existing) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Task not found" });
- }
+ const existing = await findUniqueOrThrow(
+ ctx.db.notification.findUnique({ where: { id: input.id } }),
+ "Task",
+ );
if (existing.category !== "TASK" && existing.category !== "APPROVAL") {
throw new TRPCError({
diff --git a/packages/api/src/router/project-planning-read-model.ts b/packages/api/src/router/project-planning-read-model.ts
index 90e1218..4c8af80 100644
--- a/packages/api/src/router/project-planning-read-model.ts
+++ b/packages/api/src/router/project-planning-read-model.ts
@@ -1,6 +1,7 @@
import { buildSplitAllocationReadModel } from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
+import { ROLE_BRIEF_SELECT } from "../db/selects.js";
export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
resource: {
@@ -31,7 +32,7 @@ export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
},
},
roleEntity: {
- select: { id: true, name: true, color: true },
+ select: ROLE_BRIEF_SELECT,
},
} as const;
diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts
index 1c9f29f..4a6d6f6 100644
--- a/packages/api/src/router/resource.ts
+++ b/packages/api/src/router/resource.ts
@@ -295,30 +295,30 @@ export const resourceRouter = createTRPCRouter({
getHoverCard: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
- const resource = await ctx.db.resource.findUnique({
- where: { id: input.id },
- select: {
- id: true,
- displayName: true,
- eid: true,
- email: true,
- chapter: true,
- lcrCents: true,
- ucrCents: true,
- currency: true,
- chargeabilityTarget: true,
- skills: true,
- availability: true,
- isActive: true,
- areaRole: { select: { id: true, name: true, color: true } },
- country: { select: { name: true, code: true } },
- managementLevel: { select: { name: true } },
- resourceType: true,
- },
- });
- if (!resource) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
- }
+ const resource = await findUniqueOrThrow(
+ ctx.db.resource.findUnique({
+ where: { id: input.id },
+ select: {
+ id: true,
+ displayName: true,
+ eid: true,
+ email: true,
+ chapter: true,
+ lcrCents: true,
+ ucrCents: true,
+ currency: true,
+ chargeabilityTarget: true,
+ skills: true,
+ availability: true,
+ isActive: true,
+ areaRole: { select: ROLE_BRIEF_SELECT },
+ country: { select: { name: true, code: true } },
+ managementLevel: { select: { name: true } },
+ resourceType: true,
+ },
+ }),
+ "Resource",
+ );
const directory = await getAnonymizationDirectory(ctx.db);
const anon = anonymizeResource(resource, directory);
return {
@@ -633,11 +633,14 @@ export const resourceRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
// Find the resource linked to this user
- const user = await ctx.db.user.findUnique({
- where: { email: ctx.session.user?.email ?? "" },
- include: { resource: true },
- });
- if (!user?.resource) {
+ const user = await findUniqueOrThrow(
+ ctx.db.user.findUnique({
+ where: { email: ctx.session.user?.email ?? "" },
+ include: { resource: true },
+ }),
+ "User",
+ );
+ if (!user.resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
}
const resourceId = user.resource.id;
@@ -748,17 +751,16 @@ export const resourceRouter = createTRPCRouter({
.input(z.object({ resourceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const [resource, settings] = await Promise.all([
- ctx.db.resource.findUnique({
- where: { id: input.resourceId },
- include: { areaRole: { select: { name: true } } },
- }),
+ findUniqueOrThrow(
+ ctx.db.resource.findUnique({
+ where: { id: input.resourceId },
+ include: { areaRole: { select: { name: true } } },
+ }),
+ "Resource",
+ ),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
- if (!resource) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
- }
-
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts
index 50a3f12..508ada6 100644
--- a/packages/api/src/router/role.ts
+++ b/packages/api/src/router/role.ts
@@ -3,6 +3,7 @@ import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/sh
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
+import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
@@ -89,7 +90,7 @@ export const roleRouter = createTRPCRouter({
_count: { select: { resourceRoles: true } },
resourceRoles: {
include: {
- resource: { select: { id: true, displayName: true, eid: true } },
+ resource: { select: RESOURCE_BRIEF_SELECT },
},
},
},
diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts
index 49f1f43..2d8979f 100644
--- a/packages/api/src/router/timeline.ts
+++ b/packages/api/src/router/timeline.ts
@@ -143,23 +143,22 @@ async function loadTimelineEntriesReadModel(
async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
const [project, planningRead] = await Promise.all([
- db.project.findUnique({
- where: { id: projectId },
- select: {
- id: true,
- budgetCents: true,
- winProbability: true,
- startDate: true,
- endDate: true,
- },
- }),
+ findUniqueOrThrow(
+ db.project.findUnique({
+ where: { id: projectId },
+ select: {
+ id: true,
+ budgetCents: true,
+ winProbability: true,
+ startDate: true,
+ endDate: true,
+ },
+ }),
+ "Project",
+ ),
loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
]);
- if (!project) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
- }
-
const { demandRequirements, assignments, readModel: projectReadModel } = planningRead;
const resourceIds = getAssignmentResourceIds(projectReadModel);
@@ -337,31 +336,30 @@ export const timelineRouter = createTRPCRouter({
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
- ctx.db.project.findUnique({
- where: { id: input.projectId },
- select: {
- id: true,
- name: true,
- shortCode: true,
- orderType: true,
- budgetCents: true,
- winProbability: true,
- status: true,
- startDate: true,
- endDate: true,
- staffingReqs: true,
- },
- }),
+ findUniqueOrThrow(
+ ctx.db.project.findUnique({
+ where: { id: input.projectId },
+ select: {
+ id: true,
+ name: true,
+ shortCode: true,
+ orderType: true,
+ budgetCents: true,
+ winProbability: true,
+ status: true,
+ startDate: true,
+ endDate: true,
+ staffingReqs: true,
+ },
+ }),
+ "Project",
+ ),
loadProjectPlanningReadModel(ctx.db, {
projectId: input.projectId,
activeOnly: true,
}),
]);
- if (!project) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
- }
-
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
const allResourceAllocations =
resourceIds.length === 0
diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts
index 32fc6ba..3b340ae 100644
--- a/packages/api/src/router/vacation.ts
+++ b/packages/api/src/router/vacation.ts
@@ -3,6 +3,7 @@ import { VacationStatus, VacationType } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
+import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
@@ -99,7 +100,7 @@ export const vacationRouter = createTRPCRouter({
...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
},
include: {
- resource: { select: { id: true, displayName: true, eid: true } },
+ resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
@@ -120,7 +121,7 @@ export const vacationRouter = createTRPCRouter({
ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
- resource: { select: { id: true, displayName: true, eid: true } },
+ resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
@@ -210,7 +211,7 @@ export const vacationRouter = createTRPCRouter({
: {}),
},
include: {
- resource: { select: { id: true, displayName: true, eid: true } },
+ resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } },
},
});
@@ -539,7 +540,7 @@ export const vacationRouter = createTRPCRouter({
return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING },
include: {
- resource: { select: { id: true, displayName: true, eid: true } },
+ resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
@@ -576,7 +577,7 @@ export const vacationRouter = createTRPCRouter({
endDate: { gte: input.startDate },
},
include: {
- resource: { select: { id: true, displayName: true, eid: true } },
+ resource: { select: RESOURCE_BRIEF_SELECT },
},
orderBy: { startDate: "asc" },
take: 20,