bfcadd2c52
Extract overlay/popover JSX from TimelineView (1268→1037 lines) into TimelineDragOverlays and TimelinePopovers. Extract ResourceMonthConfigSection from ReportBuilder (1132→1018 lines). Extract ResourceSkillsEditor and ResourceOrgClassification from ResourceModal (1035→714 lines). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
766 lines
29 KiB
TypeScript
766 lines
29 KiB
TypeScript
"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 (
|
|
<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[];
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
<ResourceOrgClassification
|
|
form={form}
|
|
onSetField={setField as (key: string, value: string | boolean) => void}
|
|
countryOptions={countryOptions}
|
|
orgUnitOptions={orgUnitOptions}
|
|
clientOptions={clientOptions}
|
|
managementGroupOptions={managementGroupOptions}
|
|
inputClass={INPUT_CLASS}
|
|
labelClass={LABEL_CLASS}
|
|
sectionHeaderClass={SECTION_HEADER_CLASS}
|
|
/>
|
|
|
|
{/* Section 2: Cost & Chargeability */}
|
|
<p className={SECTION_HEADER_CLASS}>Cost & Chargeability</p>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={LABEL_CLASS} htmlFor="rm-lcr">
|
|
LCR €/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 €/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>
|
|
|
|
<ResourceSkillsEditor
|
|
skills={form.skills}
|
|
onSetSkillField={setSkillField}
|
|
onAddSkill={addSkill}
|
|
onRemoveSkill={removeSkill}
|
|
inputClass={INPUT_CLASS}
|
|
labelClass={LABEL_CLASS}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|