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
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { prisma } from "@planarchy/db";
import { checkChargeabilityAlerts } from "@planarchy/api";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* GET /api/cron/chargeability-alerts
*
* Finds resources whose current-month chargeability is >15 percentage points
* below their target and creates in-app notifications for managers.
*
* Duplicate-safe: only one alert per resource per month.
*
* Optionally protect with CRON_SECRET environment variable.
* When set, requests must include `Authorization: Bearer <secret>`.
*/
export async function GET(request: Request) {
const cronSecret = process.env["CRON_SECRET"];
if (cronSecret) {
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const alertsSent = await checkChargeabilityAlerts(prisma as any);
return NextResponse.json({
ok: true,
alertsSent,
checkedAt: new Date().toISOString(),
});
} catch (error) {
console.error("[cron/chargeability-alerts] Error:", error);
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { prisma } from "@planarchy/db";
import { autoImportPublicHolidays } from "@planarchy/api";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* GET /api/cron/public-holidays?year=2027
*
* Auto-imports public holidays for all active resources for a given year.
* Each resource's federal state determines which state-specific holidays apply.
* Duplicate-safe: existing holidays are skipped.
*
* Query params:
* - year (optional): defaults to next year
*
* Optionally protected with CRON_SECRET environment variable.
* When set, requests must include `Authorization: Bearer <secret>`.
*/
export async function GET(request: Request) {
const cronSecret = process.env["CRON_SECRET"];
if (cronSecret) {
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const yearParam = searchParams.get("year");
const year = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear() + 1;
if (isNaN(year) || year < 2000 || year > 2100) {
return NextResponse.json(
{ error: "Invalid year parameter. Must be between 2000 and 2100." },
{ status: 400 },
);
}
try {
const result = await autoImportPublicHolidays(prisma, year);
return NextResponse.json({
ok: true,
year: result.year,
holidaysCreated: result.holidaysCreated,
resourcesProcessed: result.resourcesProcessed,
skippedExisting: result.skippedExisting,
});
} catch (error) {
console.error("[cron/public-holidays] Error:", error);
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
);
}
}