Files
CapaKraken/apps/web/src/components/blueprints/RolePresetsEditor.tsx
T
Hartmut b0e55786c3 feat: AI assistant (HartBOT), demand filling, budget-per-role, project favorites, and UX improvements
AI Assistant (HartBOT):
- Chat panel with inline layout, session persistence, message history (up-arrow recall)
- OpenAI function calling with 20+ tools (search, navigate, create/cancel allocations, update status)
- RBAC-aware tool filtering, fuzzy search with word-level matching
- Navigation actions (router.push) and data invalidation after mutations
- Country/metro city/org unit/role filtering on resource search

Demand Filling Enhancements:
- Two-phase fill modal: plan multiple resources, then confirm & assign all at once
- Availability preview per resource (available/partial/conflict days, existing bookings)
- Coverage bar showing demand hours distribution across assigned resources
- Fill demand from project detail page (new Assign button per demand)
- Fixed: filled demands no longer shown on timeline, demand bars no longer overlap

Budget per Role:
- DemandRequirement.budgetCents field (schema + API + UI)
- Project wizard step 3: budget input per role with allocation summary bar
- Project detail: allocated vs booked budget per demand
- Fill demand modal: role budget display with cost estimates
- AllocationModal: budget field for demand editing

Project Favorites:
- User.favoriteProjectIds (JSONB) with toggle API
- Star button on projects list and detail page (optimistic updates)
- "My Projects" dashboard widget (favorites + responsible person projects)

Project Management:
- Edit project from detail page (ProjectModal integration)
- Edit demands from detail page (AllocationModal integration)
- Admin-only project deletion (cascades assignments + demands)
- Create user accounts from admin panel

Timeline Fixes:
- Country multi-select filter with backend support
- URL param sync for same-page navigation (AI assistant integration)
- Demand lane stacking (no more overlapping bars)
- Single-day booking resize handles (always visible, min 6px)
- Single-day resize allowed (start === end)
- "All Clients" toggle (select all / deselect all)

Other Fixes:
- crypto.randomUUID fallback for non-secure contexts
- Chat message limit raised (200 max, client sends last 40)
- Status dropdown portal (no longer clipped by table overflow)
- Cents display restored in budget views (2 decimal places)
- Allocations grouped view with project sub-groups (collapsed by default)
- Server-side resource search for project wizard (no 500 limit)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-16 15:31:48 +01:00

208 lines
6.0 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 { useState } from "react";
import type { StaffingRequirement } from "@planarchy/shared";
import { uuid } from "~/lib/uuid.js";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const BTN_DANGER =
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
function makeEmptyPreset(): StaffingRequirement {
return {
id: uuid(),
role: "",
requiredSkills: [],
preferredSkills: [],
hoursPerDay: 8,
headcount: 1,
};
}
interface PresetRowProps {
preset: StaffingRequirement;
onChange: (preset: StaffingRequirement) => void;
onDelete: () => void;
}
function PresetRow({ preset, onChange, onDelete }: PresetRowProps) {
function update<K extends keyof StaffingRequirement>(key: K, value: StaffingRequirement[K]) {
onChange({ ...preset, [key]: value });
}
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex flex-wrap items-center gap-2">
{/* Role */}
<input
type="text"
value={preset.role}
onChange={(e) => update("role", e.target.value)}
placeholder="Role name"
className={`${INPUT_CLS} flex-1 min-w-32`}
aria-label="Role name"
/>
{/* Required Skills */}
<div className="flex flex-col gap-0.5 flex-1 min-w-40">
<label className="text-xs text-gray-400">Required skills (comma-sep)</label>
<input
type="text"
value={preset.requiredSkills.join(", ")}
onChange={(e) =>
update(
"requiredSkills",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="e.g. 3D Modeling, Lighting"
className={INPUT_CLS}
aria-label="Required skills"
/>
</div>
{/* Hours per day */}
<div className="flex flex-col gap-0.5 w-24">
<label className="text-xs text-gray-400">h/day</label>
<input
type="number"
value={preset.hoursPerDay}
min={0}
max={24}
step={0.5}
onChange={(e) => update("hoursPerDay", parseFloat(e.target.value) || 0)}
className={INPUT_CLS}
aria-label="Hours per day"
/>
</div>
{/* Headcount */}
<div className="flex flex-col gap-0.5 w-20">
<label className="text-xs text-gray-400">Count</label>
<input
type="number"
value={preset.headcount}
min={1}
max={20}
onChange={(e) => update("headcount", parseInt(e.target.value, 10) || 1)}
className={INPUT_CLS}
aria-label="Headcount"
/>
</div>
{/* Delete */}
<button
type="button"
onClick={onDelete}
className={`${BTN_DANGER} self-end mb-0.5`}
aria-label="Remove preset"
>
×
</button>
</div>
{/* Preferred skills (secondary row) */}
<div className="mt-2">
<label className="text-xs text-gray-400">Preferred skills (optional)</label>
<input
type="text"
value={(preset.preferredSkills ?? []).join(", ")}
onChange={(e) =>
update(
"preferredSkills",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="e.g. Compositing, Art Direction"
className={`${INPUT_CLS} w-full mt-0.5`}
aria-label="Preferred skills"
/>
</div>
</div>
);
}
interface RolePresetsEditorProps {
initialPresets: StaffingRequirement[];
/** Called with the current presets array when the user clicks Save */
onSave: (presets: StaffingRequirement[]) => void;
isSaving?: boolean;
saveError?: string | null;
}
export function RolePresetsEditor({
initialPresets,
onSave,
isSaving = false,
saveError = null,
}: RolePresetsEditorProps) {
const [presets, setPresets] = useState<StaffingRequirement[]>(initialPresets);
function addPreset() {
setPresets((prev) => [...prev, makeEmptyPreset()]);
}
function removePreset(idx: number) {
setPresets((prev) => prev.filter((_, i) => i !== idx));
}
function updatePreset(idx: number, updated: StaffingRequirement) {
setPresets((prev) => prev.map((p, i) => (i === idx ? updated : p)));
}
return (
<div>
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
{presets.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">
No role presets yet. Click &ldquo;+ Add Role&rdquo; to define default staffing.
</p>
)}
{presets.map((preset, idx) => (
<PresetRow
key={preset.id}
preset={preset}
onChange={(updated) => updatePreset(idx, updated)}
onDelete={() => removePreset(idx)}
/>
))}
</div>
<div className="mt-3">
<button
type="button"
onClick={addPreset}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
>
<span className="text-lg leading-none">+</span> Add Role
</button>
</div>
{saveError && (
<div className="mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{saveError}
</div>
)}
<div className="flex justify-end mt-4">
<button
type="button"
onClick={() => onSave(presets)}
disabled={isSaving}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isSaving ? "Saving…" : "Save Presets"}
</button>
</div>
</div>
);
}