1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
/**
|
|
* Shared pure render functions used by both TimelineResourcePanel and TimelineProjectPanel.
|
|
* Extracted to avoid duplication of identical vacation blocks, range overlay, and overbooking blink logic.
|
|
*/
|
|
|
|
import React from "react";
|
|
import { clsx } from "clsx";
|
|
import {
|
|
VACATION_TIMELINE_COLORS,
|
|
VACATION_TIMELINE_BORDER,
|
|
VACATION_TYPE_LABELS_SHORT,
|
|
} from "~/lib/status-styles.js";
|
|
import type { RangeState } from "~/hooks/useTimelineDrag.js";
|
|
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
|
|
import type { VacationEntry } from "./TimelineContext.js";
|
|
|
|
// ─── Shared types ─────────────────────────────────────────────────────────────
|
|
|
|
export interface VacationBlockInfo {
|
|
vacation: VacationEntry;
|
|
left: number;
|
|
width: number;
|
|
}
|
|
|
|
export function buildVacationBlocksByResource(
|
|
vacationsByResource: Map<string, VacationEntry[]>,
|
|
showVacations: boolean,
|
|
toLeft: (date: Date) => number,
|
|
toWidth: (start: Date, end: Date) => number,
|
|
cellWidth: number,
|
|
totalCanvasWidth: number,
|
|
showWeekends = true,
|
|
) {
|
|
if (!showVacations) return new Map<string, VacationBlockInfo[]>();
|
|
|
|
const result = new Map<string, VacationBlockInfo[]>();
|
|
for (const [resourceId, vacations] of vacationsByResource) {
|
|
const blocks: VacationBlockInfo[] = [];
|
|
for (const vacation of vacations) {
|
|
const start = new Date(vacation.startDate);
|
|
const end = new Date(vacation.endDate);
|
|
// When weekends are hidden, skip single-day entries that fall on a weekend
|
|
// (e.g. Easter Sunday). They would otherwise render at Monday's column,
|
|
// shadowing Monday's own holiday (e.g. Easter Monday).
|
|
if (!showWeekends && start.toDateString() === end.toDateString()) {
|
|
const dow = start.getDay();
|
|
if (dow === 0 || dow === 6) continue;
|
|
}
|
|
const left = toLeft(start);
|
|
const width = Math.max(cellWidth, toWidth(start, end));
|
|
if (width <= 0 || left >= totalCanvasWidth) continue;
|
|
blocks.push({ vacation, left, width });
|
|
}
|
|
if (blocks.length > 0) {
|
|
result.set(resourceId, blocks);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── Vacation block overlays ─────────────────────────────────────────────────
|
|
|
|
export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: number) {
|
|
if (blocks.length === 0) return null;
|
|
|
|
return blocks.map(({ vacation: v, left, width }) => {
|
|
const colorClass = VACATION_TIMELINE_COLORS[v.type] ?? "bg-orange-400/40";
|
|
const borderClass = VACATION_TIMELINE_BORDER[v.type] ?? "border-orange-500";
|
|
const label = VACATION_TYPE_LABELS_SHORT[v.type] ?? v.type;
|
|
const isPending = v.status === "PENDING";
|
|
|
|
return (
|
|
<div
|
|
key={`vac-${v.id}`}
|
|
data-testid="timeline-vacation-block"
|
|
data-vacation-id={v.id}
|
|
data-vacation-type={v.type}
|
|
data-vacation-status={v.status}
|
|
data-vacation-note={v.note ?? ""}
|
|
className={clsx(
|
|
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
|
|
colorClass,
|
|
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
|
|
isPending ? "" : borderClass,
|
|
)}
|
|
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
|
>
|
|
{width > 40 && (
|
|
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
|
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── Range selection overlay ─────────────────────────────────────────────────
|
|
|
|
export function renderRangeOverlay(
|
|
rangeState: RangeState,
|
|
resourceId: string,
|
|
rowHeight: number,
|
|
toLeft: (d: Date) => number,
|
|
toWidth: (s: Date, e: Date) => number,
|
|
CELL_WIDTH: number,
|
|
) {
|
|
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
|
|
return null;
|
|
}
|
|
const end = rangeState.currentDate ?? rangeState.startDate;
|
|
const [selStart, selEnd] =
|
|
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
|
|
|
|
const left = toLeft(selStart);
|
|
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
|
|
|
|
return (
|
|
<div
|
|
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
|
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── Overbooking blink overlay ───────────────────────────────────────────────
|
|
|
|
export function renderOverbookingBlink(
|
|
allocs: TimelineAssignmentEntry[],
|
|
dates: Date[],
|
|
CELL_WIDTH: number,
|
|
capacityHoursByDay?: number[],
|
|
bookingFactorsByDay?: number[],
|
|
) {
|
|
const overbooked: number[] = [];
|
|
|
|
const allocRanges = allocs.map((a) => {
|
|
const s = new Date(a.startDate);
|
|
s.setHours(0, 0, 0, 0);
|
|
const e = new Date(a.endDate);
|
|
e.setHours(0, 0, 0, 0);
|
|
return { s: s.getTime(), e: e.getTime(), h: a.hoursPerDay };
|
|
});
|
|
|
|
for (let i = 0; i < dates.length; i++) {
|
|
const d = new Date(dates[i]!);
|
|
d.setHours(0, 0, 0, 0);
|
|
const t = d.getTime();
|
|
let totalH = 0;
|
|
const factor = bookingFactorsByDay?.[i] ?? 1;
|
|
for (const { s, e, h } of allocRanges) {
|
|
if (t >= s && t <= e) {
|
|
totalH += h * factor;
|
|
}
|
|
}
|
|
if (totalH > (capacityHoursByDay?.[i] ?? 8)) overbooked.push(i);
|
|
}
|
|
|
|
if (overbooked.length === 0) return null;
|
|
|
|
return overbooked.map((i) => (
|
|
<div
|
|
key={`ob-${i}`}
|
|
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
|
style={{
|
|
left: i * CELL_WIDTH,
|
|
width: CELL_WIDTH,
|
|
}}
|
|
/>
|
|
));
|
|
}
|