refactor: consolidate duplicated code across web and API packages
- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx - Centralize status badge styles and vacation color maps into status-styles.ts - Extract dragMath.ts utility from useTimelineDrag for reuse - Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries) - Adopt findUniqueOrThrow() and Prisma select constants across API routers - Add shared fmtEur() helper for API-side money formatting - Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo - Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal - 38 files changed, reducing ~400 lines of duplicated code Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export function fmtEur(cents: number): string {
|
||||
return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`;
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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)}%`;
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user