"use client"; import { useRef, useState } from "react"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import type { Resource, SkillEntry, ResourceType } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { usePermissions } from "~/hooks/usePermissions.js"; import { ResourceOrgClassification } from "./ResourceOrgClassification.js"; import { ResourceSkillsEditor } from "./ResourceSkillsEditor.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 ( ); } export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceModalProps) { const [form, setForm] = useState(() => resource ? resourceToFormState(resource) : defaultFormState(), ); const [errorMsg, setErrorMsg] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const panelRef = useRef(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[]; 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(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); } } return (
{ if (e.target === e.currentTarget) onClose(); }} >
{ if (e.key === "Escape") onClose(); }} > {/* Header */}

{mode === "create" ? "New Resource" : "Edit Resource"}

{/* Form */}
{/* Section 1: Basic Info */}

Basic Info

setField("eid", e.target.value)} required />
setField("displayName", e.target.value)} required />
setField("email", e.target.value)} required />
setField("chapter", e.target.value)} list="rm-chapter-list" /> {chapters?.map((c) => (
{/* Portfolio & Role */}
setField("portfolioUrl", e.target.value)} />
void} countryOptions={countryOptions} orgUnitOptions={orgUnitOptions} clientOptions={clientOptions} managementGroupOptions={managementGroupOptions} inputClass={INPUT_CLASS} labelClass={LABEL_CLASS} sectionHeaderClass={SECTION_HEADER_CLASS} /> {/* Section 2: Cost & Chargeability */}

Cost & Chargeability

setField("lcrEuros", e.target.value)} required />
setField("ucrEuros", e.target.value)} required />
setField("chargeabilityTarget", e.target.value)} />
{/* Section 3: Weekly Availability */}

Weekly Availability (hours/day)

{( [ ["monday", "Mon"], ["tuesday", "Tue"], ["wednesday", "Wed"], ["thursday", "Thu"], ["friday", "Fri"], ] as const ).map(([day, label]) => (
setField(day, e.target.value)} />
))}
{/* Section 4: Skills */}

Skills

{/* Section 5: Roles */}

Roles

{roleOptions.map((role) => { const assignment = form.roles.find((r) => r.roleId === role.id); const isChecked = Boolean(assignment); return (
{ 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" />
{isChecked && ( )}
); })} {roleOptions.length === 0 && (

No roles defined yet. Create roles on the Roles page.

)}
{/* Error message */} {errorMsg && (
{errorMsg}
)}
{/* Footer */}
{mode === "edit" && canManageUsers && resource && (confirmDelete ? (
Permanently delete this resource?
) : ( ))}
); }