Files
Nexus/apps/web/src/components/resources/ResourceModal.tsx
T
Hartmut a9ad1ed8b6 feat(G-08): chapter field uses live datalist from resource.chapters
All chapter text inputs now show autocomplete suggestions from the
database (distinct chapter values from active resources) via HTML
<datalist> wired to trpc.resource.chapters:

- ResourceModal: chapter input
- RateCardsClient: rate card line chapter input
- EffortRulesClient: effort rule chapter input
- ExperienceMultipliersClient: replaces hardcoded CHAPTER_PRESETS
  with live data, falls back to presets when no data available

Also revert blueprintRolePresetsInputSchema to z.array(z.unknown())
to restore compatibility with StaffingRequirement[] call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:10:36 +02:00

1036 lines
43 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 "@capakraken/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
interface RoleAssignment {
roleId: string;
isPrimary: boolean;
}
type RoleOption = { id: string; name: string; color?: string | null };
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
type OrgUnitOption = { id: string; name: string; level: number; isActive: boolean };
type ClientOption = { id: string; name: string };
type ManagementGroupOption = { id: string; name: string; levels: { id: string; name: string }[] };
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;
rolledOff: boolean;
departed: 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,
rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false,
departed: (resource as unknown as { departed?: boolean }).departed ?? false,
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,
rolledOff: false,
departed: false,
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;
onSuccess?: (displayName: string) => 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, onSuccess }: ResourceModalProps) {
const [form, setForm] = useState<FormState>(() =>
resource ? resourceToFormState(resource) : defaultFormState(),
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { canManageUsers } = usePermissions();
const utils = trpc.useUtils();
const { data: chapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
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 });
const roleOptions = (availableRoles ?? []) as unknown as RoleOption[];
const countryOptions = (countries ?? []) as unknown as CountryOption[];
const orgUnitOptions = (orgUnits ?? []) as unknown as OrgUnitOption[];
const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[];
const clientOptions = (clients ?? []) as unknown as ClientOption[];
// Derive metro cities from selected country
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation();
const updateMutation = trpc.resource.update.useMutation();
const hardDeleteMutation = trpc.resource.hardDelete.useMutation({
onSuccess: () => {
void utils.resource.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message);
},
});
const isMutating = createMutation.isPending || updateMutation.isPending || hardDeleteMutation.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,
rolledOff: form.rolledOff,
departed: form.departed,
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
fte: parseFloat(form.fte) || 1,
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrorMsg(null);
const payload = buildPayload();
try {
if (mode === "create") {
const created = await createMutation.mutateAsync(payload);
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
onSuccess?.(created.displayName);
onClose();
return;
} else if (resource) {
await updateMutation.mutateAsync({ id: resource.id, data: payload });
}
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred while saving.";
setErrorMsg(message);
}
}
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><InfoTooltip content="Unique employee identifier (e.g. EMP-042). Used for imports and cross-referencing." />
</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><InfoTooltip content="Full name shown in the timeline, reports, and staffing views." />
</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)}
list="rm-chapter-list"
/>
<datalist id="rm-chapter-list">
{chapters?.map((c) => <option key={c} value={c} />)}
</datalist>
</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><InfoTooltip content="The resource's primary area role. Used for skill matrix grouping and AI summary generation." />
</label>
<select
id="rm-roleId"
className={INPUT_CLASS}
value={form.roleId}
onChange={(e) => setField("roleId", e.target.value)}
>
<option value=""> Not specified </option>
{roleOptions.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><InfoTooltip content="German postal code. Used to auto-derive the federal state for public holiday calculations." />
</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><InfoTooltip content="Determines which public holidays apply (e.g. Bavaria has extra holidays). Auto-derived from postal code." />
</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><InfoTooltip content="Corporate directory ID for cross-system integration (e.g. a.kasperovich)." />
</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<InfoTooltip content="Full-Time Equivalent (0.01-1.0). A value of 0.5 means the resource works 50% of standard hours." />
</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>
{countryOptions.map((c) => (
<option key={c.id} value={c.id}>{c.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>
{orgUnitOptions
.filter((u) => u.level === 7 && u.isActive)
.map((u) => (
<option key={u.id} value={u.id}>{u.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>
{clientOptions.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-mgmtGroupId">Management Level Group<InfoTooltip content="Seniority grouping (e.g. Associate, Manager, Director). Determines the available management levels." /></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>
{managementGroupOptions.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level<InfoTooltip content="Specific seniority level within the group. Used in chargeability reports and cost analysis." /></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-4 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type<InfoTooltip content="Employee, contractor, or freelancer. Affects cost attribution rules." /></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 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.rolledOff}
onChange={(e) => setField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</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.departed}
onChange={(e) => setField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</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><InfoTooltip content="Loaded Cost Rate in EUR per hour. E.g. 85 = 85.00 EUR/h. Stored internally as integer cents (8500)." />
</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><InfoTooltip content="Unit Cost Rate in EUR per hour. The rate billed to the project or client." />
</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 %<InfoTooltip content="Target % of working time on chargeable projects. E.g. 80 means 80% of hours should be billable." />
</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">
{roleOptions.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>
);
})}
{roleOptions.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-between 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">
<div>
{mode === "edit" && canManageUsers && resource && (
confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-600 dark:text-red-400 font-medium">Permanently delete this resource?</span>
<button
type="button"
onClick={() => void hardDeleteMutation.mutateAsync({ id: resource.id })}
disabled={isMutating}
className="px-3 py-1.5 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50 transition-colors"
>
{hardDeleteMutation.isPending ? "Deleting…" : "Yes, delete"}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={isMutating}
className="px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={isMutating}
className="px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 border border-red-300 dark:border-red-700 rounded-lg disabled:opacity-50 transition-colors"
>
Delete Resource
</button>
)
)}
</div>
<div className="flex items-center gap-3">
<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>
</div>
</form>
</div>
</div>
);
}