chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,941 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry } from "@planarchy/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
interface RoleAssignment {
roleId: string;
isPrimary: boolean;
}
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
yearsExperience: string;
category: string;
certified: boolean;
isMainSkill: boolean;
}
interface FormState {
eid: string;
displayName: string;
email: string;
chapter: string;
lcrEuros: string;
ucrEuros: string;
currency: string;
chargeabilityTarget: string;
monday: string;
tuesday: string;
wednesday: string;
thursday: string;
friday: string;
skills: SkillRow[];
roles: RoleAssignment[];
portfolioUrl: string;
roleId: string;
postalCode: string;
federalState: string;
countryId: string;
metroCityId: string;
orgUnitId: string;
managementLevelGroupId: string;
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
enterpriseId: string;
clientUnitId: string;
fte: string;
}
function resourceToFormState(resource: Resource): FormState {
const skills = (resource.skills as SkillEntry[]).map((s) => ({
skill: s.skill,
proficiency: s.proficiency,
yearsExperience: s.yearsExperience != null ? String(s.yearsExperience) : "",
category: s.category ?? "",
certified: s.certified ?? false,
isMainSkill: s.isMainSkill ?? false,
}));
const roles: RoleAssignment[] = (resource.roles ?? []).map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
}));
const resourceWithMeta = resource as unknown as {
portfolioUrl?: string | null;
roleId?: string | null;
};
return {
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter ?? "",
lcrEuros: String(resource.lcrCents / 100),
ucrEuros: String(resource.ucrCents / 100),
currency: resource.currency,
chargeabilityTarget: String(resource.chargeabilityTarget),
monday: String(resource.availability.monday),
tuesday: String(resource.availability.tuesday),
wednesday: String(resource.availability.wednesday),
thursday: String(resource.availability.thursday),
friday: String(resource.availability.friday),
skills,
roles,
portfolioUrl: resourceWithMeta.portfolioUrl ?? "",
roleId: resourceWithMeta.roleId ?? "",
postalCode: (resource as unknown as { postalCode?: string | null }).postalCode ?? "",
federalState: (resource as unknown as { federalState?: string | null }).federalState ?? "",
countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "",
metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "",
orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "",
managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "",
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
clientUnitId: (resource as unknown as { clientUnitId?: string | null }).clientUnitId ?? "",
fte: String((resource as unknown as { fte?: number }).fte ?? 1),
};
}
function defaultFormState(): FormState {
return {
eid: "",
displayName: "",
email: "",
chapter: "",
lcrEuros: "",
ucrEuros: "",
currency: "EUR",
chargeabilityTarget: "80",
monday: "8",
tuesday: "8",
wednesday: "8",
thursday: "8",
friday: "8",
skills: [],
roles: [],
portfolioUrl: "",
roleId: "",
postalCode: "",
federalState: "",
countryId: "",
metroCityId: "",
orgUnitId: "",
managementLevelGroupId: "",
managementLevelId: "",
resourceType: "EMPLOYEE",
chgResponsibility: true,
enterpriseId: "",
clientUnitId: "",
fte: "1",
};
}
function defaultSkillRow(): SkillRow {
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false };
}
interface ResourceModalProps {
mode: "create" | "edit";
resource?: Resource;
onClose: () => void;
}
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const SECTION_HEADER_CLASS = "text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4";
const PRIMARY_BTN =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
function Spinner() {
return (
<svg
className="animate-spin h-4 w-4 text-white inline-block mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
const [form, setForm] = useState<FormState>(() =>
resource ? resourceToFormState(resource) : defaultFormState(),
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const { data: availableRoles } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 });
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// Derive metro cities from selected country
const selectedCountry = (countries ?? []).find((c) => c.id === form.countryId) as unknown as { id: string; metroCities: { id: string; name: string }[] } | undefined;
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = (mgmtGroups ?? []).find((g) => g.id === form.managementLevelGroupId) as unknown as { id: string; levels: { id: string; name: string }[] } | undefined;
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message ?? "An error occurred while saving.");
},
});
const updateMutation = trpc.resource.update.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message ?? "An error occurred while saving.");
},
});
const isMutating = createMutation.isPending || updateMutation.isPending;
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrorMsg(null);
}
function setSkillField(index: number, key: keyof SkillRow, value: string | number | boolean) {
setForm((prev) => {
const skills = prev.skills.map((s, i) => (i === index ? { ...s, [key]: value } : s));
return { ...prev, skills };
});
}
function addSkill() {
setForm((prev) => ({ ...prev, skills: [...prev.skills, defaultSkillRow()] }));
}
function removeSkill(index: number) {
setForm((prev) => ({ ...prev, skills: prev.skills.filter((_, i) => i !== index) }));
}
function buildPayload() {
const lcrCents = Math.round(parseFloat(form.lcrEuros || "0") * 100);
const ucrCents = Math.round(parseFloat(form.ucrEuros || "0") * 100);
const chargeabilityTarget = parseFloat(form.chargeabilityTarget || "80");
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
const skills = form.skills
.filter((s) => s.skill.trim() !== "")
.map((s) => ({
skill: s.skill.trim(),
proficiency: s.proficiency,
...(s.yearsExperience !== "" ? { yearsExperience: parseFloat(s.yearsExperience) } : {}),
...(s.category.trim() !== "" ? { category: s.category.trim() } : {}),
...(s.certified ? { certified: s.certified } : {}),
...(s.isMainSkill ? { isMainSkill: true } : {}),
}));
void mainSkillCount; // used for UI validation only
return {
eid: form.eid.trim(),
displayName: form.displayName.trim(),
email: form.email.trim(),
...(form.chapter.trim() !== "" ? { chapter: form.chapter.trim() } : {}),
lcrCents,
ucrCents,
currency: form.currency,
chargeabilityTarget,
availability: {
monday: parseFloat(form.monday || "8"),
tuesday: parseFloat(form.tuesday || "8"),
wednesday: parseFloat(form.wednesday || "8"),
thursday: parseFloat(form.thursday || "8"),
friday: parseFloat(form.friday || "8"),
},
skills,
roles: form.roles,
...(form.portfolioUrl.trim() !== "" ? { portfolioUrl: form.portfolioUrl.trim() } : {}),
...(form.roleId.trim() !== "" ? { roleId: form.roleId.trim() } : {}),
...(form.postalCode.trim() !== "" ? { postalCode: form.postalCode.trim() } : {}),
...(form.federalState.trim() !== "" ? { federalState: form.federalState.trim() } : {}),
...(form.countryId ? { countryId: form.countryId } : {}),
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}),
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility,
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
fte: parseFloat(form.fte) || 1,
};
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrorMsg(null);
const payload = buildPayload();
if (mode === "create") {
createMutation.mutate(payload);
} else if (resource) {
updateMutation.mutate({ id: resource.id, data: payload });
}
}
const proficiencyLabels: Record<number, string> = {
1: "1 Beginner",
2: "2 Elementary",
3: "3 Intermediate",
4: "4 Advanced",
5: "5 Expert",
};
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{mode === "create" ? "New Resource" : "Edit Resource"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close modal"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} noValidate>
<div className="px-6 pb-4">
{/* Section 1: Basic Info */}
<p className={SECTION_HEADER_CLASS}>Basic Info</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-eid">
Employee ID <span className="text-red-500">*</span>
</label>
<input
id="rm-eid"
type="text"
className={INPUT_CLASS}
placeholder="EMP-042"
value={form.eid}
onChange={(e) => setField("eid", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-displayName">
Display Name <span className="text-red-500">*</span>
</label>
<input
id="rm-displayName"
type="text"
className={INPUT_CLASS}
placeholder="Jane Smith"
value={form.displayName}
onChange={(e) => setField("displayName", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-email">
Email <span className="text-red-500">*</span>
</label>
<input
id="rm-email"
type="email"
className={INPUT_CLASS}
placeholder="jane@example.com"
value={form.email}
onChange={(e) => setField("email", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chapter">
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-chapter"
type="text"
className={INPUT_CLASS}
placeholder="Engineering"
value={form.chapter}
onChange={(e) => setField("chapter", e.target.value)}
/>
</div>
</div>
{/* Portfolio & Role */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-portfolioUrl">
Portfolio URL <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-portfolioUrl"
type="url"
className={INPUT_CLASS}
placeholder="https://artstation.com/…"
value={form.portfolioUrl}
onChange={(e) => setField("portfolioUrl", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-roleId">
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<select
id="rm-roleId"
className={INPUT_CLASS}
value={form.roleId}
onChange={(e) => setField("roleId", e.target.value)}
>
<option value=""> Not specified </option>
{(availableRoles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
</div>
{/* Postal Code & Federal State */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-postalCode">
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-postalCode"
type="text"
className={INPUT_CLASS}
placeholder="80331"
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
setField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
setField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-federalState">
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<select
id="rm-federalState"
className={INPUT_CLASS}
value={form.federalState}
onChange={(e) => setField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={SECTION_HEADER_CLASS}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-enterpriseId"
type="text"
className={INPUT_CLASS}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => setField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-fte">
FTE
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={INPUT_CLASS}
placeholder="1.0"
value={form.fte}
onChange={(e) => setField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-countryId">Country</label>
<select
id="rm-countryId"
className={INPUT_CLASS}
value={form.countryId}
onChange={(e) => {
setField("countryId", e.target.value);
setField("metroCityId", ""); // reset city when country changes
}}
>
<option value=""> Not specified </option>
{(countries ?? []).map((c) => (
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-metroCityId">Metro City</label>
<select
id="rm-metroCityId"
className={INPUT_CLASS}
value={form.metroCityId}
onChange={(e) => setField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-orgUnitId">Org Unit (L7 Team)</label>
<select
id="rm-orgUnitId"
className={INPUT_CLASS}
value={form.orgUnitId}
onChange={(e) => setField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{(orgUnits ?? [])
.filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive)
.map((u) => (
<option key={u.id} value={u.id}>{(u as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-clientUnitId">Client Unit</label>
<select
id="rm-clientUnitId"
className={INPUT_CLASS}
value={form.clientUnitId}
onChange={(e) => setField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{(clients ?? []).map((c) => (
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group</label>
<select
id="rm-mgmtGroupId"
className={INPUT_CLASS}
value={form.managementLevelGroupId}
onChange={(e) => {
setField("managementLevelGroupId", e.target.value);
setField("managementLevelId", ""); // reset level when group changes
}}
>
<option value=""> Not specified </option>
{(mgmtGroups ?? []).map((g) => (
<option key={g.id} value={g.id}>{(g as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level</label>
<select
id="rm-mgmtLevelId"
className={INPUT_CLASS}
value={form.managementLevelId}
onChange={(e) => setField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
<select
id="rm-resourceType"
className={INPUT_CLASS}
value={form.resourceType}
onChange={(e) => setField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>{t.charAt(0) + t.slice(1).toLowerCase()}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.chgResponsibility}
onChange={(e) => setField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */}
<p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-lcr">
LCR &euro;/h <span className="text-red-500">*</span>
</label>
<input
id="rm-lcr"
type="number"
min="0"
step="0.01"
className={INPUT_CLASS}
placeholder="80"
value={form.lcrEuros}
onChange={(e) => setField("lcrEuros", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-ucr">
UCR &euro;/h <span className="text-red-500">*</span>
</label>
<input
id="rm-ucr"
type="number"
min="0"
step="0.01"
className={INPUT_CLASS}
placeholder="120"
value={form.ucrEuros}
onChange={(e) => setField("ucrEuros", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-currency">
Currency
</label>
<select
id="rm-currency"
className={INPUT_CLASS}
value={form.currency}
onChange={(e) => setField("currency", e.target.value)}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chargeability">
Chargeability Target %
</label>
<input
id="rm-chargeability"
type="number"
min="0"
max="100"
className={INPUT_CLASS}
placeholder="80"
value={form.chargeabilityTarget}
onChange={(e) => setField("chargeabilityTarget", e.target.value)}
/>
</div>
</div>
{/* Section 3: Weekly Availability */}
<p className={SECTION_HEADER_CLASS}>Weekly Availability (hours/day)</p>
<div className="grid grid-cols-5 gap-3">
{(
[
["monday", "Mon"],
["tuesday", "Tue"],
["wednesday", "Wed"],
["thursday", "Thu"],
["friday", "Fri"],
] as const
).map(([day, label]) => (
<div key={day}>
<label className={LABEL_CLASS} htmlFor={`rm-${day}`}>
{label}
</label>
<input
id={`rm-${day}`}
type="number"
min="0"
max="24"
step="0.5"
className={INPUT_CLASS}
value={form[day]}
onChange={(e) => setField(day, e.target.value)}
/>
</div>
))}
</div>
{/* Section 4: Skills */}
<p className={SECTION_HEADER_CLASS}>Skills</p>
<div className="space-y-3">
{form.skills.map((skillRow, idx) => {
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2;
return (
<div
key={idx}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`}
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={INPUT_CLASS}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => setSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={INPUT_CLASS}
value={skillRow.proficiency}
onChange={(e) =>
setSkillField(idx, "proficiency", parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={INPUT_CLASS}
placeholder="—"
value={skillRow.yearsExperience}
onChange={(e) => setSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none"> Main</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => setSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => removeSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={addSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
{/* Section 5: Roles */}
<p className={SECTION_HEADER_CLASS}>Roles</p>
<div className="space-y-2">
{(availableRoles ?? []).map((role) => {
const assignment = form.roles.find((r) => r.roleId === role.id);
const isChecked = Boolean(assignment);
return (
<div key={role.id} className="flex items-center gap-3 py-1">
<input
type="checkbox"
id={`role-${role.id}`}
checked={isChecked}
onChange={(e) => {
if (e.target.checked) {
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
} else {
setField("roles", form.roles.filter((r) => r.roleId !== role.id));
}
}}
className="rounded border-gray-300"
/>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<label htmlFor={`role-${role.id}`} className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1">
{role.name}
</label>
{isChecked && (
<label className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="radio"
name="primary-role"
checked={assignment?.isPrimary ?? false}
onChange={() => {
setField("roles", form.roles.map((r) =>
r.roleId === role.id
? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
));
}}
className="border-gray-300"
/>
Primary
</label>
)}
</div>
);
})}
{(availableRoles ?? []).length === 0 && (
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
)}
</div>
{/* Error message */}
{errorMsg && (
<div className="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{errorMsg}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<button
type="button"
onClick={onClose}
disabled={isMutating}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button type="submit" disabled={isMutating} className={PRIMARY_BTN}>
{isMutating && <Spinner />}
{isMutating ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}