chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
|
||||
|
||||
export type { AllocationMovedSnapshot };
|
||||
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
export function useAllocationHistory() {
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const past = useRef<AllocationMovedSnapshot[]>([]);
|
||||
const future = useRef<AllocationMovedSnapshot[]>([]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
const push = useCallback((snapshot: AllocationMovedSnapshot) => {
|
||||
past.current = [...past.current.slice(-MAX_HISTORY + 1), snapshot];
|
||||
future.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
const last = past.current[past.current.length - 1];
|
||||
if (!last) return;
|
||||
past.current = past.current.slice(0, -1);
|
||||
future.current = [last, ...future.current];
|
||||
setCanUndo(past.current.length > 0);
|
||||
setCanRedo(true);
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: last.mutationAllocationId,
|
||||
startDate: last.before.startDate,
|
||||
endDate: last.before.endDate,
|
||||
});
|
||||
}, [updateMutation]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
const next = future.current[0];
|
||||
if (!next) return;
|
||||
future.current = future.current.slice(1);
|
||||
past.current = [...past.current, next];
|
||||
setCanUndo(true);
|
||||
setCanRedo(future.current.length > 0);
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: next.mutationAllocationId,
|
||||
startDate: next.after.startDate,
|
||||
endDate: next.after.endDate,
|
||||
});
|
||||
}, [updateMutation]);
|
||||
|
||||
return { push, undo, redo, canUndo, canRedo };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type HeatmapColorScheme = "green-red" | "blue-orange" | "purple-yellow" | "mono";
|
||||
|
||||
export interface AppPreferences {
|
||||
/** Hide allocations that belong to COMPLETED or CANCELLED projects. Default: true. */
|
||||
hideCompletedProjects: boolean;
|
||||
/**
|
||||
* Timeline row rendering style.
|
||||
* "strip" — horizontal colored blocks per allocation (classic Gantt) + load graph
|
||||
* "bar" — stacked vertical bars per day showing hours by project
|
||||
* "heatmap" — strips overlaid with a green→red utilization colour per day column
|
||||
*/
|
||||
timelineDisplayMode: "strip" | "bar" | "heatmap";
|
||||
/** Color palette used for heatmap overlays and bar-mode project view bars. */
|
||||
heatmapColorScheme: HeatmapColorScheme;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "planarchy_prefs";
|
||||
const CHANGE_EVENT = "planarchy-prefs-changed";
|
||||
|
||||
const DEFAULT: AppPreferences = {
|
||||
hideCompletedProjects: true,
|
||||
timelineDisplayMode: "strip",
|
||||
heatmapColorScheme: "green-red",
|
||||
};
|
||||
|
||||
export function readAppPreferences(): AppPreferences {
|
||||
if (typeof window === "undefined") return DEFAULT;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT;
|
||||
return { ...DEFAULT, ...(JSON.parse(raw) as Partial<AppPreferences>) };
|
||||
} catch {
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
function saveAppPreferences(prefs: AppPreferences) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
// Broadcast to all hook instances in the same tab
|
||||
window.dispatchEvent(new CustomEvent<AppPreferences>(CHANGE_EVENT, { detail: prefs }));
|
||||
}
|
||||
|
||||
export function useAppPreferences() {
|
||||
const [prefs, setPrefs] = useState<AppPreferences>(DEFAULT);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync from storage on mount
|
||||
setPrefs(readAppPreferences());
|
||||
|
||||
// Keep in sync when any hook instance saves a change
|
||||
function handleChange(e: Event) {
|
||||
setPrefs((e as CustomEvent<AppPreferences>).detail);
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, handleChange);
|
||||
return () => window.removeEventListener(CHANGE_EVENT, handleChange);
|
||||
}, []);
|
||||
|
||||
const setHideCompletedProjects = useCallback((value: boolean) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, hideCompletedProjects: value };
|
||||
saveAppPreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setTimelineDisplayMode = useCallback((value: AppPreferences["timelineDisplayMode"]) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, timelineDisplayMode: value };
|
||||
saveAppPreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setHeatmapColorScheme = useCallback((value: HeatmapColorScheme) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, heatmapColorScheme: value };
|
||||
saveAppPreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import type { ColumnDef, ColumnPreferences, ViewKey } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// Simple localStorage helper (no external lib needed)
|
||||
function readLocalVisible(view: ViewKey): string[] | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(`colvis_${view}`);
|
||||
return raw ? (JSON.parse(raw) as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalVisible(view: ViewKey, keys: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(`colvis_${view}`, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
export function useColumnConfig(
|
||||
view: ViewKey,
|
||||
builtinColumns: ColumnDef[],
|
||||
customColumns: ColumnDef[] = [],
|
||||
) {
|
||||
const allColumns = useMemo(
|
||||
() => [...builtinColumns, ...customColumns],
|
||||
[builtinColumns, customColumns],
|
||||
);
|
||||
|
||||
// Local optimistic state — immediately reflects user toggles without waiting for server
|
||||
const [localVisible, setLocalVisible] = useState<string[] | null>(
|
||||
() => readLocalVisible(view),
|
||||
);
|
||||
|
||||
const { data: serverPrefs } = trpc.user.getColumnPreferences.useQuery(undefined, {
|
||||
staleTime: 300_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
const setMutation = trpc.user.setColumnPreferences.useMutation();
|
||||
|
||||
const visibleKeys = useMemo(() => {
|
||||
const alwaysVisible = allColumns.filter((c) => !c.hideable).map((c) => c.key);
|
||||
// Local state (optimistic) → server prefs → defaults
|
||||
const saved =
|
||||
localVisible ??
|
||||
(serverPrefs as ColumnPreferences | undefined)?.[view]?.visible ??
|
||||
null;
|
||||
if (saved) {
|
||||
const valid = saved.filter((k) => allColumns.some((c) => c.key === k));
|
||||
return [...new Set([...alwaysVisible, ...valid])];
|
||||
}
|
||||
return allColumns.filter((c) => c.defaultVisible).map((c) => c.key);
|
||||
}, [localVisible, serverPrefs, allColumns, view]);
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() =>
|
||||
allColumns
|
||||
.filter((c) => visibleKeys.includes(c.key))
|
||||
.sort((a, b) => visibleKeys.indexOf(a.key) - visibleKeys.indexOf(b.key)),
|
||||
[allColumns, visibleKeys],
|
||||
);
|
||||
|
||||
const setVisible = useCallback(
|
||||
(keys: string[]) => {
|
||||
setLocalVisible(keys); // immediate re-render
|
||||
writeLocalVisible(view, keys); // persist for next page load
|
||||
setMutation.mutate({ view, visible: keys }); // sync to server
|
||||
},
|
||||
[view, setMutation],
|
||||
);
|
||||
|
||||
return { allColumns, visibleColumns, visibleKeys, setVisible };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
type DashboardLayoutConfig,
|
||||
type DashboardWidgetType,
|
||||
} from "@planarchy/shared/types";
|
||||
import {
|
||||
createDashboardWidget,
|
||||
createDefaultDashboardLayout,
|
||||
getNextDashboardWidgetY,
|
||||
normalizeDashboardLayout,
|
||||
} from "@planarchy/shared/schemas";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const STORAGE_KEY = "planarchy_dashboard_v1";
|
||||
|
||||
function generateWidgetId() {
|
||||
return `widget-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
function loadFromStorage(): DashboardLayoutConfig | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return normalizeDashboardLayout(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(config: DashboardLayoutConfig) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function useDashboardLayout() {
|
||||
const [config, setConfig] = useState<DashboardLayoutConfig>(() => loadFromStorage() ?? createDefaultDashboardLayout());
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
}) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined };
|
||||
|
||||
const saveMutation = trpc.user.saveDashboardLayout.useMutation();
|
||||
|
||||
// Sync from DB on load (DB wins if it has data)
|
||||
useEffect(() => {
|
||||
if (dbData?.layout) {
|
||||
const dbConfig = normalizeDashboardLayout(dbData.layout);
|
||||
setConfig(dbConfig);
|
||||
saveToStorage(dbConfig);
|
||||
}
|
||||
}, [dbData]);
|
||||
|
||||
const persist = useCallback((nextConfig: DashboardLayoutConfig) => {
|
||||
const newConfig = normalizeDashboardLayout(nextConfig);
|
||||
saveToStorage(newConfig);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveMutation.mutate({ layout: newConfig });
|
||||
}, 2000);
|
||||
}, [saveMutation]);
|
||||
|
||||
const addWidget = useCallback((type: DashboardWidgetType) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
widgets: [
|
||||
...prev.widgets,
|
||||
createDashboardWidget(type, {
|
||||
id: generateWidgetId(),
|
||||
x: 0,
|
||||
y: getNextDashboardWidgetY(prev.widgets),
|
||||
}),
|
||||
],
|
||||
};
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
}, [persist]);
|
||||
|
||||
const removeWidget = useCallback((id: string) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) };
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
}, [persist]);
|
||||
|
||||
const updateWidgetConfig = useCallback((id: string, configUpdate: Record<string, unknown>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w,
|
||||
),
|
||||
};
|
||||
persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
}, [persist]);
|
||||
|
||||
const onLayoutChange = useCallback(
|
||||
(layout: { i: string; x: number; y: number; w: number; h: number }[]) => {
|
||||
setConfig((prev) => {
|
||||
const updatedWidgets = prev.widgets.map((w) => {
|
||||
const item = layout.find((l) => l.i === w.id);
|
||||
if (!item) return w;
|
||||
return { ...w, x: item.x, y: item.y, w: item.w, h: item.h };
|
||||
});
|
||||
|
||||
// Only persist when the user actually moved/resized something.
|
||||
// react-grid-layout fires onLayoutChange on mount too — we skip that
|
||||
// to avoid overwriting saved positions with compacted coordinates.
|
||||
const changed = updatedWidgets.some((w) => {
|
||||
const orig = prev.widgets.find((o) => o.id === w.id);
|
||||
return orig && (w.x !== orig.x || w.y !== orig.y || w.w !== orig.w || w.h !== orig.h);
|
||||
});
|
||||
|
||||
const newConfig = { ...prev, widgets: updatedWidgets };
|
||||
if (changed) persist(newConfig);
|
||||
return newConfig;
|
||||
});
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const resetLayout = useCallback(() => {
|
||||
const defaultConfig = createDefaultDashboardLayout();
|
||||
setConfig(defaultConfig);
|
||||
persist(defaultConfig);
|
||||
}, [persist]);
|
||||
|
||||
return {
|
||||
config,
|
||||
addWidget,
|
||||
removeWidget,
|
||||
updateWidgetConfig,
|
||||
onLayoutChange,
|
||||
resetLayout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import type { FieldType } from "@planarchy/shared";
|
||||
|
||||
// Typed-routes are enabled in next.config.ts; bypass for dynamic URLs.
|
||||
type PlainRouter = { replace: (url: string, opts?: { scroll?: boolean }) => void };
|
||||
|
||||
export interface CustomFieldFilter {
|
||||
key: string;
|
||||
value: string;
|
||||
type: FieldType;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-persisted filter state for list views.
|
||||
* Built-in filters (search, chapter) use simple query params.
|
||||
* Custom field filters encode as: cf_<fieldKey>=<value> + cft_<fieldKey>=<FieldType>
|
||||
*/
|
||||
export function useFilters() {
|
||||
const router = useRouter() as unknown as PlainRouter;
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const search = searchParams.get("search") ?? "";
|
||||
const chapter = searchParams.get("chapter") ?? "";
|
||||
const status = searchParams.get("status") ?? "";
|
||||
|
||||
const customFieldFilters: CustomFieldFilter[] = useMemo(() => {
|
||||
const filters: CustomFieldFilter[] = [];
|
||||
for (const [param, value] of searchParams.entries()) {
|
||||
if (param.startsWith("cf_") && !param.startsWith("cft_")) {
|
||||
const fieldKey = param.slice(3);
|
||||
const type = (searchParams.get(`cft_${fieldKey}`) ?? "TEXT") as FieldType;
|
||||
if (value) filters.push({ key: fieldKey, value, type });
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}, [searchParams]);
|
||||
|
||||
const setFilter = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams, pathname],
|
||||
);
|
||||
|
||||
const setCustomFieldFilter = useCallback(
|
||||
(fieldKey: string, value: string, type: FieldType) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(`cf_${fieldKey}`, value);
|
||||
params.set(`cft_${fieldKey}`, type);
|
||||
} else {
|
||||
params.delete(`cf_${fieldKey}`);
|
||||
params.delete(`cft_${fieldKey}`);
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams, pathname],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
router.replace(pathname, { scroll: false });
|
||||
}, [router, pathname]);
|
||||
|
||||
const hasActiveFilters =
|
||||
search !== "" || chapter !== "" || status !== "" || customFieldFilters.length > 0;
|
||||
|
||||
return {
|
||||
search,
|
||||
chapter,
|
||||
status,
|
||||
customFieldFilters,
|
||||
setFilter,
|
||||
setCustomFieldFilter,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function useFocusTrap(ref: React.RefObject<HTMLElement | null>, isOpen: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isOpen || !ref.current) return;
|
||||
const el = ref.current;
|
||||
|
||||
const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
// Focus first element when modal opens
|
||||
first?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
if (focusable.length === 0) { e.preventDefault(); return; }
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first?.focus(); }
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("keydown", handleKeyDown);
|
||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, ref]);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const COST_ROLES = ["ADMIN", "MANAGER", "CONTROLLER"] as const;
|
||||
const EDIT_ROLES = ["ADMIN", "MANAGER"] as const;
|
||||
const SCORE_ROLES = ["ADMIN", "MANAGER"] as const;
|
||||
|
||||
export function usePermissions() {
|
||||
const { data: session } = useSession();
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? "USER";
|
||||
|
||||
return {
|
||||
canViewCosts: COST_ROLES.includes(role as typeof COST_ROLES[number]),
|
||||
canEdit: EDIT_ROLES.includes(role as typeof EDIT_ROLES[number]),
|
||||
canManageUsers: role === "ADMIN",
|
||||
canManageBlueprints: role === "ADMIN",
|
||||
canViewScores: SCORE_ROLES.includes(role as typeof SCORE_ROLES[number]),
|
||||
role,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { AllocationLike, Assignment, DemandRequirement } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
/**
|
||||
* Fetches full project context when a project is being dragged or the panel opens.
|
||||
* Returns the project's resources, their own allocations, and all cross-project allocations.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ProjectDragContextResult = {
|
||||
contextResourceIds: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
contextAllocations: any[];
|
||||
projectAssignments: Assignment<AllocationLike>[];
|
||||
projectDemands: DemandRequirement<AllocationLike>[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
project: any | null;
|
||||
};
|
||||
|
||||
export function useProjectDragContext(projectId: string | null): ProjectDragContextResult {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data } = trpc.timeline.getProjectContext.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId, staleTime: 10_000 },
|
||||
) as { data: any };
|
||||
|
||||
return {
|
||||
contextResourceIds: (data?.resourceIds ?? []) as string[],
|
||||
contextAllocations: data?.allResourceAllocations ?? [],
|
||||
projectAssignments: (data?.assignments ?? []) as Assignment<AllocationLike>[],
|
||||
projectDemands: (data?.demands ?? []) as DemandRequirement<AllocationLike>[],
|
||||
project: data?.project ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import type { ViewPrefsHandle } from "./useViewPrefs.js";
|
||||
|
||||
/**
|
||||
* Manages manual drag-to-reorder row ordering for a table.
|
||||
*
|
||||
* - When a column sort is active (sortField !== null), row order is suppressed —
|
||||
* the sorted rows are returned unchanged and dragging is disabled.
|
||||
* - When no column sort is active and a saved rowOrder exists, rows are sorted
|
||||
* by their position in rowOrder; rows without a saved position go to the end.
|
||||
* - Calling reorder() saves the new order and clears any active column sort.
|
||||
*
|
||||
* Each user's rowOrder is persisted independently via useViewPrefs.
|
||||
*/
|
||||
export function useRowOrder<T extends { id: string }>(
|
||||
rows: T[],
|
||||
prefs: Pick<ViewPrefsHandle, "rowOrder" | "setRowOrder">,
|
||||
activeSortField: string | null,
|
||||
resetSort: () => void,
|
||||
) {
|
||||
const { rowOrder, setRowOrder } = prefs;
|
||||
|
||||
const orderedRows = useMemo((): T[] => {
|
||||
// Column sort takes precedence — ignore manual order while sorting
|
||||
if (activeSortField !== null) return rows;
|
||||
if (rowOrder.length === 0) return rows;
|
||||
|
||||
const indexMap = new Map(rowOrder.map((id, i) => [id, i]));
|
||||
return [...rows].sort((a, b) => {
|
||||
const ai = indexMap.get(a.id) ?? Infinity;
|
||||
const bi = indexMap.get(b.id) ?? Infinity;
|
||||
return ai - bi;
|
||||
});
|
||||
}, [rows, rowOrder, activeSortField]);
|
||||
|
||||
const reorder = useCallback(
|
||||
(draggedId: string, targetId: string) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
// Build current ordered ID list from the current orderedRows snapshot
|
||||
// (works correctly even when rowOrder is empty / partial)
|
||||
const currentIds = orderedRows.map((r) => r.id);
|
||||
const fromIdx = currentIds.indexOf(draggedId);
|
||||
const toIdx = currentIds.indexOf(targetId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
const next = [...currentIds];
|
||||
next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, draggedId);
|
||||
|
||||
// Clear any active column sort (mutual exclusion)
|
||||
resetSort();
|
||||
setRowOrder(next);
|
||||
},
|
||||
[orderedRows, resetSort, setRowOrder],
|
||||
);
|
||||
|
||||
const resetOrder = useCallback(() => {
|
||||
setRowOrder([]);
|
||||
}, [setRowOrder]);
|
||||
|
||||
const isCustomOrder = rowOrder.length > 0 && activeSortField === null;
|
||||
|
||||
return { orderedRows, reorder, isCustomOrder, resetOrder };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export interface UseSelectionReturn {
|
||||
selectedIds: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
toggleAll: (ids: string[]) => void;
|
||||
clear: () => void;
|
||||
isAllSelected: (ids: string[]) => boolean;
|
||||
isIndeterminate: (ids: string[]) => boolean;
|
||||
count: number;
|
||||
selectedArray: string[];
|
||||
}
|
||||
|
||||
export function useSelection(): UseSelectionReturn {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback((ids: string[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
const allSelected = ids.every((id) => prev.has(id));
|
||||
if (allSelected) {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
} else {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.add(id));
|
||||
return next;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => setSelectedIds(new Set()), []);
|
||||
|
||||
const isAllSelected = useCallback(
|
||||
(ids: string[]) => ids.length > 0 && ids.every((id) => selectedIds.has(id)),
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const isIndeterminate = useCallback(
|
||||
(ids: string[]) => ids.some((id) => selectedIds.has(id)) && !ids.every((id) => selectedIds.has(id)),
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
toggle,
|
||||
toggleAll,
|
||||
clear,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
count: selectedIds.size,
|
||||
selectedArray: [...selectedIds],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
|
||||
export type SortDir = "asc" | "desc" | null;
|
||||
|
||||
export interface TableSortState<F extends string> {
|
||||
sorted: never[]; // overridden per call
|
||||
sortField: F | null;
|
||||
sortDir: SortDir;
|
||||
toggle: (field: F, getValue?: (row: never) => string | number | null | undefined) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export interface UseTableSortOptions {
|
||||
/** Initial sort field loaded from persisted preferences. */
|
||||
initialField?: string | null;
|
||||
/** Initial sort direction loaded from persisted preferences. */
|
||||
initialDir?: "asc" | "desc" | null;
|
||||
/**
|
||||
* Called whenever the sort state changes (after the first render).
|
||||
* Use this to persist the new sort state.
|
||||
*/
|
||||
onSortChange?: (field: string | null, dir: SortDir) => void;
|
||||
}
|
||||
|
||||
function compareValues(a: unknown, b: unknown, dir: "asc" | "desc"): number {
|
||||
// Nulls last regardless of direction
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
|
||||
let result: number;
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
result = a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
result = a - b;
|
||||
} else {
|
||||
result = String(a).localeCompare(String(b));
|
||||
}
|
||||
return dir === "asc" ? result : -result;
|
||||
}
|
||||
|
||||
export function useTableSort<T extends object, F extends string = string>(
|
||||
rows: T[],
|
||||
options?: UseTableSortOptions,
|
||||
) {
|
||||
const [sortField, setSortField] = useState<F | null>(
|
||||
(options?.initialField ?? null) as F | null,
|
||||
);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(options?.initialDir ?? null);
|
||||
// Store custom getValue functions keyed by field
|
||||
const [getters, setGetters] = useState<Partial<Record<F, (row: T) => unknown>>>({});
|
||||
|
||||
// Keep onSortChange in a ref so the effect doesn't need it as a dependency
|
||||
const onSortChangeRef = useRef(options?.onSortChange);
|
||||
onSortChangeRef.current = options?.onSortChange;
|
||||
|
||||
// Skip the initial render — only fire onSortChange for user-driven changes
|
||||
const isFirstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
onSortChangeRef.current?.(sortField, sortDir);
|
||||
}, [sortField, sortDir]);
|
||||
|
||||
function toggle(field: F, getValue?: (row: T) => unknown) {
|
||||
if (getValue) {
|
||||
setGetters((prev) => ({ ...prev, [field]: getValue }));
|
||||
}
|
||||
setSortField((prev) => {
|
||||
if (prev !== field) {
|
||||
setSortDir("asc");
|
||||
return field;
|
||||
}
|
||||
// Cycle: asc → desc → null
|
||||
setSortDir((d) => {
|
||||
if (d === "asc") return "desc";
|
||||
if (d === "desc") return null;
|
||||
return "asc";
|
||||
});
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setSortField(null);
|
||||
setSortDir(null);
|
||||
}
|
||||
|
||||
const sorted = useMemo((): T[] => {
|
||||
if (!sortField || !sortDir) return rows;
|
||||
const getter = getters[sortField] ?? ((row: T) => row[sortField as unknown as keyof T]);
|
||||
return [...rows].sort((a, b) => compareValues(getter(a), getter(b), sortDir));
|
||||
}, [rows, sortField, sortDir, getters]);
|
||||
|
||||
return { sorted, sortField, sortDir, toggle, reset };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type AccentColor = "sky" | "indigo" | "violet" | "emerald" | "rose" | "amber";
|
||||
|
||||
export interface ThemePreferences {
|
||||
mode: ThemeMode;
|
||||
accent: AccentColor;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "planarchy_theme";
|
||||
const DEFAULT: ThemePreferences = { mode: "light", accent: "sky" };
|
||||
|
||||
function readStorage(): ThemePreferences {
|
||||
if (typeof window === "undefined") return DEFAULT;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT;
|
||||
return { ...DEFAULT, ...(JSON.parse(raw) as Partial<ThemePreferences>) };
|
||||
} catch {
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(prefs: ThemePreferences) {
|
||||
const html = document.documentElement;
|
||||
if (prefs.mode === "dark") html.classList.add("dark");
|
||||
else html.classList.remove("dark");
|
||||
html.setAttribute("data-accent", prefs.accent);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [prefs, setPrefs] = useState<ThemePreferences>(DEFAULT);
|
||||
|
||||
// Read from storage on mount
|
||||
useEffect(() => {
|
||||
const stored = readStorage();
|
||||
setPrefs(stored);
|
||||
applyTheme(stored);
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((mode: ThemeMode) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, mode };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAccent = useCallback((accent: AccentColor) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, accent };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, setMode, setAccent };
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Project-shift drag state ───────────────────────────────────────────────
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
allocationId: string | null;
|
||||
originalStartDate: Date | null;
|
||||
originalEndDate: Date | null;
|
||||
currentStartDate: Date | null;
|
||||
currentEndDate: Date | null;
|
||||
startMouseX: number;
|
||||
originalLeft: number;
|
||||
blockWidth: number;
|
||||
daysDelta: number;
|
||||
}
|
||||
|
||||
export interface BlockClickInfo {
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface ShiftPreviewData {
|
||||
valid: boolean;
|
||||
deltaCents: number;
|
||||
wouldExceedBudget: boolean;
|
||||
budgetUtilizationAfter: number;
|
||||
conflictCount: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const INITIAL_DRAG_STATE: DragState = {
|
||||
isDragging: false,
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
allocationId: null,
|
||||
originalStartDate: null,
|
||||
originalEndDate: null,
|
||||
currentStartDate: null,
|
||||
currentEndDate: null,
|
||||
startMouseX: 0,
|
||||
originalLeft: 0,
|
||||
blockWidth: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
|
||||
// ─── Per-allocation drag state ──────────────────────────────────────────────
|
||||
|
||||
export type AllocDragMode = "move" | "resize-start" | "resize-end";
|
||||
|
||||
export interface AllocDragState {
|
||||
isActive: boolean;
|
||||
mode: AllocDragMode;
|
||||
allocationId: string | null;
|
||||
mutationAllocationId: string | null;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
resourceId: string | null;
|
||||
originalStartDate: Date | null;
|
||||
originalEndDate: Date | null;
|
||||
currentStartDate: Date | null;
|
||||
currentEndDate: Date | null;
|
||||
startMouseX: number;
|
||||
daysDelta: number;
|
||||
}
|
||||
|
||||
const INITIAL_ALLOC_DRAG: AllocDragState = {
|
||||
isActive: false,
|
||||
mode: "move",
|
||||
allocationId: null,
|
||||
mutationAllocationId: null,
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
resourceId: null,
|
||||
originalStartDate: null,
|
||||
originalEndDate: null,
|
||||
currentStartDate: null,
|
||||
currentEndDate: null,
|
||||
startMouseX: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
|
||||
// ─── Range-select state ─────────────────────────────────────────────────────
|
||||
|
||||
export interface RangeState {
|
||||
isSelecting: boolean;
|
||||
resourceId: string | null;
|
||||
startDate: Date | null;
|
||||
currentDate: Date | null;
|
||||
suggestedProjectId: string | null;
|
||||
startClientX: number;
|
||||
}
|
||||
|
||||
export interface RangeSelectedInfo {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
suggestedProjectId: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
const INITIAL_RANGE_STATE: RangeState = {
|
||||
isSelecting: false,
|
||||
resourceId: null,
|
||||
startDate: null,
|
||||
currentDate: null,
|
||||
suggestedProjectId: null,
|
||||
startClientX: 0,
|
||||
};
|
||||
|
||||
// ─── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AllocationMovedSnapshot {
|
||||
allocationId: string;
|
||||
mutationAllocationId: string;
|
||||
projectName: string;
|
||||
before: { startDate: Date; endDate: Date };
|
||||
after: { startDate: Date; endDate: Date };
|
||||
}
|
||||
|
||||
export function useTimelineDrag({
|
||||
cellWidth,
|
||||
onShiftApplied,
|
||||
onBlockClick,
|
||||
onRangeSelected,
|
||||
onAllocationMoved,
|
||||
}: {
|
||||
cellWidth: number;
|
||||
onShiftApplied?: (projectId: string) => void;
|
||||
onBlockClick?: (info: BlockClickInfo) => void;
|
||||
onRangeSelected?: (info: RangeSelectedInfo) => void;
|
||||
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
|
||||
}) {
|
||||
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
|
||||
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
|
||||
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
|
||||
|
||||
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
|
||||
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
|
||||
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
|
||||
|
||||
// Keep always-current refs for values used inside document event handlers
|
||||
const cellWidthRef = useRef(cellWidth);
|
||||
cellWidthRef.current = cellWidth;
|
||||
|
||||
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
|
||||
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({ x: 0, y: 0, decided: false });
|
||||
|
||||
const onBlockClickRef = useRef(onBlockClick);
|
||||
onBlockClickRef.current = onBlockClick;
|
||||
|
||||
const onAllocationMovedRef = useRef(onAllocationMoved);
|
||||
onAllocationMovedRef.current = onAllocationMoved;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Project-shift preview
|
||||
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
||||
{
|
||||
projectId: dragState.projectId ?? "",
|
||||
newStartDate: dragState.currentStartDate ?? new Date(),
|
||||
newEndDate: dragState.currentEndDate ?? new Date(),
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
dragState.isDragging &&
|
||||
dragState.projectId !== null &&
|
||||
dragState.daysDelta !== 0 &&
|
||||
dragState.currentStartDate !== null,
|
||||
staleTime: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
|
||||
onSuccess: (data: { project: { id: string } }) => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
void utils.project.list.invalidate();
|
||||
onShiftApplied?.(data.project.id);
|
||||
},
|
||||
}) as { isPending: boolean; mutate: (...args: unknown[]) => void; mutateAsync: (...args: unknown[]) => Promise<unknown> };
|
||||
|
||||
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
|
||||
|
||||
const updateAllocMutation = 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();
|
||||
const snap = pendingSnapshotRef.current;
|
||||
if (snap) {
|
||||
onAllocationMovedRef.current?.(snap);
|
||||
pendingSnapshotRef.current = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
|
||||
|
||||
const onProjectBarMouseDown = useCallback(
|
||||
(e: React.MouseEvent, opts: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const state: DragState = {
|
||||
isDragging: true,
|
||||
projectId: opts.projectId,
|
||||
projectName: opts.projectName,
|
||||
allocationId: null,
|
||||
originalStartDate: opts.startDate,
|
||||
originalEndDate: opts.endDate,
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
originalLeft: 0,
|
||||
blockWidth: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
dragStateRef.current = state;
|
||||
setDragState(state);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
||||
const onBlockMouseDown = useCallback(
|
||||
(e: React.MouseEvent, opts: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
allocationId?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
blockLeft: number;
|
||||
blockWidth: number;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const state: DragState = {
|
||||
isDragging: true,
|
||||
projectId: opts.projectId,
|
||||
projectName: opts.projectName,
|
||||
allocationId: opts.allocationId ?? null,
|
||||
originalStartDate: opts.startDate,
|
||||
originalEndDate: opts.endDate,
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
originalLeft: opts.blockLeft,
|
||||
blockWidth: opts.blockWidth,
|
||||
daysDelta: 0,
|
||||
};
|
||||
dragStateRef.current = state;
|
||||
setDragState(state);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Per-allocation drag — document-level listeners ─────────────────────────
|
||||
//
|
||||
// Uses document.addEventListener instead of React canvas events so the drag
|
||||
// works reliably even when the cursor leaves the canvas boundary (e.g. while
|
||||
// moving quickly or scrolling into the sticky header area).
|
||||
|
||||
const onAllocMouseDown = useCallback(
|
||||
(e: React.MouseEvent, opts: {
|
||||
mode: AllocDragMode;
|
||||
allocationId: string;
|
||||
mutationAllocationId?: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const initial: AllocDragState = {
|
||||
isActive: true,
|
||||
mode: opts.mode,
|
||||
allocationId: opts.allocationId,
|
||||
mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
|
||||
projectId: opts.projectId,
|
||||
projectName: opts.projectName,
|
||||
resourceId: opts.resourceId,
|
||||
originalStartDate: opts.startDate,
|
||||
originalEndDate: opts.endDate,
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
daysDelta: 0,
|
||||
};
|
||||
allocDragRef.current = initial;
|
||||
setAllocDragState(initial);
|
||||
|
||||
// ── document handlers ────────────────────────────────────────────────
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
|
||||
|
||||
const deltaX = ev.clientX - alloc.startMouseX;
|
||||
const daysDelta = Math.round(deltaX / cellWidthRef.current);
|
||||
if (daysDelta === alloc.daysDelta) return;
|
||||
|
||||
const newStart = new Date(alloc.originalStartDate);
|
||||
const newEnd = new Date(alloc.originalEndDate);
|
||||
|
||||
if (alloc.mode === "move") {
|
||||
newStart.setDate(newStart.getDate() + daysDelta);
|
||||
newEnd.setDate(newEnd.getDate() + daysDelta);
|
||||
} else if (alloc.mode === "resize-start") {
|
||||
newStart.setDate(newStart.getDate() + daysDelta);
|
||||
if (newStart >= newEnd) newStart.setTime(newEnd.getTime() - 86400000);
|
||||
} else {
|
||||
// resize-end
|
||||
newEnd.setDate(newEnd.getDate() + daysDelta);
|
||||
if (newEnd <= newStart) newEnd.setTime(newStart.getTime() + 86400000);
|
||||
}
|
||||
|
||||
const updated: AllocDragState = {
|
||||
...alloc,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
daysDelta,
|
||||
};
|
||||
allocDragRef.current = updated;
|
||||
setAllocDragState(updated);
|
||||
}
|
||||
|
||||
function handleUp() {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive) return;
|
||||
|
||||
if (alloc.daysDelta === 0 && alloc.allocationId) {
|
||||
// No movement → treat as click, open alloc popover
|
||||
onBlockClickRef.current?.({
|
||||
allocationId: alloc.allocationId,
|
||||
projectId: alloc.projectId ?? "",
|
||||
projectName: alloc.projectName ?? "",
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
pendingSnapshotRef.current = {
|
||||
allocationId: alloc.allocationId,
|
||||
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
||||
projectName: alloc.projectName ?? "",
|
||||
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
|
||||
after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
|
||||
};
|
||||
updateAllocMutation.mutate({
|
||||
allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
||||
startDate: alloc.currentStartDate,
|
||||
endDate: alloc.currentEndDate,
|
||||
});
|
||||
}
|
||||
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
setAllocDragState(INITIAL_ALLOC_DRAG);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
|
||||
);
|
||||
|
||||
// ── Range-select ────────────────────────────────────────────────────────────
|
||||
|
||||
const onRowMouseDown = useCallback(
|
||||
(e: React.MouseEvent, opts: {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
suggestedProjectId?: string;
|
||||
}) => {
|
||||
if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
|
||||
e.preventDefault();
|
||||
const state: RangeState = {
|
||||
isSelecting: true,
|
||||
resourceId: opts.resourceId,
|
||||
startDate: opts.startDate,
|
||||
currentDate: opts.startDate,
|
||||
suggestedProjectId: opts.suggestedProjectId ?? null,
|
||||
startClientX: e.clientX,
|
||||
};
|
||||
rangeStateRef.current = state;
|
||||
setRangeState(state);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Canvas-level handlers (project shift + range select only) ──────────────
|
||||
|
||||
const onCanvasMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Project shift
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
|
||||
const deltaX = e.clientX - drag.startMouseX;
|
||||
const daysDelta = Math.round(deltaX / cellWidth);
|
||||
if (daysDelta !== drag.daysDelta) {
|
||||
const newStart = new Date(drag.originalStartDate);
|
||||
newStart.setDate(newStart.getDate() + daysDelta);
|
||||
const newEnd = new Date(drag.originalEndDate);
|
||||
newEnd.setDate(newEnd.getDate() + daysDelta);
|
||||
const updated: DragState = { ...drag, currentStartDate: newStart, currentEndDate: newEnd, daysDelta };
|
||||
dragStateRef.current = updated;
|
||||
setDragState(updated);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Range select
|
||||
const range = rangeStateRef.current;
|
||||
if (range.isSelecting && range.startDate) {
|
||||
const deltaX = e.clientX - range.startClientX;
|
||||
const daysDelta = Math.round(deltaX / cellWidth);
|
||||
const currentDate = new Date(range.startDate);
|
||||
currentDate.setDate(currentDate.getDate() + daysDelta);
|
||||
|
||||
const prevDelta = range.currentDate
|
||||
? Math.round((range.currentDate.getTime() - range.startDate.getTime()) / 86400000)
|
||||
: 0;
|
||||
if (daysDelta === prevDelta) return;
|
||||
|
||||
const updated: RangeState = { ...range, currentDate };
|
||||
rangeStateRef.current = updated;
|
||||
setRangeState(updated);
|
||||
}
|
||||
},
|
||||
[cellWidth],
|
||||
);
|
||||
|
||||
const onCanvasMouseUp = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
// Project shift
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging) {
|
||||
if (drag.daysDelta === 0) {
|
||||
if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
|
||||
onBlockClick?.({
|
||||
allocationId: drag.allocationId ?? "",
|
||||
projectId: drag.projectId,
|
||||
projectName: drag.projectName ?? "",
|
||||
startDate: drag.originalStartDate,
|
||||
endDate: drag.originalEndDate,
|
||||
});
|
||||
}
|
||||
} else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
|
||||
try {
|
||||
await applyShiftMutation.mutateAsync({
|
||||
projectId: drag.projectId,
|
||||
newStartDate: drag.currentStartDate,
|
||||
newEndDate: drag.currentEndDate,
|
||||
});
|
||||
} catch {
|
||||
// Validation error — revert visually
|
||||
}
|
||||
}
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Range select
|
||||
const range = rangeStateRef.current;
|
||||
if (range.isSelecting && range.resourceId && range.startDate) {
|
||||
const endDate = range.currentDate ?? range.startDate;
|
||||
const [startDate, finalEnd] =
|
||||
range.startDate <= endDate
|
||||
? [range.startDate, endDate]
|
||||
: [endDate, range.startDate];
|
||||
|
||||
onRangeSelected?.({
|
||||
resourceId: range.resourceId,
|
||||
startDate,
|
||||
endDate: finalEnd,
|
||||
suggestedProjectId: range.suggestedProjectId,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
});
|
||||
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
}
|
||||
},
|
||||
[applyShiftMutation, onBlockClick, onRangeSelected],
|
||||
);
|
||||
|
||||
const onCanvasMouseLeave = useCallback(() => {
|
||||
// Only cancel project-shift and range-select on canvas leave.
|
||||
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
||||
if (dragStateRef.current.isDragging) {
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
}
|
||||
if (rangeStateRef.current.isSelecting) {
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Touch support ───────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
|
||||
function toClientX(e: React.TouchEvent): number {
|
||||
return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0;
|
||||
}
|
||||
|
||||
const onProjectBarTouchStart = useCallback(
|
||||
(e: React.TouchEvent, opts: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
|
||||
onProjectBarMouseDown(
|
||||
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
|
||||
opts,
|
||||
);
|
||||
},
|
||||
[onProjectBarMouseDown],
|
||||
);
|
||||
|
||||
const onAllocTouchStart = useCallback(
|
||||
(e: React.TouchEvent, opts: {
|
||||
mode: AllocDragMode;
|
||||
allocationId: string;
|
||||
mutationAllocationId?: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
|
||||
onAllocMouseDown(
|
||||
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
|
||||
opts,
|
||||
);
|
||||
},
|
||||
[onAllocMouseDown],
|
||||
);
|
||||
|
||||
const onRowTouchStart = useCallback(
|
||||
(e: React.TouchEvent, opts: {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
suggestedProjectId?: string;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false };
|
||||
onRowMouseDown(
|
||||
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
|
||||
opts,
|
||||
);
|
||||
},
|
||||
[onRowMouseDown],
|
||||
);
|
||||
|
||||
const onCanvasTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Scroll vs drag disambiguation: once decided, stick with the decision
|
||||
if (!touchStartRef.current.decided) {
|
||||
const dx = Math.abs(touch.clientX - touchStartRef.current.x);
|
||||
const dy = Math.abs(touch.clientY - touchStartRef.current.y);
|
||||
if (dx > 8 || dy > 8) {
|
||||
touchStartRef.current.decided = true;
|
||||
if (dy > dx) return; // vertical scroll wins — don't intercept
|
||||
} else {
|
||||
return; // haven't moved enough to decide yet
|
||||
}
|
||||
}
|
||||
|
||||
onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent);
|
||||
},
|
||||
[onCanvasMouseMove],
|
||||
);
|
||||
|
||||
const onCanvasTouchEnd = useCallback(
|
||||
async (e: React.TouchEvent) => {
|
||||
const clientX = e.changedTouches[0]?.clientX ?? 0;
|
||||
const clientY = e.changedTouches[0]?.clientY ?? 0;
|
||||
await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent);
|
||||
},
|
||||
[onCanvasMouseUp],
|
||||
);
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const shiftPreview: ShiftPreviewData | null =
|
||||
dragState.isDragging && dragState.daysDelta !== 0 && previewData
|
||||
? {
|
||||
valid: previewData.valid,
|
||||
deltaCents: previewData.costImpact.deltaCents,
|
||||
wouldExceedBudget: previewData.costImpact.wouldExceedBudget,
|
||||
budgetUtilizationAfter: previewData.costImpact.budgetUtilizationAfter,
|
||||
conflictCount: previewData.conflictDetails.length,
|
||||
errors: previewData.errors.map((e) => e.message),
|
||||
warnings: previewData.warnings.map((w) => w.message),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying: applyShiftMutation.isPending,
|
||||
isAllocSaving: updateAllocMutation.isPending,
|
||||
onProjectBarMouseDown,
|
||||
onBlockMouseDown,
|
||||
onAllocMouseDown,
|
||||
onRowMouseDown,
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
onCanvasMouseLeave,
|
||||
// Touch equivalents
|
||||
onProjectBarTouchStart,
|
||||
onAllocTouchStart,
|
||||
onRowTouchStart,
|
||||
onCanvasTouchMove,
|
||||
onCanvasTouchEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useMemo } from "react";
|
||||
import { addDays, createDatePositionCache } from "~/components/timeline/utils.js";
|
||||
import { formatMonthYear } from "~/lib/format.js";
|
||||
|
||||
export function useTimelineLayout(
|
||||
viewStart: Date,
|
||||
viewDays: number,
|
||||
zoom: "day" | "week" | "month",
|
||||
showWeekends: boolean,
|
||||
today: Date,
|
||||
) {
|
||||
const CELL_WIDTH = zoom === "day" ? 40 : zoom === "week" ? 14 : 4;
|
||||
|
||||
// Visible dates, filtered by showWeekends
|
||||
const dates = useMemo(() => {
|
||||
const result: Date[] = [];
|
||||
for (let i = 0; i < viewDays; i++) {
|
||||
const d = addDays(viewStart, i);
|
||||
if (!showWeekends && (d.getDay() === 0 || d.getDay() === 6)) continue;
|
||||
result.push(d);
|
||||
}
|
||||
return result;
|
||||
}, [viewStart, viewDays, showWeekends]);
|
||||
|
||||
const visibleDays = dates.length;
|
||||
const totalCanvasWidth = visibleDays * CELL_WIDTH;
|
||||
|
||||
// O(1) position helpers via pre-computed cache
|
||||
const { toLeft, toWidth } = useMemo(
|
||||
() => createDatePositionCache(viewStart, viewDays, CELL_WIDTH, showWeekends),
|
||||
[viewStart, viewDays, CELL_WIDTH, showWeekends],
|
||||
);
|
||||
|
||||
// Grid lines — memoized; identical for every row
|
||||
const gridLines = useMemo(() => dates.map((date, i) => {
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isSaturday = date.getDay() === 6;
|
||||
const isSunday = date.getDay() === 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"absolute top-0 bottom-0 border-r",
|
||||
isToday ? "border-brand-300 border-r-2" :
|
||||
isSaturday ? "border-amber-200 bg-amber-50/40" :
|
||||
isSunday ? "border-gray-200 bg-gray-100/60" :
|
||||
"border-gray-100",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}), [dates, CELL_WIDTH, today]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Month groups for the month header
|
||||
const monthGroups = useMemo(() => {
|
||||
const groups: { label: string; colCount: number }[] = [];
|
||||
for (const d of dates) {
|
||||
const label = formatMonthYear(d);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.label === label) last.colCount++;
|
||||
else groups.push({ label, colCount: 1 });
|
||||
}
|
||||
return groups;
|
||||
}, [dates]);
|
||||
|
||||
// Convert clientX to a Date on the visible timeline
|
||||
function xToDate(clientX: number, rowCanvasRect: DOMRect): Date {
|
||||
const x = clientX - rowCanvasRect.left;
|
||||
const colIndex = Math.max(0, Math.min(dates.length - 1, Math.floor(x / CELL_WIDTH)));
|
||||
return dates[colIndex] ?? today;
|
||||
}
|
||||
|
||||
return { CELL_WIDTH, dates, visibleDays, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { SSE_EVENT_TYPES } from "@planarchy/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Connects to the SSE timeline endpoint and invalidates React Query caches
|
||||
* when allocation/project change events arrive.
|
||||
*/
|
||||
export function useTimelineSSE() {
|
||||
const queryClient = useQueryClient();
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
function connect() {
|
||||
es = new EventSource("/api/sse/timeline");
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as { type: string };
|
||||
|
||||
switch (data.type) {
|
||||
case SSE_EVENT_TYPES.ALLOCATION_CREATED:
|
||||
case SSE_EVENT_TYPES.ALLOCATION_UPDATED:
|
||||
case SSE_EVENT_TYPES.ALLOCATION_DELETED:
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] });
|
||||
break;
|
||||
|
||||
case SSE_EVENT_TYPES.PROJECT_SHIFTED:
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||
void queryClient.invalidateQueries({ queryKey: [["project", "list"]] });
|
||||
break;
|
||||
|
||||
case SSE_EVENT_TYPES.BUDGET_WARNING:
|
||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] });
|
||||
break;
|
||||
|
||||
case SSE_EVENT_TYPES.PING:
|
||||
reconnectAttempts = 0; // Reset on successful ping
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
reconnectTimeout.current = setTimeout(connect, delay);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
es?.close();
|
||||
if (reconnectTimeout.current) {
|
||||
clearTimeout(reconnectTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useViewPrefs — per-view persistence of sort state and row order.
|
||||
*
|
||||
* Uses a separate localStorage key ("viewprefs_<view>") from useColumnConfig
|
||||
* ("colvis_<view>") to avoid format conflicts. Server-side the data is merged
|
||||
* into the same User.columnPreferences JSONB via trpc.user.setColumnPreferences.
|
||||
*
|
||||
* Each user's sort + row order is independent (stored on their own User record).
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { ViewKey } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface SavedSort {
|
||||
field: string;
|
||||
dir: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface LocalViewPrefs {
|
||||
sort?: SavedSort;
|
||||
rowOrder?: string[];
|
||||
}
|
||||
|
||||
const LS_KEY = (view: ViewKey) => `viewprefs_${view}`;
|
||||
|
||||
function readLocal(view: ViewKey): LocalViewPrefs {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY(view));
|
||||
return raw ? (JSON.parse(raw) as LocalViewPrefs) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocal(view: ViewKey, patch: { sort?: SavedSort | null; rowOrder?: string[] | null }) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const current = readLocal(view);
|
||||
const next = { ...current, ...patch };
|
||||
// Remove keys that are explicitly set to null/undefined
|
||||
if (next.sort == null) delete next.sort;
|
||||
if (!next.rowOrder?.length) delete next.rowOrder;
|
||||
localStorage.setItem(LS_KEY(view), JSON.stringify(next));
|
||||
} catch {
|
||||
// ignore write errors (private browsing, quota, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
export function useViewPrefs(view: ViewKey) {
|
||||
// Initialise synchronously from localStorage so there's no flash
|
||||
const init = readLocal(view);
|
||||
|
||||
const [savedSort, setSavedSortState] = useState<SavedSort | null>(init.sort ?? null);
|
||||
const [rowOrder, setRowOrderState] = useState<string[]>(init.rowOrder ?? []);
|
||||
|
||||
const setMutation = trpc.user.setColumnPreferences.useMutation();
|
||||
|
||||
// Debounce server sync so rapid drags don't spam mutations
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
function scheduleSave(patch: Parameters<typeof setMutation.mutate>[0]) {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setMutation.mutate(patch);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const setSavedSort = useCallback(
|
||||
(sort: SavedSort | null) => {
|
||||
setSavedSortState(sort);
|
||||
writeLocal(view, { sort: sort ?? null });
|
||||
scheduleSave({ view, sort: sort ?? null });
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view],
|
||||
);
|
||||
|
||||
const setRowOrder = useCallback(
|
||||
(ids: string[]) => {
|
||||
setRowOrderState(ids);
|
||||
writeLocal(view, { rowOrder: ids });
|
||||
scheduleSave({ view, rowOrder: ids.length > 0 ? ids : null });
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view],
|
||||
);
|
||||
|
||||
return { savedSort, setSavedSort, rowOrder, setRowOrder };
|
||||
}
|
||||
|
||||
export type ViewPrefsHandle = ReturnType<typeof useViewPrefs>;
|
||||
Reference in New Issue
Block a user