093e13b88f
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata
Co-Authored-By: claude-flow <ruv@ruv.net>
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { DateInput } from "~/components/ui/DateInput.js";
|
|
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
|
|
export function StaffingPanel() {
|
|
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
|
const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0] ?? "");
|
|
const [endDate, setEndDate] = useState(
|
|
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
|
|
);
|
|
const [hoursPerDay, setHoursPerDay] = useState(8);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
|
|
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
|
|
{
|
|
requiredSkills: requiredSkills,
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
hoursPerDay,
|
|
},
|
|
{ enabled: submitted },
|
|
);
|
|
|
|
return (
|
|
<div className="app-page space-y-6">
|
|
<div className="app-page-header gap-4">
|
|
<div className="space-y-3">
|
|
<span className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-brand-700 dark:border-brand-900/60 dark:bg-brand-900/30 dark:text-brand-200">
|
|
Staffing
|
|
</span>
|
|
<div>
|
|
<h1 className="app-page-title">Staffing Suggestions</h1>
|
|
<p className="app-page-subtitle max-w-2xl">
|
|
Match open work with the strongest available people based on skills, availability, utilization, and cost.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="app-surface max-w-xl p-4">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
plANARCHY blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
|
<div className="space-y-6">
|
|
<div className="app-surface-strong p-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Search Criteria</h2>
|
|
<p className="mt-1 text-sm text-gray-500">Define the role needs and let the matching engine rank the best candidates.</p>
|
|
|
|
<div className="mt-6 space-y-5">
|
|
<div>
|
|
<label className="app-label">Required Skills<InfoTooltip content="Skills the candidate must have. The engine scores overlap and proficiency against this list." /></label>
|
|
<SkillTagInput
|
|
value={requiredSkills}
|
|
onChange={setRequiredSkills}
|
|
placeholder="Add skill…"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
|
<div>
|
|
<label className="app-label">Start Date</label>
|
|
<DateInput
|
|
value={startDate}
|
|
onChange={setStartDate}
|
|
className="app-input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="app-label">End Date</label>
|
|
<DateInput
|
|
value={endDate}
|
|
onChange={setEndDate}
|
|
min={startDate}
|
|
className="app-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="app-label">Hours per Day<InfoTooltip content="Required hours per day for the role. Used to check conflicts and estimate capacity." /></label>
|
|
<input
|
|
type="number"
|
|
value={hoursPerDay}
|
|
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
|
min={1}
|
|
max={24}
|
|
className="app-input"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setSubmitted(true)}
|
|
className="inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
|
|
>
|
|
Find Matches
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="app-surface p-5">
|
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Skills</div>
|
|
<div className="mt-1 text-gray-600 dark:text-gray-300">Quality of skill overlap with the requested stack.</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Availability</div>
|
|
<div className="mt-1 text-gray-600 dark:text-gray-300">Conflicts and free capacity during the selected period.</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Cost + Load</div>
|
|
<div className="mt-1 text-gray-600 dark:text-gray-300">Cost efficiency and current utilization weighting.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{isLoading && (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
|
Finding best matches...
|
|
</div>
|
|
)}
|
|
|
|
{suggestions && suggestions.length === 0 && (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
|
No resources found matching your criteria.
|
|
</div>
|
|
)}
|
|
|
|
{suggestions && suggestions.length > 0 && (
|
|
<div className="space-y-4">
|
|
{suggestions.map((suggestion, idx) => (
|
|
<div key={suggestion.resourceId} className="app-surface p-5">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
|
|
{idx + 1}
|
|
</div>
|
|
<div>
|
|
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
|
|
<div className="text-sm text-gray-500">{suggestion.eid}</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
|
|
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
|
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
|
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
|
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{suggestion.matchedSkills.map((skill) => (
|
|
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
|
{skill}
|
|
</span>
|
|
))}
|
|
{suggestion.missingSkills.map((skill) => (
|
|
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
|
|
{skill} missing
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
|
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} €/h</span>
|
|
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
|
{suggestion.availabilityConflicts.length > 0 && (
|
|
<span className="font-medium text-amber-600 dark:text-amber-300">
|
|
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!submitted && (
|
|
<div className="app-surface-strong border-dashed py-20 text-center">
|
|
<div className="mx-auto max-w-md">
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">No suggestions yet</div>
|
|
<p className="mt-2 text-sm text-gray-500">
|
|
Add the required skills and date range, then run the search to see ranked staffing matches.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) {
|
|
return (
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500 inline-flex items-center gap-0.5">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
|
|
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
|
|
<div
|
|
className="h-full rounded-full bg-brand-500"
|
|
style={{ width: `${value}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
|
|
</div>
|
|
);
|
|
}
|