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:
@@ -31,6 +31,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
@@ -495,6 +496,9 @@ export const allocationRouter = createTRPCRouter({
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId);
|
||||
// Fire-and-forget: compute and notify top-3 staffing suggestions
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void generateAutoSuggestions(ctx.db as any, demandRequirement.id);
|
||||
return demandRequirement;
|
||||
}),
|
||||
|
||||
@@ -631,6 +635,13 @@ export const allocationRouter = createTRPCRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void checkBudgetThresholds(ctx.db as any, result.assignment.projectId);
|
||||
|
||||
// If there are still unfilled slots, refresh suggestions for remaining demand
|
||||
if (result.updatedDemandRequirement.headcount > 0
|
||||
&& result.updatedDemandRequirement.status !== "COMPLETED") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void generateAutoSuggestions(ctx.db as any, result.updatedDemandRequirement.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1191,4 +1191,228 @@ export const resourceRouter = createTRPCRouter({
|
||||
|
||||
return { updated: input.ids.length };
|
||||
}),
|
||||
|
||||
// ─── Skill Marketplace ────────────────────────────────────────────────────
|
||||
|
||||
getSkillMarketplace: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
// Section 1: Skill search
|
||||
searchSkill: z.string().optional(),
|
||||
minProficiency: z.number().int().min(1).max(5).optional().default(1),
|
||||
availableOnly: z.boolean().optional().default(false),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
// ── Fetch all active resources with skills ──
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
chapter: true,
|
||||
skills: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Fetch current assignments for utilization calc ──
|
||||
const allResourceIds = resources.map((r) => r.id);
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: {
|
||||
resourceId: { in: allResourceIds },
|
||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||
endDate: { gte: now },
|
||||
startDate: { lte: thirtyDaysFromNow },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build utilization map (simple: booked hours per day / available hours per day)
|
||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
|
||||
|
||||
// Current daily booked hours (assignments overlapping today)
|
||||
let todayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
todayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
|
||||
|
||||
// Find earliest date when resource has capacity (within 30 days)
|
||||
let earliestAvailableDate: Date | null = null;
|
||||
const checkDate = new Date(now);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const day = checkDate.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
let dayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= checkDate && a.endDate >= checkDate) {
|
||||
dayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
if (dayBooked < dailyAvailHours * 0.8) {
|
||||
earliestAvailableDate = new Date(checkDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
}
|
||||
|
||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||
}
|
||||
|
||||
// ── Section 1: Skill Search ──
|
||||
let searchResults: Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
skillProficiency: number;
|
||||
skillName: string;
|
||||
utilizationPercent: number;
|
||||
availableFrom: string | null;
|
||||
}> = [];
|
||||
|
||||
if (input.searchSkill && input.searchSkill.trim().length > 0) {
|
||||
const needle = input.searchSkill.toLowerCase();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
const match = skills.find(
|
||||
(s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency,
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const util = utilizationMap.get(r.id);
|
||||
if (input.availableOnly && !util?.earliestAvailableDate) continue;
|
||||
|
||||
searchResults.push({
|
||||
id: r.id,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
skillProficiency: match.proficiency,
|
||||
skillName: match.skill,
|
||||
utilizationPercent: util?.utilizationPercent ?? 0,
|
||||
availableFrom: util?.earliestAvailableDate?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent);
|
||||
}
|
||||
|
||||
// ── Section 2: Skill Gap Heat Map ──
|
||||
// Demand: from unfilled DemandRequirements + project staffingReqs skills
|
||||
const unfilled = await ctx.db.demandRequirement.findMany({
|
||||
where: {
|
||||
endDate: { gte: now },
|
||||
assignments: { none: {} },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
project: {
|
||||
select: { staffingReqs: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Collect demanded skills from project staffingReqs
|
||||
const demandSkillCounts = new Map<string, number>();
|
||||
for (const demand of unfilled) {
|
||||
const staffingReqs = (demand.project.staffingReqs as unknown as Array<{
|
||||
role?: string;
|
||||
roleId?: string;
|
||||
requiredSkills?: string[];
|
||||
}>) ?? [];
|
||||
|
||||
// Match demand to staffing req by role or roleId
|
||||
const matchedReq = staffingReqs.find(
|
||||
(sr) =>
|
||||
(demand.roleId && sr.roleId === demand.roleId) ||
|
||||
(demand.role && sr.role === demand.role),
|
||||
);
|
||||
|
||||
if (matchedReq?.requiredSkills) {
|
||||
for (const skill of matchedReq.requiredSkills) {
|
||||
demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supply: count resources with skill at proficiency >= 3
|
||||
const supplySkillCounts = new Map<string, number>();
|
||||
const allSkillCounts = new Map<string, number>();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1);
|
||||
if (s.proficiency >= 3) {
|
||||
supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all skill names from both demand and supply
|
||||
const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]);
|
||||
const gapData = Array.from(allGapSkills)
|
||||
.map((skill) => {
|
||||
const supply = supplySkillCounts.get(skill) ?? 0;
|
||||
const demand = demandSkillCounts.get(skill) ?? 0;
|
||||
return { skill, supply, demand, gap: demand - supply };
|
||||
})
|
||||
.sort((a, b) => b.gap - a.gap);
|
||||
|
||||
// ── Section 3: Distribution (top 20 by resource count) ──
|
||||
const aggregated = Array.from(
|
||||
(() => {
|
||||
const map = new Map<string, { skill: string; count: number; totalProficiency: number }>();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
const entry = map.get(s.skill);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
entry.totalProficiency += s.proficiency;
|
||||
} else {
|
||||
map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})().values(),
|
||||
)
|
||||
.map((e) => ({
|
||||
skill: e.skill,
|
||||
count: e.count,
|
||||
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20);
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return {
|
||||
searchResults: anonymizeResources(searchResults, directory),
|
||||
gapData,
|
||||
distribution: aggregated,
|
||||
totalResources: resources.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emit
|
||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
@@ -277,6 +278,10 @@ export const vacationRouter = createTRPCRouter({
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Check for team conflicts before approving (non-blocking)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const conflictResult = await checkVacationConflicts(ctx.db as any, input.id, userRecord?.id);
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
@@ -307,7 +312,7 @@ export const vacationRouter = createTRPCRouter({
|
||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
|
||||
}
|
||||
|
||||
return updated;
|
||||
return { ...updated, warnings: conflictResult.warnings };
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -373,6 +378,14 @@ export const vacationRouter = createTRPCRouter({
|
||||
select: { id: true, resourceId: true },
|
||||
});
|
||||
|
||||
// Check for team conflicts before approving (non-blocking)
|
||||
const conflictMap = await checkBatchVacationConflicts(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ctx.db as any,
|
||||
vacations.map((v) => v.id),
|
||||
userRecord?.id,
|
||||
);
|
||||
|
||||
await ctx.db.vacation.updateMany({
|
||||
where: { id: { in: vacations.map((v) => v.id) } },
|
||||
data: {
|
||||
@@ -402,7 +415,13 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
return { approved: vacations.length };
|
||||
// Flatten all warnings into a single array
|
||||
const warnings: string[] = [];
|
||||
for (const [, w] of conflictMap) {
|
||||
warnings.push(...w);
|
||||
}
|
||||
|
||||
return { approved: vacations.length, warnings };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user