feat: Sprint 3 — automation, intelligence, skill marketplace

Auto-Staffing Suggestions (A6):
- generateAutoSuggestions() ranks top-3 resources on demand creation
- Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%)
- Creates in-app notification with match scores for managers
- Triggered after createDemandRequirement and partial fillDemandRequirement

Vacation Conflict Detection (A7):
- checkVacationConflicts() warns when >50% chapter absent on same days
- Returns warnings array in approve/batchApprove responses (advisory, non-blocking)
- Creates VACATION_CONFLICT_WARNING notification for approver

Weekly Chargeability Alerts (A10):
- checkChargeabilityAlerts() finds resources >15pp below target
- Cron endpoint: GET /api/cron/chargeability-alerts
- Duplicate-safe by resourceId + month composite key

Rate Card Auto-Apply (A11):
- lookupRate() finds best matching rate card line (weighted scoring)
- Auto-fills demand line rates in estimate create/updateDraft when rates are 0
- Marks auto-filled lines with metadata.autoAppliedRateCard
- New lookupDemandLineRate query for on-demand UI lookups

Public Holiday Auto-Import (A12):
- autoImportPublicHolidays() generates holidays by resource federal state
- Cron endpoint: GET /api/cron/public-holidays?year=2027
- Duplicate-safe, uses existing getPublicHolidays() from shared

Skill Marketplace MVP (G6):
- New page: /analytics/skill-marketplace with 3 sections
- Skill Search: filter by name, proficiency, availability, sortable results
- Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators
- Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart)
- New getSkillMarketplace query in resource router
- Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 21:39:05 +01:00
parent 6e5b9ec85b
commit 6f34659587
16 changed files with 1906 additions and 4 deletions
+140 -2
View File
@@ -37,6 +37,7 @@ import {
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { lookupRate } from "../lib/rate-card-lookup.js";
import {
controllerProcedure,
createTRPCRouter,
@@ -142,6 +143,75 @@ function withComputedMetrics<
};
}
/**
* Auto-fill rate card rates into demand lines that have default (zero) rates.
* A line is eligible for auto-fill when both costRateCents and billRateCents
* are 0 (the Zod default) and rateSource is not explicitly set.
*
* Returns the enriched demand lines and a list of line indices that were auto-filled.
*/
async function autoFillDemandLineRates(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
projectId?: string | null,
): Promise<{
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
autoFilledIndices: number[];
}> {
// Resolve clientId from the linked project
let clientId: string | null = null;
if (projectId) {
const project = await db.project.findUnique({
where: { id: projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const autoFilledIndices: number[] = [];
const enriched = await Promise.all(
demandLines.map(async (line, index) => {
// Only auto-fill if both rates are at default (0) and no explicit rateSource
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
if (!isDefaultRate || hasExplicitSource) return line;
const result = await lookupRate(db, {
clientId,
chapter: line.chapter ?? null,
roleId: line.roleId ?? null,
});
if (!result) return line;
autoFilledIndices.push(index);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
return {
...line,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateSource: `rate-card:${result.rateCardId}`,
metadata: {
...existingMetadata,
autoAppliedRateCard: {
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
appliedAt: new Date().toISOString(),
},
},
};
}),
);
return { demandLines: enriched, autoFilledIndices };
}
export const estimateRouter = createTRPCRouter({
list: protectedProcedure
.input(EstimateListFiltersSchema.default({}))
@@ -180,9 +250,14 @@ export const estimateRouter = createTRPCRouter({
);
}
// Auto-fill rates from rate cards for demand lines with default (zero) rates
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(input, input.baseCurrency),
withComputedMetrics(enrichedInput, input.baseCurrency),
);
await ctx.db.auditLog.create({
@@ -198,6 +273,7 @@ export const estimateRouter = createTRPCRouter({
status: estimate.status,
projectId: estimate.projectId,
latestVersionNumber: estimate.latestVersionNumber,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
@@ -263,11 +339,25 @@ export const estimateRouter = createTRPCRouter({
);
}
// Auto-fill rates from rate cards for demand lines with default (zero) rates
// Resolve projectId: explicit input or existing estimate's projectId
let effectiveProjectId = input.projectId;
if (!effectiveProjectId) {
const existing = await ctx.db.estimate.findUnique({
where: { id: input.id },
select: { projectId: true },
});
effectiveProjectId = existing?.projectId ?? undefined;
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(input, input.baseCurrency ?? "EUR"),
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
@@ -300,6 +390,7 @@ export const estimateRouter = createTRPCRouter({
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
@@ -837,4 +928,51 @@ export const estimateRouter = createTRPCRouter({
return { versionId: version.id, terms: validated };
}),
// ─── Rate Card Lookup for Demand Lines ──────────────────────────────────
lookupDemandLineRate: controllerProcedure
.input(z.object({
projectId: z.string().optional(),
clientId: z.string().optional(),
roleId: z.string().optional(),
chapter: z.string().optional(),
seniority: z.string().optional(),
location: z.string().optional(),
workType: z.string().optional(),
effectiveDate: z.coerce.date().optional(),
}))
.query(async ({ ctx, input }) => {
// Resolve clientId from project if not provided directly
let clientId = input.clientId ?? null;
if (!clientId && input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const result = await lookupRate(ctx.db, {
clientId,
chapter: input.chapter ?? null,
roleId: input.roleId ?? null,
seniority: input.seniority ?? null,
location: input.location ?? null,
workType: input.workType ?? null,
effectiveDate: input.effectiveDate ?? null,
});
if (!result) return { found: false as const };
return {
found: true as const,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
};
}),
});