chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"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";
|
||||
|
||||
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="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Search Form */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Search Criteria</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Required Skills
|
||||
</label>
|
||||
<SkillTagInput
|
||||
value={requiredSkills}
|
||||
onChange={setRequiredSkills}
|
||||
placeholder="Add skill…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
||||
<DateInput
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
||||
<DateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hours per Day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={1}
|
||||
max={24}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Find Matches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="lg:col-span-2">
|
||||
{isLoading && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
Finding best matches...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
No resources found matching your criteria.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div key={suggestion.resourceId} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center font-bold text-brand-700">
|
||||
#{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{suggestion.resourceName}</div>
|
||||
<div className="text-xs text-gray-500">{suggestion.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-brand-600">{suggestion.score}</div>
|
||||
<div className="text-xs text-gray-400">score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-4 gap-3">
|
||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
|
||||
<ScoreBar label="Avail." value={suggestion.scoreBreakdown.availabilityScore} />
|
||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
|
||||
<ScoreBar label="Util." value={suggestion.scoreBreakdown.utilizationScore} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{suggestion.matchedSkills.map((skill) => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-green-50 text-green-700 text-xs rounded-full">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{suggestion.missingSkills.map((skill) => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-red-50 text-red-600 text-xs rounded-full">
|
||||
{skill} (missing)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex 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="text-yellow-600">⚠ {suggestion.availabilityConflicts.length} conflicts</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!submitted && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 border-dashed p-12 text-center text-gray-300">
|
||||
Fill in the criteria and click "Find Matches" to see staffing suggestions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBar({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-500 rounded-full"
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user