Files
Nexus/apps/web/src/components/resources/ResourceModal.tsx
T

942 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}