feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -14,11 +14,7 @@ import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
|
||||
import { TimelineHeader } from "./TimelineHeader.js";
|
||||
import { TimelineToolbar } from "./TimelineToolbar.js";
|
||||
import { addDays } from "./utils.js";
|
||||
import {
|
||||
HEADER_DAY_HEIGHT,
|
||||
HEADER_MONTH_HEIGHT,
|
||||
LABEL_WIDTH,
|
||||
} from "./timelineConstants.js";
|
||||
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
|
||||
import { formatDateShort } from "~/lib/format.js";
|
||||
import {
|
||||
TimelineProvider,
|
||||
@@ -40,11 +36,18 @@ export function TimelineView() {
|
||||
pushHistoryRef.current = pushHistory;
|
||||
|
||||
const [popover, setPopover] = useState<{
|
||||
allocationId: string; projectId: string; x: number; y: number;
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [newAllocPopover, setNewAllocPopover] = useState<{
|
||||
resourceId: string; startDate: Date; endDate: Date;
|
||||
suggestedProjectId: string | null; anchorX: number; anchorY: number;
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
suggestedProjectId: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
} | null>(null);
|
||||
|
||||
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
|
||||
@@ -53,12 +56,24 @@ export function TimelineView() {
|
||||
const cellWidthRef = useRef(40);
|
||||
|
||||
const {
|
||||
dragState, allocDragState, rangeState,
|
||||
shiftPreview, isPreviewLoading, isApplying, isAllocSaving,
|
||||
onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown,
|
||||
onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave,
|
||||
onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart,
|
||||
onCanvasTouchMove, onCanvasTouchEnd,
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying,
|
||||
isAllocSaving,
|
||||
onProjectBarMouseDown,
|
||||
onAllocMouseDown,
|
||||
onRowMouseDown,
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
onCanvasMouseLeave,
|
||||
onProjectBarTouchStart,
|
||||
onAllocTouchStart,
|
||||
onRowTouchStart,
|
||||
onCanvasTouchMove,
|
||||
onCanvasTouchEnd,
|
||||
} = useTimelineDrag({
|
||||
cellWidth: cellWidthRef.current,
|
||||
onBlockClick: (info) => {
|
||||
@@ -189,7 +204,14 @@ function TimelineViewContent({
|
||||
contextResourceIds: string[];
|
||||
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
|
||||
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
|
||||
newAllocPopover: { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number } | null;
|
||||
newAllocPopover: {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
suggestedProjectId: string | null;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
} | null;
|
||||
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
|
||||
openPanelProjectId: string | null;
|
||||
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
@@ -231,13 +253,8 @@ function TimelineViewContent({
|
||||
|
||||
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
|
||||
|
||||
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = useTimelineLayout(
|
||||
viewStart,
|
||||
viewDays,
|
||||
filters.zoom,
|
||||
filters.showWeekends,
|
||||
today,
|
||||
);
|
||||
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
|
||||
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
|
||||
|
||||
// Keep cellWidthRef in sync so the drag hook uses the correct value.
|
||||
cellWidthRef.current = CELL_WIDTH;
|
||||
@@ -295,8 +312,14 @@ function TimelineViewContent({
|
||||
const isMac = navigator.platform.toUpperCase().includes("MAC");
|
||||
const modKey = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (!modKey) return;
|
||||
if (e.key === "z" && !e.shiftKey) { e.preventDefault(); void undo(); }
|
||||
if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); void redo(); }
|
||||
if (e.key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void undo();
|
||||
}
|
||||
if ((e.key === "z" && e.shiftKey) || e.key === "y") {
|
||||
e.preventDefault();
|
||||
void redo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
@@ -317,8 +340,7 @@ function TimelineViewContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 relative mt-2 mx-4 mb-4">
|
||||
|
||||
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
|
||||
{/* Toolbar */}
|
||||
<TimelineToolbar
|
||||
viewMode={viewMode}
|
||||
@@ -335,23 +357,26 @@ function TimelineViewContent({
|
||||
onNavigateForward={() => setViewStart((v) => addDays(v, 28))}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={() => { void undo(); }}
|
||||
onRedo={() => { void redo(); }}
|
||||
onUndo={() => {
|
||||
void undo();
|
||||
}}
|
||||
onRedo={() => {
|
||||
void redo();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrollable canvas */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleContainerScroll}
|
||||
className="flex-1 overflow-auto border border-gray-200 rounded-xl bg-white"
|
||||
className="app-surface relative flex-1 overflow-auto"
|
||||
>
|
||||
{isInitialLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400">
|
||||
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading timeline...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ minWidth: LABEL_WIDTH + totalCanvasWidth }}>
|
||||
|
||||
<TimelineHeader
|
||||
monthGroups={monthGroups}
|
||||
dates={dates}
|
||||
@@ -370,7 +395,9 @@ function TimelineViewContent({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={(e) => void onCanvasMouseUp(e)}
|
||||
onMouseLeave={onCanvasMouseLeave}
|
||||
onTouchMove={(e) => { onCanvasTouchMove(e); }}
|
||||
onTouchMove={(e) => {
|
||||
onCanvasTouchMove(e);
|
||||
}}
|
||||
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={clsx(
|
||||
@@ -423,15 +450,14 @@ function TimelineViewContent({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Saving indicators */}
|
||||
{(isApplying || isAllocSaving) && (
|
||||
<div className="absolute inset-0 bg-white/40 flex items-center justify-center z-50 rounded-xl pointer-events-none">
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-5 py-3 shadow-xl text-sm font-medium text-gray-700">
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
|
||||
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{isApplying ? "Applying shift…" : "Saving…"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,10 +471,17 @@ function TimelineViewContent({
|
||||
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
|
||||
>
|
||||
<ShiftPreviewTooltip
|
||||
preview={shiftPreview ?? {
|
||||
valid: true, deltaCents: 0, wouldExceedBudget: false,
|
||||
budgetUtilizationAfter: 0, conflictCount: 0, errors: [], warnings: [],
|
||||
}}
|
||||
preview={
|
||||
shiftPreview ?? {
|
||||
valid: true,
|
||||
deltaCents: 0,
|
||||
wouldExceedBudget: false,
|
||||
budgetUtilizationAfter: 0,
|
||||
conflictCount: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
projectName={dragState.projectName ?? ""}
|
||||
newStartDate={dragState.currentStartDate ?? today}
|
||||
newEndDate={dragState.currentEndDate ?? today}
|
||||
@@ -458,20 +491,23 @@ function TimelineViewContent({
|
||||
)}
|
||||
|
||||
{/* Alloc drag tooltip */}
|
||||
{allocDragState.isActive && allocDragState.daysDelta !== 0 && allocDragState.currentStartDate && allocDragState.currentEndDate && (
|
||||
<div
|
||||
ref={allocTooltipRef}
|
||||
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
|
||||
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
|
||||
>
|
||||
<div className="font-semibold">{allocDragState.projectName}</div>
|
||||
<div className="opacity-80">
|
||||
{formatDateShort(allocDragState.currentStartDate)}
|
||||
{" – "}
|
||||
{formatDateShort(allocDragState.currentEndDate)}
|
||||
{allocDragState.isActive &&
|
||||
allocDragState.daysDelta !== 0 &&
|
||||
allocDragState.currentStartDate &&
|
||||
allocDragState.currentEndDate && (
|
||||
<div
|
||||
ref={allocTooltipRef}
|
||||
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
|
||||
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
|
||||
>
|
||||
<div className="font-semibold">{allocDragState.projectName}</div>
|
||||
<div className="opacity-80">
|
||||
{formatDateShort(allocDragState.currentStartDate)}
|
||||
{" – "}
|
||||
{formatDateShort(allocDragState.currentEndDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Range-select hint */}
|
||||
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
|
||||
@@ -482,9 +518,10 @@ function TimelineViewContent({
|
||||
>
|
||||
{(() => {
|
||||
const end = rangeState.currentDate;
|
||||
const [s, e] = rangeState.startDate <= end
|
||||
? [rangeState.startDate, end]
|
||||
: [end, rangeState.startDate];
|
||||
const [s, e] =
|
||||
rangeState.startDate <= end
|
||||
? [rangeState.startDate, end]
|
||||
: [end, rangeState.startDate];
|
||||
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1;
|
||||
return `${days} day${days !== 1 ? "s" : ""}`;
|
||||
})()}
|
||||
@@ -497,7 +534,10 @@ function TimelineViewContent({
|
||||
allocationId={popover.allocationId}
|
||||
projectId={popover.projectId}
|
||||
onClose={() => setPopover(null)}
|
||||
onOpenPanel={(pid) => { setPopover(null); setOpenPanelProjectId(pid); }}
|
||||
onOpenPanel={(pid) => {
|
||||
setPopover(null);
|
||||
setOpenPanelProjectId(pid);
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
/>
|
||||
@@ -519,10 +559,7 @@ function TimelineViewContent({
|
||||
|
||||
{/* Project side panel */}
|
||||
{openPanelProjectId && (
|
||||
<ProjectPanel
|
||||
projectId={openPanelProjectId}
|
||||
onClose={() => setOpenPanelProjectId(null)}
|
||||
/>
|
||||
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
|
||||
)}
|
||||
|
||||
{/* Open-demand assignment modal */}
|
||||
|
||||
Reference in New Issue
Block a user