feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -19,7 +19,10 @@ interface NewAllocationPopoverProps {
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function NewAllocationPopover({
|
||||
@@ -50,7 +53,8 @@ export function NewAllocationPopover({
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projects = (projectsData?.projects ?? []) as Array<{ id: string; name: string; orderType?: string }>;
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
|
||||
|
||||
@@ -94,57 +98,50 @@ export function NewAllocationPopover({
|
||||
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
|
||||
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
|
||||
|
||||
const ORDER_COLORS: Record<string, string> = {
|
||||
CHARGEABLE: "bg-emerald-100 text-emerald-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
BD: "bg-violet-100 text-violet-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-2xl overflow-hidden"
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm font-semibold text-gray-700">Assign to Project</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Assign to Project</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
|
||||
<DateInput
|
||||
value={start}
|
||||
onChange={setStart}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
|
||||
<DateInput
|
||||
value={end}
|
||||
onChange={setEnd}
|
||||
min={start}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Project</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label>
|
||||
{selectedProject && !dropdownOpen ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border border-brand-300 rounded-lg px-3 py-2 cursor-pointer bg-brand-50"
|
||||
className="flex items-center gap-2 border border-brand-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-brand-50 dark:bg-sky-950/30"
|
||||
onClick={() => { setDropdownOpen(true); setSearch(""); }}
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span>
|
||||
<span className="text-xs text-gray-400">▾</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">{selectedProject.name}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">▾</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
@@ -155,18 +152,18 @@ export function NewAllocationPopover({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{dropdownOpen && projects.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 bg-white border border-gray-200 rounded-xl shadow-lg mt-1 max-h-44 overflow-y-auto">
|
||||
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2 border-b border-gray-50 last:border-0"
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate">{p.name}</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,18 +174,18 @@ export function NewAllocationPopover({
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Hours / day</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -197,7 +194,7 @@ export function NewAllocationPopover({
|
||||
step={0.5}
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
||||
className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
className="w-24 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[4, 6, 8].map((h) => (
|
||||
@@ -209,7 +206,7 @@ export function NewAllocationPopover({
|
||||
"px-2 py-1 rounded text-xs font-medium border transition-colors",
|
||||
hoursPerDay === h
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
: "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
{h}h
|
||||
@@ -220,13 +217,13 @@ export function NewAllocationPopover({
|
||||
</div>
|
||||
|
||||
{/* Overbooking notice */}
|
||||
<p className="text-xs text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 rounded-lg">
|
||||
Overlapping allocations are allowed — resource may be overbooked.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{createMutation.isError && (
|
||||
<p className="text-xs text-red-600">{createMutation.error.message}</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{createMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
@@ -243,7 +240,7 @@ export function NewAllocationPopover({
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user