chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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">&times;</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">&times;</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"
>
&times;
</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 &amp; 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"]);
+220
View File
@@ -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;
}