chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AllocationLike, AllocationReadModel, Assignment } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface AllocationPopoverProps {
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onOpenPanel: (projectId: string) => void;
|
||||
/** Pixel position relative to the viewport */
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
type AllocationPopoverAssignment = Assignment<AllocationLike>;
|
||||
|
||||
export function AllocationPopover({
|
||||
allocationId,
|
||||
projectId,
|
||||
onClose,
|
||||
onOpenPanel,
|
||||
anchorX,
|
||||
anchorY,
|
||||
}: AllocationPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 10_000 },
|
||||
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
|
||||
const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined;
|
||||
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [includeSaturday, setIncludeSaturday] = useState(false);
|
||||
const [role, setRole] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (allocation) {
|
||||
setHoursPerDay(allocation.hoursPerDay);
|
||||
setStartDate(toDateInput(new Date(allocation.startDate)));
|
||||
setEndDate(toDateInput(new Date(allocation.endDate)));
|
||||
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
|
||||
setIncludeSaturday(meta?.includeSaturday ?? false);
|
||||
setRole(allocation.role ?? "");
|
||||
}
|
||||
}, [allocation]);
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.allocation.listView.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!allocation || hoursPerDay === null) return;
|
||||
updateMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(allocation),
|
||||
hoursPerDay,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
includeSaturday,
|
||||
role: role || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Position popover so it stays on screen
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(anchorX, window.innerWidth - 320),
|
||||
top: Math.min(anchorY + 8, window.innerHeight - 360),
|
||||
zIndex: 50,
|
||||
width: 300,
|
||||
};
|
||||
|
||||
if (isLoading || !allocation) {
|
||||
return (
|
||||
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={popoverStyle}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{role}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Resource */}
|
||||
<div className="text-xs text-gray-500">
|
||||
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
|
||||
{" "}· <span className="text-gray-400">{allocation.resource?.eid}</span>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={hoursPerDay ?? ""}
|
||||
onChange={(e) => setHoursPerDay(parseFloat(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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<DateInput
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
|
||||
<DateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include Saturday */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSaturday}
|
||||
onChange={(e) => setIncludeSaturday(e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Include Saturdays</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{updateMutation.isError && (
|
||||
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className={clsx(
|
||||
"flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors",
|
||||
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link to full panel */}
|
||||
<button
|
||||
onClick={() => { onClose(); onOpenPanel(projectId); }}
|
||||
className="w-full text-xs text-brand-600 hover:text-brand-800 text-center pt-1"
|
||||
>
|
||||
Open Project Panel →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ConflictOverlayProps {
|
||||
/** Pixel left offset within the canvas */
|
||||
left: number;
|
||||
/** Pixel width */
|
||||
width: number;
|
||||
/** Row height */
|
||||
height: number;
|
||||
/** Conflict type */
|
||||
type: "availability" | "overlap" | "budget";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const ConflictOverlay = memo(function ConflictOverlay({ left, width, height, type, message }: ConflictOverlayProps) {
|
||||
const colors = {
|
||||
availability: "bg-red-400/30 border-red-400",
|
||||
overlap: "bg-orange-400/30 border-orange-400",
|
||||
budget: "bg-yellow-400/30 border-yellow-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute border-2 rounded-md pointer-events-none",
|
||||
"flex items-center justify-center",
|
||||
colors[type],
|
||||
)}
|
||||
style={{ left, width, height, top: 4 }}
|
||||
title={message}
|
||||
>
|
||||
<span className="text-xs font-medium text-red-700 bg-white/80 px-1 rounded">
|
||||
{type === "availability" ? "⚡ conflict" : type === "overlap" ? "⚠ overlap" : "💰 over budget"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface NewAllocationPopoverProps {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
/** Pre-selected project (from project-view sub-row context) */
|
||||
suggestedProjectId?: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
export function NewAllocationPopover({
|
||||
resourceId,
|
||||
startDate,
|
||||
endDate,
|
||||
suggestedProjectId,
|
||||
anchorX,
|
||||
anchorY,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: NewAllocationPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
suggestedProjectId ?? null,
|
||||
);
|
||||
const [role, setRole] = useState("Team Member");
|
||||
const [hoursPerDay, setHoursPerDay] = useState(8);
|
||||
const [start, setStart] = useState(toDateInput(startDate));
|
||||
const [end, setEnd] = useState(toDateInput(endDate));
|
||||
const [dropdownOpen, setDropdownOpen] = useState(!suggestedProjectId);
|
||||
|
||||
const { data: projectsData } = trpc.project.list.useQuery(
|
||||
{ search, limit: 20 },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
|
||||
|
||||
const createMutation = trpc.timeline.quickAssign.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
onCreated();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
function handleCreate() {
|
||||
if (!selectedProjectId) return;
|
||||
createMutation.mutate({
|
||||
resourceId,
|
||||
projectId: selectedProjectId,
|
||||
startDate: new Date(start),
|
||||
endDate: new Date(end),
|
||||
hoursPerDay,
|
||||
role,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
});
|
||||
}
|
||||
|
||||
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
|
||||
|
||||
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"
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 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"
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
autoFocus={dropdownOpen}
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
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"
|
||||
/>
|
||||
{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">
|
||||
{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"
|
||||
>
|
||||
<span className="text-sm text-gray-800 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
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"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[4, 6, 8].map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setHoursPerDay(h)}
|
||||
className={clsx(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overbooking notice */}
|
||||
<p className="text-xs text-amber-600 bg-amber-50 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>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate || createMutation.isPending}
|
||||
className={clsx(
|
||||
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{createMutation.isPending ? "Creating…" : "Assign"}
|
||||
</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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useState } from "react";
|
||||
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface ProjectPanelProps {
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DemandSummary {
|
||||
id: string;
|
||||
role: string;
|
||||
hoursPerDay: number;
|
||||
requestedHeadcount: number;
|
||||
}
|
||||
|
||||
interface ProjectPanelAssignment {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
sourceAllocationId?: string;
|
||||
resourceId: string;
|
||||
role: string | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
metadata: { includeSaturday?: boolean } | null;
|
||||
resource?: {
|
||||
displayName: string;
|
||||
eid: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProjectPanelDemand {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
sourceAllocationId?: string;
|
||||
role: string | null;
|
||||
hoursPerDay: number;
|
||||
requestedHeadcount: number;
|
||||
roleEntity?: {
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProjectPanelProject {
|
||||
name: string;
|
||||
orderType?: string;
|
||||
status?: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
budgetCents: number;
|
||||
staffingReqs?: unknown;
|
||||
}
|
||||
|
||||
interface ProjectPanelContext {
|
||||
project: ProjectPanelProject;
|
||||
assignments?: ProjectPanelAssignment[];
|
||||
demands?: ProjectPanelDemand[];
|
||||
}
|
||||
|
||||
interface ProjectPanelResource {
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter?: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
green: "bg-green-500",
|
||||
amber: "bg-amber-400",
|
||||
red: "bg-red-500",
|
||||
};
|
||||
|
||||
function toDateInput(d: Date | string): string {
|
||||
return new Date(d).toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
function normalizeRole(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 5_000 },
|
||||
);
|
||||
|
||||
const { data: budgetStatus } = trpc.timeline.getBudgetStatus.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 5_000 },
|
||||
);
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.allocation.deleteAssignment.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
setAddingMember(false);
|
||||
setResourceSearch("");
|
||||
},
|
||||
});
|
||||
|
||||
const [addingMember, setAddingMember] = useState(false);
|
||||
const [resourceSearch, setResourceSearch] = useState("");
|
||||
const [pendingEdits, setPendingEdits] = useState<
|
||||
Record<string, { hoursPerDay?: number; startDate?: string; endDate?: string; includeSaturday?: boolean; role?: string }>
|
||||
>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: allResources } = trpc.resource.list.useQuery(
|
||||
{ search: resourceSearch },
|
||||
{ enabled: addingMember, staleTime: 10_000 },
|
||||
);
|
||||
|
||||
if (isLoading || !ctx) {
|
||||
return (
|
||||
<PanelShell onClose={onClose}>
|
||||
<div className="flex items-center justify-center h-64 text-gray-400 text-sm">Loading…</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { project, assignments = [], demands = [] } = ctx as unknown as ProjectPanelContext;
|
||||
const staffingReqs = (project.staffingReqs as unknown as StaffingRequirement[]) ?? [];
|
||||
const effectiveAssignments = assignments as unknown as ProjectPanelAssignment[];
|
||||
const projectDemands = demands as unknown as ProjectPanelDemand[];
|
||||
const effectiveDemands: DemandSummary[] = projectDemands.length > 0
|
||||
? projectDemands.map((demand) => ({
|
||||
id: demand.id,
|
||||
role: demand.roleEntity?.name ?? demand.role ?? "Unassigned",
|
||||
hoursPerDay: demand.hoursPerDay,
|
||||
requestedHeadcount: demand.requestedHeadcount,
|
||||
}))
|
||||
: staffingReqs.map((req, index) => ({
|
||||
id: `staffing-${index}`,
|
||||
role: req.role,
|
||||
hoursPerDay: req.hoursPerDay,
|
||||
requestedHeadcount: req.headcount,
|
||||
}));
|
||||
|
||||
// Demand vs supply matching
|
||||
const reqMatches = effectiveDemands.map((demand) => {
|
||||
const demandRole = normalizeRole(demand.role);
|
||||
const matched = effectiveAssignments.filter((assignment) =>
|
||||
normalizeRole(assignment.role).includes(demandRole),
|
||||
);
|
||||
const totalHeadcount = matched.length;
|
||||
const fulfilled = totalHeadcount >= demand.requestedHeadcount;
|
||||
const partial = !fulfilled && totalHeadcount > 0;
|
||||
return { demand, matched, fulfilled, partial };
|
||||
});
|
||||
|
||||
const unmatchedAssignments = effectiveAssignments.filter(
|
||||
(assignment) =>
|
||||
!effectiveDemands.some((demand) =>
|
||||
normalizeRole(assignment.role).includes(normalizeRole(demand.role)),
|
||||
),
|
||||
);
|
||||
|
||||
// Budget bar
|
||||
const budgetEUR = (project.budgetCents / 100).toFixed(0);
|
||||
const allocatedEUR = budgetStatus ? (budgetStatus.allocatedCents / 100).toFixed(0) : "—";
|
||||
const utilPct = budgetStatus?.utilizationPercent ?? 0;
|
||||
const budgetBarColor =
|
||||
utilPct >= 100 ? STATUS_COLORS.red : utilPct >= 85 ? STATUS_COLORS.amber : STATUS_COLORS.green;
|
||||
|
||||
function getEdit(id: string) {
|
||||
return pendingEdits[id] ?? {};
|
||||
}
|
||||
|
||||
function setEdit(id: string, patch: typeof pendingEdits[string]) {
|
||||
setPendingEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? {}), ...patch } }));
|
||||
}
|
||||
|
||||
function saveEdit(allocId: string) {
|
||||
const edit = getEdit(allocId);
|
||||
const alloc = effectiveAssignments.find((a) => a.id === allocId);
|
||||
if (!alloc) return;
|
||||
updateMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(alloc),
|
||||
hoursPerDay: edit.hoursPerDay,
|
||||
startDate: edit.startDate ? new Date(edit.startDate) : undefined,
|
||||
endDate: edit.endDate ? new Date(edit.endDate) : undefined,
|
||||
includeSaturday: edit.includeSaturday,
|
||||
role: edit.role,
|
||||
});
|
||||
setPendingEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[allocId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddMember(resourceId: string) {
|
||||
createAssignmentMutation.mutate({
|
||||
resourceId,
|
||||
projectId,
|
||||
startDate: new Date(project.startDate),
|
||||
endDate: new Date(project.endDate),
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
role: "Team Member",
|
||||
status: AllocationStatus.PROPOSED,
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
|
||||
const availableResources = (allResources?.resources ?? []) as unknown as ProjectPanelResource[];
|
||||
const filteredResources = availableResources.filter(
|
||||
(r) => !effectiveAssignments.some((a) => a.resourceId === r.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelShell onClose={onClose}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">{project.name}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
project.orderType === "CHARGEABLE" ? "bg-emerald-100 text-emerald-700" :
|
||||
project.orderType === "BD" ? "bg-violet-100 text-violet-700" :
|
||||
project.orderType === "INTERNAL" ? "bg-blue-100 text-blue-700" :
|
||||
"bg-gray-100 text-gray-600",
|
||||
)}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{project.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{toDateInput(project.startDate)} → {toDateInput(project.endDate)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-6">
|
||||
|
||||
{/* Budget section */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Budget</h3>
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Allocated</span>
|
||||
<span className="font-semibold text-gray-900">€{allocatedEUR} / €{budgetEUR}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx("h-full rounded-full transition-all", budgetBarColor)}
|
||||
style={{ width: `${Math.min(utilPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{utilPct.toFixed(1)}% utilized</span>
|
||||
{budgetStatus && (
|
||||
<span>€{(budgetStatus.remainingCents / 100).toFixed(0)} remaining</span>
|
||||
)}
|
||||
</div>
|
||||
{budgetStatus?.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"text-xs px-2 py-1 rounded-lg",
|
||||
w.level === "critical" ? "bg-red-50 text-red-700" :
|
||||
w.level === "warning" ? "bg-amber-50 text-amber-700" :
|
||||
"bg-blue-50 text-blue-700",
|
||||
)}
|
||||
>
|
||||
{w.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Demand vs Supply */}
|
||||
{effectiveDemands.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Demand vs Supply
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{reqMatches.map(({ demand, matched, fulfilled, partial }) => (
|
||||
<div key={demand.id} className="border border-gray-100 rounded-xl overflow-hidden">
|
||||
<div className={clsx(
|
||||
"flex items-center justify-between px-3 py-2",
|
||||
fulfilled ? "bg-green-50" : partial ? "bg-amber-50" : "bg-red-50",
|
||||
)}>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-800">{demand.role}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">{demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day</span>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"text-xs font-semibold",
|
||||
fulfilled ? "text-green-600" : partial ? "text-amber-600" : "text-red-600",
|
||||
)}>
|
||||
{fulfilled ? "✓ Filled" : partial ? `${matched.length}/${demand.requestedHeadcount}` : "Unfilled"}
|
||||
</span>
|
||||
</div>
|
||||
{matched.length > 0 && (
|
||||
<div className="px-3 py-1.5 bg-white border-t border-gray-100 space-y-0.5">
|
||||
{matched.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>{a.resource?.displayName}</span>
|
||||
<span className="text-gray-400">{a.hoursPerDay}h/day</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{unmatchedAssignments.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-400 mb-1">Unmatched assignments</div>
|
||||
{unmatchedAssignments.map((a) => (
|
||||
<div key={a.id} className="text-xs text-gray-500 flex justify-between">
|
||||
<span>{a.resource?.displayName} — {a.role}</span>
|
||||
<span>{a.hoursPerDay}h/day</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Team */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Team</h3>
|
||||
<button
|
||||
onClick={() => setAddingMember(true)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
+ Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resource search for adding */}
|
||||
{addingMember && (
|
||||
<div className="mb-3 border border-gray-200 rounded-xl p-3 bg-gray-50 space-y-2">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search by name or EID…"
|
||||
value={resourceSearch}
|
||||
onChange={(e) => setResourceSearch(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
{filteredResources.slice(0, 8).map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleAddMember(r.id)}
|
||||
disabled={createAssignmentMutation.isPending}
|
||||
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white text-sm text-gray-800 border border-transparent hover:border-gray-200 transition-colors"
|
||||
>
|
||||
<span className="font-medium">{r.displayName}</span>
|
||||
<span className="text-gray-400 ml-2 text-xs">{r.eid}</span>
|
||||
{r.chapter && <span className="text-gray-300 ml-1 text-xs">· {r.chapter}</span>}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setAddingMember(false); setResourceSearch(""); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{effectiveAssignments.map((alloc) => {
|
||||
const edit = getEdit(alloc.id);
|
||||
const isDirty = Object.keys(edit).length > 0;
|
||||
const meta = alloc.metadata as { includeSaturday?: boolean } | null;
|
||||
const inclSat = edit.includeSaturday ?? meta?.includeSaturday ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="border border-gray-100 rounded-xl p-3 space-y-2 bg-white"
|
||||
>
|
||||
{/* Resource name + delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-800">{alloc.resource?.displayName}</span>
|
||||
<span className="text-xs text-gray-400 ml-1.5">{alloc.resource?.eid}</span>
|
||||
</div>
|
||||
{confirmDelete === alloc.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-600">Remove?</span>
|
||||
<button
|
||||
onClick={() => { deleteMutation.mutate({ id: getPlanningEntryMutationId(alloc) }); setConfirmDelete(null); }}
|
||||
className="text-xs text-red-600 font-medium hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(alloc.id)}
|
||||
className="text-xs text-red-400 hover:text-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<input
|
||||
type="text"
|
||||
value={edit.role ?? alloc.role ?? ""}
|
||||
onChange={(e) => setEdit(alloc.id, { role: e.target.value })}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Role"
|
||||
/>
|
||||
|
||||
{/* Dates + hours */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 mb-0.5">Start</label>
|
||||
<DateInput
|
||||
value={edit.startDate ?? toDateInput(alloc.startDate)}
|
||||
onChange={(v) => setEdit(alloc.id, { startDate: v })}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 mb-0.5">End</label>
|
||||
<DateInput
|
||||
value={edit.endDate ?? toDateInput(alloc.endDate)}
|
||||
onChange={(v) => setEdit(alloc.id, { endDate: v })}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 mb-0.5">h/day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={edit.hoursPerDay ?? alloc.hoursPerDay}
|
||||
onChange={(e) => setEdit(alloc.id, { hoursPerDay: parseFloat(e.target.value) })}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturday toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inclSat}
|
||||
onChange={(e) => setEdit(alloc.id, { includeSaturday: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">Include Saturdays</span>
|
||||
</label>
|
||||
|
||||
{/* Save button */}
|
||||
{isDirty && (
|
||||
<button
|
||||
onClick={() => saveEdit(alloc.id)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{effectiveAssignments.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
No team members yet. Add one above.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
<span className="text-sm font-semibold text-gray-700">Project Details</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { memo } from "react";
|
||||
import type { ShiftPreviewData } from "~/hooks/useTimelineDrag.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
|
||||
interface ShiftPreviewTooltipProps {
|
||||
preview: ShiftPreviewData;
|
||||
projectName: string;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
const abs = Math.abs(cents);
|
||||
const str = (abs / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 });
|
||||
return `${cents < 0 ? "−" : "+"}${str} €`;
|
||||
}
|
||||
|
||||
export const ShiftPreviewTooltip = memo(function ShiftPreviewTooltip({
|
||||
preview,
|
||||
projectName,
|
||||
newStartDate,
|
||||
newEndDate,
|
||||
isLoading,
|
||||
}: ShiftPreviewTooltipProps) {
|
||||
const { canViewCosts } = usePermissions();
|
||||
const dateStr = `${formatDate(newStartDate)} → ${formatDate(newEndDate)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-white border rounded-xl shadow-2xl p-3 min-w-56 max-w-72 text-sm",
|
||||
preview.valid ? "border-gray-200" : "border-red-300",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="font-semibold text-gray-900 truncate mb-2">{projectName}</div>
|
||||
<div className="text-xs text-gray-500 mb-3 font-mono">{dateStr}</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-gray-400 animate-pulse">Calculating...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Cost delta */}
|
||||
{canViewCosts && preview.deltaCents !== 0 && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500">Cost delta</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xs font-mono font-medium",
|
||||
preview.deltaCents > 0 ? "text-red-600" : "text-green-600",
|
||||
)}
|
||||
>
|
||||
{formatCents(preview.deltaCents)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget utilization */}
|
||||
{canViewCosts && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-500">Budget after</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xs font-medium",
|
||||
preview.wouldExceedBudget
|
||||
? "text-red-600"
|
||||
: preview.budgetUtilizationAfter > 85
|
||||
? "text-yellow-600"
|
||||
: "text-gray-700",
|
||||
)}
|
||||
>
|
||||
{preview.budgetUtilizationAfter.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflicts */}
|
||||
{preview.conflictCount > 0 && (
|
||||
<div className="mb-2 text-xs text-yellow-700 bg-yellow-50 rounded-lg px-2 py-1.5">
|
||||
⚠ {preview.conflictCount} availability conflict{preview.conflictCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{preview.errors.map((err, i) => (
|
||||
<div key={i} className="mb-1 text-xs text-red-700 bg-red-50 rounded-lg px-2 py-1.5">
|
||||
✗ {err}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Warnings */}
|
||||
{preview.warnings.slice(0, 2).map((warn, i) => (
|
||||
<div key={i} className="mb-1 text-xs text-yellow-700 bg-yellow-50 rounded-lg px-2 py-1">
|
||||
{warn}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Action hint */}
|
||||
<div className="mt-2 text-xs text-gray-400 text-center">
|
||||
{preview.valid ? "Release to apply shift" : "Cannot apply — resolve errors first"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
export interface TimelineFilters {
|
||||
chapters: string[];
|
||||
/** Filter to specific resource EIDs */
|
||||
eids: string[];
|
||||
/** Filter to specific project IDs */
|
||||
projectIds: string[];
|
||||
showWeekends: boolean;
|
||||
zoom: "day" | "week" | "month";
|
||||
/**
|
||||
* Hide allocations whose project has status COMPLETED or CANCELLED.
|
||||
* Defaults to the user's global app preference; can be toggled per session.
|
||||
*/
|
||||
hideCompletedProjects: boolean;
|
||||
/** Show DRAFT projects and their PROPOSED allocations on the timeline. */
|
||||
showDrafts: boolean;
|
||||
/** Show approved vacation blocks on resource rows. Default: true. */
|
||||
showVacations: boolean;
|
||||
/** Show open-demand entries (no resource assigned yet). Default: true. */
|
||||
showPlaceholders: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: TimelineFilters = {
|
||||
chapters: [],
|
||||
eids: [],
|
||||
projectIds: [],
|
||||
showWeekends: false,
|
||||
zoom: "day",
|
||||
hideCompletedProjects: true, // overridden at runtime from AppPreferences
|
||||
showDrafts: false,
|
||||
showVacations: true,
|
||||
showPlaceholders: true,
|
||||
};
|
||||
|
||||
interface TimelineFilterProps {
|
||||
filters: TimelineFilters;
|
||||
onChange: (filters: TimelineFilters) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ─── Chip ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-50 border border-brand-200 text-brand-700 rounded-full text-xs font-medium">
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-brand-400 hover:text-brand-700 leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── EID picker ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EidPicker({
|
||||
selectedEids,
|
||||
onChange,
|
||||
}: {
|
||||
selectedEids: string[];
|
||||
onChange: (eids: string[]) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
{ search, isActive: true, limit: 200 },
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
type ResourceRow = { id: string; eid: string; displayName: string; chapter: string | null };
|
||||
const suggestions = (data?.resources as ResourceRow[] | undefined ?? []).filter((r) => !selectedEids.includes(r.eid));
|
||||
|
||||
function add(eid: string) {
|
||||
onChange([...selectedEids, eid]);
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function remove(eid: string) {
|
||||
onChange(selectedEids.filter((e) => e !== eid));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{selectedEids.map((eid) => (
|
||||
<Chip key={eid} label={eid} onRemove={() => remove(eid)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search by name or EID…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{suggestions.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); add(r.eid); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<span className="font-mono text-gray-500 w-16 flex-shrink-0">{r.eid}</span>
|
||||
<span className="text-gray-800 truncate">{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 flex-shrink-0">{r.chapter}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Project picker ───────────────────────────────────────────────────────────
|
||||
|
||||
function ProjectPicker({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.project.list.useQuery(
|
||||
{ search, limit: 200 },
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
type ProjectRow = { id: string; shortCode: string; name: string };
|
||||
const suggestions = (data?.projects as ProjectRow[] | undefined ?? []).filter((p) => !selectedIds.includes(p.id));
|
||||
|
||||
// Labels for selected chips — need to resolve names
|
||||
const { data: allData } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const projectMap = new Map((allData?.projects as ProjectRow[] | undefined ?? []).map((p) => [p.id, p]));
|
||||
|
||||
function add(id: string) {
|
||||
onChange([...selectedIds, id]);
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{selectedIds.map((id) => {
|
||||
const p = projectMap.get(id);
|
||||
return (
|
||||
<Chip
|
||||
key={id}
|
||||
label={p ? p.name : id}
|
||||
onRemove={() => remove(id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{suggestions.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); add(p.id); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<span className="text-gray-800 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main filter panel ────────────────────────────────────────────────────────
|
||||
|
||||
export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineFilterProps) {
|
||||
const { data: resourceData } = trpc.resource.list.useQuery({ isActive: true, limit: 500 });
|
||||
const chapters = [
|
||||
...new Set(
|
||||
(resourceData?.resources as Array<{ chapter: string | null }> | undefined ?? []).map((r) => r.chapter).filter(Boolean) as string[],
|
||||
),
|
||||
].sort();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeCount =
|
||||
filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-12 z-30 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl w-80 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-brand-600">{activeCount} active</span>
|
||||
)}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom level */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Zoom</label>
|
||||
<div className="flex gap-2">
|
||||
{(["day", "week", "month"] as const).map((z) => (
|
||||
<button
|
||||
key={z}
|
||||
onClick={() => onChange({ ...filters, zoom: z })}
|
||||
className={clsx(
|
||||
"flex-1 px-2 py-1.5 text-xs rounded-lg border capitalize",
|
||||
filters.zoom === z
|
||||
? "bg-brand-50 border-brand-300 text-brand-700"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{z}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EID filter */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
People (EID)
|
||||
</label>
|
||||
<EidPicker
|
||||
selectedEids={filters.eids}
|
||||
onChange={(eids) => onChange({ ...filters, eids })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project filter */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Projects
|
||||
</label>
|
||||
<ProjectPicker
|
||||
selectedIds={filters.projectIds}
|
||||
onChange={(projectIds) => onChange({ ...filters, projectIds })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chapters */}
|
||||
{chapters.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Chapters
|
||||
</label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{chapters.map((ch) => (
|
||||
<label key={ch} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.chapters.includes(ch)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...filters.chapters, ch]
|
||||
: filters.chapters.filter((c) => c !== ch);
|
||||
onChange({ ...filters, chapters: next });
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibility toggles */}
|
||||
<div className="mb-4 space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Visibility</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showWeekends}
|
||||
onChange={(e) => onChange({ ...filters, showWeekends: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">Show weekends</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!filters.hideCompletedProjects}
|
||||
onChange={(e) => onChange({ ...filters, hideCompletedProjects: !e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show completed & cancelled
|
||||
<span className="block text-xs text-gray-400 font-normal">Default set in Preferences</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showDrafts}
|
||||
onChange={(e) => onChange({ ...filters, showDrafts: e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show draft projects
|
||||
<span className="block text-xs text-gray-400 font-normal">Shows PROPOSED allocations</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showVacations}
|
||||
onChange={(e) => onChange({ ...filters, showVacations: e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show vacation blocks
|
||||
<span className="block text-xs text-gray-400 font-normal">Approved leave on resource rows</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showPlaceholders}
|
||||
onChange={(e) => onChange({ ...filters, showPlaceholders: e.target.checked })}
|
||||
className="rounded border-gray-300 mt-0.5"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Show open demand
|
||||
<span className="block text-xs text-gray-400 font-normal">Dashed bars for unassigned staffing demand</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onChange(DEFAULT_FILTERS)}
|
||||
disabled={activeCount === 0 && !filters.showWeekends && filters.hideCompletedProjects && !filters.showDrafts && filters.showVacations && filters.showPlaceholders}
|
||||
className="w-full text-xs text-gray-500 hover:text-gray-700 underline disabled:opacity-40 disabled:no-underline"
|
||||
>
|
||||
Reset all filters
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { MONTHS_SHORT } from "./timelineConstants.js";
|
||||
|
||||
interface TimelineHeaderProps {
|
||||
monthGroups: { label: string; colCount: number }[];
|
||||
dates: Date[];
|
||||
CELL_WIDTH: number;
|
||||
LABEL_WIDTH: number;
|
||||
HEADER_MONTH_HEIGHT: number;
|
||||
HEADER_DAY_HEIGHT: number;
|
||||
zoom: "day" | "week" | "month";
|
||||
viewMode: "resource" | "project";
|
||||
today: Date;
|
||||
}
|
||||
|
||||
export function TimelineHeader({
|
||||
monthGroups,
|
||||
dates,
|
||||
CELL_WIDTH,
|
||||
LABEL_WIDTH,
|
||||
HEADER_MONTH_HEIGHT,
|
||||
HEADER_DAY_HEIGHT,
|
||||
zoom,
|
||||
viewMode,
|
||||
today,
|
||||
}: TimelineHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Month header */}
|
||||
<div
|
||||
className="sticky top-0 z-40 flex bg-white border-b border-gray-100"
|
||||
style={{ height: HEADER_MONTH_HEIGHT }}
|
||||
>
|
||||
<div className="flex-shrink-0 border-r border-gray-200" style={{ width: LABEL_WIDTH }} />
|
||||
<div className="flex">
|
||||
{monthGroups.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs font-semibold text-gray-500 border-r border-gray-200 px-2 flex items-center bg-gray-50"
|
||||
style={{ width: m.colCount * CELL_WIDTH }}
|
||||
>
|
||||
{m.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day header — hidden at month zoom (cells too narrow for labels) */}
|
||||
{zoom !== "month" && (
|
||||
<div
|
||||
className="sticky z-40 flex bg-gray-50 border-b border-gray-200 select-none"
|
||||
style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 flex items-center px-4 text-xs font-medium text-gray-400 uppercase tracking-wider"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
{viewMode === "resource" ? "Resource" : "Project / Resource"}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{dates.map((date, i) => {
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isMonday = date.getDay() === 1;
|
||||
const isSaturday = date.getDay() === 6;
|
||||
const isSunday = date.getDay() === 0;
|
||||
// Week zoom: show label only on Mondays to avoid overcrowding
|
||||
const showLabel = zoom === "day" || isMonday;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
|
||||
isToday ? "bg-brand-50 border-brand-200" :
|
||||
isSaturday ? "bg-amber-50/60 border-amber-200" :
|
||||
isSunday ? "bg-gray-100/80 border-gray-200" :
|
||||
isMonday ? "border-gray-200" : "border-gray-100",
|
||||
)}
|
||||
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
|
||||
>
|
||||
{showLabel && (
|
||||
<>
|
||||
<span className={clsx(
|
||||
"font-medium leading-none",
|
||||
isToday ? "text-brand-600" : isSaturday ? "text-amber-600" : "text-gray-600",
|
||||
)}>
|
||||
{zoom === "week"
|
||||
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
|
||||
: date.getDate()}
|
||||
</span>
|
||||
{zoom === "day" && (
|
||||
<span className={clsx(
|
||||
"text-[9px] leading-none mt-0.5",
|
||||
isSaturday ? "text-amber-400" : "text-gray-300",
|
||||
)}>
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
viewMode: "resource" | "project";
|
||||
onViewModeChange: (mode: "resource" | "project") => void;
|
||||
filters: TimelineFilters;
|
||||
onFiltersChange: (f: TimelineFilters) => void;
|
||||
filterOpen: boolean;
|
||||
onFilterOpenChange: (open: boolean) => void;
|
||||
resourceCount: number;
|
||||
projectCount: number;
|
||||
totalAllocCount: number;
|
||||
onNavigateBack: () => void;
|
||||
onNavigateToday: () => void;
|
||||
onNavigateForward: () => void;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}
|
||||
|
||||
export function TimelineToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
filterOpen,
|
||||
onFilterOpenChange,
|
||||
resourceCount,
|
||||
projectCount,
|
||||
totalAllocCount,
|
||||
onNavigateBack,
|
||||
onNavigateToday,
|
||||
onNavigateForward,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: TimelineToolbarProps) {
|
||||
const activeFilterCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2 gap-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
{viewMode === "resource"
|
||||
? `${resourceCount} resources · ${totalAllocCount} allocations`
|
||||
: `${projectCount} projects`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Timeline navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
title="Previous 4 weeks"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateToday}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateForward}
|
||||
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
title="Next 4 weeks"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Undo / Redo */}
|
||||
{(onUndo ?? onRedo) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
|
||||
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-sm">
|
||||
<button
|
||||
onClick={() => onViewModeChange("resource")}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 transition-colors",
|
||||
viewMode === "resource"
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
Resource view
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange("project")}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 border-l border-gray-200 transition-colors",
|
||||
viewMode === "project"
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
Project view
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => onFilterOpenChange(!filterOpen)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors",
|
||||
filterOpen || activeFilterCount > 0
|
||||
? "bg-brand-50 border-brand-300 text-brand-700"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
Filter
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="w-4 h-4 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<TimelineFilter
|
||||
filters={filters}
|
||||
onChange={onFiltersChange}
|
||||
isOpen={filterOpen}
|
||||
onClose={() => onFilterOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
|
||||
// ─── Heatmap colour palettes ───────────────────────────────────────────────────
|
||||
// Each palette is [minPct, overlayRgba, barRgba] — overlay is semi-transparent
|
||||
// (for heatmap mode strips), bar is more opaque (for bar-mode project view).
|
||||
export const HEATMAP_PALETTES: Record<HeatmapColorScheme, [number, string, string][]> = {
|
||||
"green-red": [
|
||||
[0, "rgba(34,197,94,0.18)", "rgba(34,197,94,0.70)"],
|
||||
[25, "rgba(132,204,22,0.25)", "rgba(132,204,22,0.75)"],
|
||||
[50, "rgba(250,204,21,0.32)", "rgba(250,204,21,0.80)"],
|
||||
[75, "rgba(249,115,22,0.40)", "rgba(249,115,22,0.82)"],
|
||||
[90, "rgba(239,68,68,0.48)", "rgba(239,68,68,0.85)"],
|
||||
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
|
||||
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
|
||||
],
|
||||
"blue-orange": [
|
||||
[0, "rgba(56,189,248,0.22)", "rgba(56,189,248,0.70)"],
|
||||
[25, "rgba(59,130,246,0.28)", "rgba(59,130,246,0.75)"],
|
||||
[50, "rgba(251,191,36,0.35)", "rgba(251,191,36,0.80)"],
|
||||
[75, "rgba(249,115,22,0.42)", "rgba(249,115,22,0.82)"],
|
||||
[90, "rgba(239,68,68,0.50)", "rgba(239,68,68,0.85)"],
|
||||
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
|
||||
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
|
||||
],
|
||||
"purple-yellow": [
|
||||
[0, "rgba(167,139,250,0.22)", "rgba(167,139,250,0.70)"],
|
||||
[25, "rgba(139,92,246,0.28)", "rgba(139,92,246,0.75)"],
|
||||
[50, "rgba(250,204,21,0.35)", "rgba(250,204,21,0.80)"],
|
||||
[75, "rgba(245,158,11,0.42)", "rgba(245,158,11,0.82)"],
|
||||
[90, "rgba(239,68,68,0.50)", "rgba(239,68,68,0.85)"],
|
||||
[100, "rgba(185,28,28,0.58)", "rgba(185,28,28,0.88)"],
|
||||
[125, "rgba(109,40,217,0.64)", "rgba(109,40,217,0.88)"],
|
||||
],
|
||||
"mono": [
|
||||
[0, "rgba(156,163,175,0.18)", "rgba(156,163,175,0.60)"],
|
||||
[25, "rgba(107,114,128,0.25)", "rgba(107,114,128,0.68)"],
|
||||
[50, "rgba(75,85,99,0.30)", "rgba(75,85,99,0.74)"],
|
||||
[75, "rgba(55,65,81,0.36)", "rgba(55,65,81,0.80)"],
|
||||
[90, "rgba(31,41,55,0.42)", "rgba(31,41,55,0.85)"],
|
||||
[100, "rgba(17,24,39,0.52)", "rgba(17,24,39,0.88)"],
|
||||
[125, "rgba(0,0,0,0.60)", "rgba(0,0,0,0.90)"],
|
||||
],
|
||||
};
|
||||
|
||||
// pct = (totalHoursPerDay / 8) * 100. Returns rgba string or null for 0%.
|
||||
// mode: "overlay" for heatmap strips, "bar" for solid bar fill.
|
||||
export function heatmapColor(pct: number, scheme: HeatmapColorScheme, mode: "overlay" | "bar" = "overlay"): string | null {
|
||||
if (pct <= 0) return null;
|
||||
const palette = HEATMAP_PALETTES[scheme] ?? HEATMAP_PALETTES["green-red"];
|
||||
let entry = palette[0]!;
|
||||
for (const row of palette) {
|
||||
if (pct > row[0]) entry = row;
|
||||
else break;
|
||||
}
|
||||
return mode === "bar" ? entry[2] : entry[1];
|
||||
}
|
||||
|
||||
// Legacy alias used by heatmap overlay (overlay mode, green-red default)
|
||||
export function heatmapBgColor(pct: number, scheme: HeatmapColorScheme = "green-red"): string | null {
|
||||
return heatmapColor(pct, scheme, "overlay");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// ─── Layout constants ──────────────────────────────────────────────────────────
|
||||
export const ROW_HEIGHT = 52;
|
||||
export const SUB_LANE_HEIGHT = 36;
|
||||
export const HEADER_DAY_HEIGHT = 28;
|
||||
export const HEADER_MONTH_HEIGHT = 24;
|
||||
export const LABEL_WIDTH = 256;
|
||||
export const PROJECT_HEADER_HEIGHT = 40;
|
||||
|
||||
export const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
|
||||
export const ORDER_TYPE_COLORS: Record<string, { bg: string; text: string; light: string }> = {
|
||||
CHARGEABLE: { bg: "bg-emerald-500", text: "text-white", light: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800" },
|
||||
INTERNAL: { bg: "bg-blue-500", text: "text-white", light: "bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800" },
|
||||
BD: { bg: "bg-violet-500", text: "text-white", light: "bg-violet-50 border-violet-200 dark:bg-violet-950 dark:border-violet-800" },
|
||||
OVERHEAD: { bg: "bg-slate-400", text: "text-white", light: "bg-slate-50 border-slate-200 dark:bg-slate-800 dark:border-slate-700" },
|
||||
};
|
||||
|
||||
export const ORDER_TYPE_BADGE: Record<string, string> = {
|
||||
CHARGEABLE: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
|
||||
INTERNAL: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
||||
BD: "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300",
|
||||
OVERHEAD: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
|
||||
};
|
||||
|
||||
export const DONE_STATUSES = new Set(["COMPLETED", "CANCELLED"]);
|
||||
@@ -0,0 +1,220 @@
|
||||
// ─── Date helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function daysBetween(a: Date, b: Date): number {
|
||||
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date to a left-pixel offset within the visible timeline.
|
||||
* Accounts for weekend-skipping when showWeekends is false.
|
||||
*/
|
||||
export function dateToLeft(
|
||||
date: Date,
|
||||
viewStart: Date,
|
||||
viewEnd: Date,
|
||||
cellWidth: number,
|
||||
showWeekends: boolean,
|
||||
): number {
|
||||
const clamped = date < viewStart ? viewStart : date > viewEnd ? viewEnd : date;
|
||||
if (showWeekends) {
|
||||
return daysBetween(viewStart, clamped) * cellWidth;
|
||||
}
|
||||
let count = 0;
|
||||
const cur = new Date(viewStart);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const target = new Date(clamped);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
while (cur < target) {
|
||||
const dow = cur.getDay();
|
||||
if (dow !== 0 && dow !== 6) count++;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return count * cellWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the pixel width of a date range within the visible timeline.
|
||||
* Accounts for weekend-skipping when showWeekends is false.
|
||||
*/
|
||||
export function dateRangeToWidth(
|
||||
start: Date,
|
||||
end: Date,
|
||||
viewStart: Date,
|
||||
viewEnd: Date,
|
||||
cellWidth: number,
|
||||
showWeekends: boolean,
|
||||
): number {
|
||||
let count = 0;
|
||||
const cur = new Date(start < viewStart ? viewStart : start);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endC = new Date(end > viewEnd ? viewEnd : end);
|
||||
endC.setHours(0, 0, 0, 0);
|
||||
while (cur <= endC) {
|
||||
const dow = cur.getDay();
|
||||
if (showWeekends || (dow !== 0 && dow !== 6)) count++;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return count * cellWidth;
|
||||
}
|
||||
|
||||
// ─── O(1) position cache ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-computes a pixel-offset lookup table for the entire visible date range.
|
||||
* Returns `toLeft` / `toWidth` with O(1) lookups instead of O(n) loops.
|
||||
* Use inside a `useMemo` that depends on viewStart / viewDays / cellWidth / showWeekends.
|
||||
*/
|
||||
export function createDatePositionCache(
|
||||
viewStart: Date,
|
||||
viewDays: number,
|
||||
cellWidth: number,
|
||||
showWeekends: boolean,
|
||||
): { toLeft: (date: Date) => number; toWidth: (start: Date, end: Date) => number } {
|
||||
// offsetMap: day-start-timestamp → pixel left rankMap: same → 0-based visible-day index
|
||||
const offsetMap = new Map<number, number>();
|
||||
const rankMap = new Map<number, number>();
|
||||
|
||||
let rank = 0;
|
||||
const cur = new Date(viewStart);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const viewStartT = cur.getTime();
|
||||
|
||||
for (let i = 0; i < viewDays; i++) {
|
||||
const dow = cur.getDay();
|
||||
if (showWeekends || (dow !== 0 && dow !== 6)) {
|
||||
const t = cur.getTime();
|
||||
offsetMap.set(t, rank * cellWidth);
|
||||
rankMap.set(t, rank);
|
||||
rank++;
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalWidth = rank * cellWidth;
|
||||
const viewEndT = cur.getTime(); // timestamp of the day AFTER the last visible day
|
||||
|
||||
function toLeft(date: Date): number {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const t = d.getTime();
|
||||
if (t <= viewStartT) return 0;
|
||||
if (t >= viewEndT) return totalWidth;
|
||||
const cached = offsetMap.get(t);
|
||||
if (cached !== undefined) return cached;
|
||||
// Weekend when showWeekends=false: return the *next* visible day's offset.
|
||||
// This matches the original dateToLeft which counts strictly-before business days,
|
||||
// so Saturday gets the same offset as the following Monday.
|
||||
const next = new Date(d);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
if (next.getTime() >= viewEndT) return totalWidth;
|
||||
const v = offsetMap.get(next.getTime());
|
||||
if (v !== undefined) return v;
|
||||
}
|
||||
return totalWidth;
|
||||
}
|
||||
|
||||
function toWidth(start: Date, end: Date): number {
|
||||
const sNorm = new Date(start < viewStart ? viewStart : start);
|
||||
sNorm.setHours(0, 0, 0, 0);
|
||||
const eNorm = new Date(end);
|
||||
eNorm.setHours(0, 0, 0, 0);
|
||||
|
||||
// Rank of the first visible day at-or-after sNorm
|
||||
let sRank: number;
|
||||
const sT = sNorm.getTime();
|
||||
const rS = rankMap.get(sT);
|
||||
if (rS !== undefined) {
|
||||
sRank = rS;
|
||||
} else {
|
||||
sRank = rank; // default: past end → 0 width
|
||||
const next = new Date(sNorm);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
if (next.getTime() >= viewEndT) break;
|
||||
const r = rankMap.get(next.getTime());
|
||||
if (r !== undefined) { sRank = r; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Rank of the last visible day at-or-before eNorm
|
||||
let eRank: number;
|
||||
const eT = eNorm.getTime();
|
||||
if (eT >= viewEndT) {
|
||||
eRank = rank - 1; // clamp to last visible day
|
||||
} else {
|
||||
const rE = rankMap.get(eT);
|
||||
if (rE !== undefined) {
|
||||
eRank = rE;
|
||||
} else {
|
||||
eRank = sRank - 1; // default: no visible day → 0 width
|
||||
const prev = new Date(eNorm);
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
prev.setDate(prev.getDate() - 1);
|
||||
if (prev.getTime() < viewStartT) break;
|
||||
const r = rankMap.get(prev.getTime());
|
||||
if (r !== undefined) { eRank = r; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eRank < sRank) return 0;
|
||||
return (eRank - sRank + 1) * cellWidth;
|
||||
}
|
||||
|
||||
return { toLeft, toWidth };
|
||||
}
|
||||
|
||||
// ─── Sub-lane computation ──────────────────────────────────────────────────────
|
||||
|
||||
export interface SubLaneAlloc {
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy lane assignment for overlapping allocations.
|
||||
* Returns a map of allocationId → lane index (0-based).
|
||||
* Allocations are sorted by startDate then assigned to the first lane
|
||||
* that doesn't overlap with the previous occupant.
|
||||
*/
|
||||
export function computeSubLanes(allocs: SubLaneAlloc[]): Map<string, number> {
|
||||
const sorted = [...allocs].sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
|
||||
);
|
||||
|
||||
// laneEnds[i] = end date of the last allocation placed in lane i
|
||||
const laneEnds: Date[] = [];
|
||||
const result = new Map<string, number>();
|
||||
|
||||
for (const alloc of sorted) {
|
||||
const start = new Date(alloc.startDate);
|
||||
const end = new Date(alloc.endDate);
|
||||
|
||||
let placed = false;
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
const laneEnd = laneEnds[i]!;
|
||||
if (start > laneEnd) {
|
||||
// Lane is free
|
||||
laneEnds[i] = end;
|
||||
result.set(alloc.id, i);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
result.set(alloc.id, laneEnds.length);
|
||||
laneEnds.push(end);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user