refactor(web): decompose TimelineView, ReportBuilder, and ResourceModal into focused components

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>
This commit is contained in:
2026-04-11 23:16:38 +02:00
parent 5a4836d292
commit bfcadd2c52
8 changed files with 1224 additions and 785 deletions
@@ -2,11 +2,12 @@
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 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;
@@ -105,10 +106,14 @@ function resourceToFormState(resource: Resource): FormState {
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 ?? "",
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,
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 ?? "",
@@ -154,7 +159,14 @@ function defaultFormState(): FormState {
}
function defaultSkillRow(): SkillRow {
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false };
return {
skill: "",
proficiency: 3,
yearsExperience: "",
category: "",
certified: false,
isMainSkill: false,
};
}
interface ResourceModalProps {
@@ -167,7 +179,8 @@ interface ResourceModalProps {
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 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";
@@ -211,7 +224,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
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: 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[];
@@ -220,14 +235,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
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({
@@ -240,7 +247,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
},
});
const isMutating = createMutation.isPending || updateMutation.isPending || hardDeleteMutation.isPending;
const isMutating =
createMutation.isPending || updateMutation.isPending || hardDeleteMutation.isPending;
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -306,7 +314,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
...(form.countryId ? { countryId: form.countryId } : {}),
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}),
...(form.managementLevelGroupId
? { managementLevelGroupId: form.managementLevelGroupId }
: {}),
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility,
@@ -345,14 +355,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
}
}
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"
@@ -363,7 +365,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<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(); }}
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">
@@ -376,7 +380,13 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
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}>
<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>
@@ -391,7 +401,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<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." />
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"
@@ -405,7 +416,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</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." />
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"
@@ -433,7 +445,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chapter">
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
Chapter{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-chapter"
@@ -445,7 +458,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
list="rm-chapter-list"
/>
<datalist id="rm-chapter-list">
{chapters?.map((c) => <option key={c} value={c} />)}
{chapters?.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
</div>
@@ -454,7 +469,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<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>
Portfolio URL{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-portfolioUrl"
@@ -467,7 +483,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</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." />
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"
@@ -477,241 +495,25 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
>
<option value=""> Not specified </option>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
<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>
<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 &amp; Chargeability</p>
@@ -719,7 +521,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<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)." />
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"
@@ -735,7 +538,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</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." />
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"
@@ -766,7 +570,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</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." />
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"
@@ -815,103 +620,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
{/* 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>
<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>
@@ -931,7 +647,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
if (e.target.checked) {
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
} else {
setField("roles", form.roles.filter((r) => r.roleId !== role.id));
setField(
"roles",
form.roles.filter((r) => r.roleId !== role.id),
);
}
}}
className="rounded border-gray-300"
@@ -940,7 +659,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
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">
<label
htmlFor={`role-${role.id}`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1"
>
{role.name}
</label>
{isChecked && (
@@ -950,11 +672,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
name="primary-role"
checked={assignment?.isPrimary ?? false}
onChange={() => {
setField("roles", form.roles.map((r) =>
r.roleId === role.id
? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
));
setField(
"roles",
form.roles.map((r) =>
r.roleId === role.id
? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
),
);
}}
className="border-gray-300"
/>
@@ -965,7 +690,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
);
})}
{roleOptions.length === 0 && (
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
<p className="text-sm text-gray-400 italic">
No roles defined yet. Create roles on the Roles page.
</p>
)}
</div>
@@ -980,10 +707,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
{/* 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 ? (
{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>
<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 })}
@@ -1010,8 +741,7 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
>
Delete Resource
</button>
)
)}
))}
</div>
<div className="flex items-center gap-3">
<button