236 lines
8.4 KiB
TypeScript
236 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { clsx } from "clsx";
|
||
import { useRef, useState } from "react";
|
||
import { trpc } from "~/lib/trpc/client.js";
|
||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
|
||
import { TimelineQuickFilters } from "./TimelineQuickFilters.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.clientIds.length +
|
||
filters.chapters.length +
|
||
filters.eids.length +
|
||
filters.projectIds.length +
|
||
filters.countryCodes.length;
|
||
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
// Track selected resource ID for the combobox (separate from the EID-based filter)
|
||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
|
||
|
||
// Look up resource to get EID when selected
|
||
const { data: resourceLookup } = trpc.resource.directory.useQuery(
|
||
{ limit: 500 },
|
||
{ staleTime: 60_000 },
|
||
);
|
||
|
||
function handleProjectChange(id: string | null) {
|
||
onFiltersChange({ ...filters, projectIds: id ? [id] : [] });
|
||
}
|
||
|
||
function handleResourceChange(id: string | null) {
|
||
setSelectedResourceId(id);
|
||
if (!id) {
|
||
onFiltersChange({ ...filters, eids: [] });
|
||
return;
|
||
}
|
||
const resources = (resourceLookup?.resources ?? []) as Array<{ id: string; eid: string }>;
|
||
const resource = resources.find((r) => r.id === id);
|
||
if (resource?.eid) {
|
||
onFiltersChange({ ...filters, eids: [resource.eid] });
|
||
}
|
||
}
|
||
|
||
function clearQuickFilters() {
|
||
onFiltersChange({
|
||
...filters,
|
||
clientIds: [],
|
||
chapters: [],
|
||
eids: [],
|
||
projectIds: [],
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="app-toolbar relative z-20 flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<ProjectCombobox
|
||
value={filters.projectIds[0] ?? null}
|
||
onChange={handleProjectChange}
|
||
placeholder="Filter by project..."
|
||
className="min-w-[220px]"
|
||
/>
|
||
<ResourceCombobox
|
||
value={selectedResourceId}
|
||
onChange={handleResourceChange}
|
||
placeholder="Filter by resource..."
|
||
className="min-w-[180px]"
|
||
/>
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||
{viewMode === "resource"
|
||
? `${resourceCount} resources \u00B7 ${totalAllocCount} allocations`
|
||
: `${projectCount} projects`}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||
<TimelineQuickFilters filters={filters} onChange={onFiltersChange} />
|
||
{activeFilterCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={clearQuickFilters}
|
||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-600 transition hover:border-gray-400 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||
>
|
||
Clear filters
|
||
</button>
|
||
)}
|
||
|
||
{/* Timeline navigation */}
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={onNavigateBack}
|
||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
title="Previous 4 weeks"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onNavigateToday}
|
||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
>
|
||
Today
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onNavigateForward}
|
||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
title="Next 4 weeks"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
|
||
{/* Undo / Redo */}
|
||
{(onUndo ?? onRedo) && (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={onUndo}
|
||
disabled={!canUndo}
|
||
title="Undo (Ctrl+Z)"
|
||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
>
|
||
↩
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onRedo}
|
||
disabled={!canRedo}
|
||
title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
|
||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||
>
|
||
↪
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* View mode toggle */}
|
||
<div className="flex overflow-hidden rounded-xl border border-gray-300 bg-white text-sm dark:border-gray-600 dark:bg-gray-900">
|
||
<button
|
||
type="button"
|
||
onClick={() => onViewModeChange("resource")}
|
||
className={clsx(
|
||
"px-3 py-2 transition-colors",
|
||
viewMode === "resource"
|
||
? "bg-brand-600 text-white"
|
||
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
|
||
)}
|
||
>
|
||
Resource view
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onViewModeChange("project")}
|
||
className={clsx(
|
||
"border-l border-gray-300 px-3 py-2 transition-colors dark:border-gray-600",
|
||
viewMode === "project"
|
||
? "bg-brand-600 text-white"
|
||
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
|
||
)}
|
||
>
|
||
Project view
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filter */}
|
||
<div ref={filterAnchorRef} className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => onFilterOpenChange(!filterOpen)}
|
||
className={clsx(
|
||
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors",
|
||
filterOpen || activeFilterCount > 0
|
||
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
|
||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800",
|
||
)}
|
||
>
|
||
Filter
|
||
{activeFilterCount > 0 && (
|
||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-brand-600 text-xs text-white">
|
||
{activeFilterCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<TimelineFilter
|
||
anchorRef={filterAnchorRef}
|
||
filters={filters}
|
||
onChange={onFiltersChange}
|
||
isOpen={filterOpen}
|
||
onClose={() => onFilterOpenChange(false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|