refactor: consolidate duplicated code across web and API packages

- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx
- Centralize status badge styles and vacation color maps into status-styles.ts
- Extract dragMath.ts utility from useTimelineDrag for reuse
- Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries)
- Adopt findUniqueOrThrow() and Prisma select constants across API routers
- Add shared fmtEur() helper for API-side money formatting
- Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo
- Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal
- 38 files changed, reducing ~400 lines of duplicated code

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:10:08 +01:00
parent ddec3a927a
commit e7b74f13bd
38 changed files with 637 additions and 652 deletions
@@ -1,11 +1,12 @@
"use client";
import { useRef, useState, useMemo, useCallback } from "react";
import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatDateMedium } from "~/lib/format.js";
import { formatCents, formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface OpenDemandAllocation {
@@ -69,15 +70,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200);
}
const utils = trpc.useUtils();
const invalidatePlanningViews = useCallback(async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
await utils.timeline.getProjectContext.invalidate();
await utils.timeline.getBudgetStatus.invalidate();
}, [utils]);
const invalidatePlanningViews = useInvalidatePlanningViews();
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
@@ -211,7 +204,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR` : ""}
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
</div>
</div>
</div>
@@ -414,7 +407,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{(r.estimatedCostCents / 100).toLocaleString("de-DE")} EUR</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
<td className="px-3 py-2 text-right">
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
{r.coveragePercent}%
@@ -431,7 +424,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{Math.round(consumedHours)}h / {totalDemandHours}h
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{(planned.reduce((s, r) => s + r.estimatedCostCents, 0) / 100).toLocaleString("de-DE")} EUR
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
@@ -441,7 +434,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<tr>
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR
{formatCents(allocation.budgetCents)} EUR
</td>
<td className="px-3 py-1.5 text-right text-xs">
{(() => {
@@ -449,7 +442,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const remain = allocation.budgetCents! - totalCost;
return (
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
{remain < 0 ? `${(Math.abs(remain) / 100).toLocaleString("de-DE")} over` : `${(remain / 100).toLocaleString("de-DE")} left`}
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
</span>
);
})()}
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatCents, formatMoney } from "~/lib/format.js";
import { ProjectStatus } from "@planarchy/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
@@ -271,7 +272,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{(p.totalCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })}
{formatCents(p.totalCostCents)}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{p.totalPersonDays}d
@@ -280,7 +281,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
{p.budgetCents > 0 ? (
<div className="flex items-center justify-end gap-1.5">
<span className="text-gray-700 dark:text-gray-200">
{(p.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })}
{formatMoney(p.budgetCents)}
</span>
<span
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${overBudget ? "bg-red-500" : util >= 80 ? "bg-amber-500" : "bg-green-500"}`}
@@ -2,7 +2,7 @@
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { formatDate, formatMoney } from "~/lib/format.js";
import { formatCents, formatDate, formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ALLOCATION_STATUS_BADGE } from "~/lib/status-styles.js";
import { usePermissions } from "~/hooks/usePermissions.js";
@@ -127,7 +127,7 @@ export function ProjectAssignmentsTable({ assignments }: ProjectAssignmentsTable
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{assignment.hoursPerDay}h</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })}
{formatCents(assignment.dailyCostCents)}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
{(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.hoursPerDay).toLocaleString("de-DE", { minimumFractionDigits: 1 })}h
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "~/lib/format.js";
import { formatCents, formatDate } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationModal } from "~/components/allocations/AllocationModal.js";
@@ -132,11 +132,11 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
return (
<div>
<div className="text-gray-900 dark:text-gray-100">
{(demand.budgetCents! / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
{formatCents(demand.budgetCents!)} EUR
</div>
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
{bookedCents > 0 ? `${(bookedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} booked` : ""}
{remainCents < 0 ? ` (${(Math.abs(remainCents) / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} over)` : ""}
{bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""}
{remainCents < 0 ? ` (${formatCents(Math.abs(remainCents))} over)` : ""}
</div>
</div>
);
@@ -10,6 +10,7 @@ import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -506,9 +507,9 @@ function Step3({ state, onChange }: Step3Props) {
<div className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
<div className="flex justify-between">
<span>Project: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
<span>Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
<span className="font-semibold">Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
</div>
</div>
);
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate } from "~/lib/format.js";
import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js";
@@ -276,14 +276,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{canViewCosts && (
<StatCard
label="LCR"
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`}
value={`${formatMoney(resource.lcrCents, resource.currency)}/h`}
tooltip="Loaded Cost Rate: fully-loaded hourly cost including salary, benefits, and overhead. Used in budget calculations."
/>
)}
{canViewCosts && (
<StatCard
label="UCR"
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`}
value={`${formatMoney(resource.ucrCents, resource.currency)}/h`}
tooltip="Unit Cost Rate: the rate charged to the client or project for this resource's time."
/>
)}
@@ -518,7 +518,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{canViewCosts && (
<td className="px-4 py-3 text-right text-gray-700">
{a.dailyCostCents > 0
? `${(a.dailyCostCents / 100).toFixed(0)}/d`
? `${formatMoney(a.dailyCostCents)}/d`
: "—"}
</td>
)}
@@ -4,6 +4,7 @@ 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 { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -29,6 +30,7 @@ export function AllocationPopover({
}: AllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
@@ -55,10 +57,7 @@ export function AllocationPopover({
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();
invalidateTimeline();
void utils.allocation.listView.invalidate();
onClose();
},
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
interface BatchAssignPopoverProps {
resourceIds: string[];
@@ -29,7 +30,7 @@ export function BatchAssignPopover({
onCreated,
}: BatchAssignPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
@@ -51,10 +52,7 @@ export function BatchAssignPopover({
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
invalidateTimeline();
onCreated();
onClose();
},
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatDateLong } from "~/lib/format.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
@@ -143,13 +143,13 @@ export function DemandPopover({
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
{formatCents(demand.dailyCostCents)} EUR
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
{formatCents(budgetCents)} EUR
</div>
</div>
</>
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps {
@@ -36,7 +37,7 @@ export function NewAllocationPopover({
onCreated,
}: NewAllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
@@ -60,10 +61,7 @@ export function NewAllocationPopover({
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();
invalidateTimeline();
onCreated();
onClose();
},
@@ -4,6 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -89,7 +90,7 @@ function normalizeRole(value: string | null | undefined): string {
}
export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery(
{ projectId },
@@ -102,29 +103,16 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
);
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();
},
onSuccess: invalidateTimeline,
});
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();
},
onSuccess: invalidateTimeline,
});
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();
invalidateTimeline();
setAddingMember(false);
setResourceSearch("");
},
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@planarchy/shared";
interface ResourceHoverCardProps {
@@ -120,14 +121,14 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
{formatCents(data.lcrCents)} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
{formatCents(data.ucrCents)} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
@@ -1,7 +1,7 @@
"use client";
import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react";
import {
@@ -22,6 +22,12 @@ import {
} from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
import {
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -173,7 +179,7 @@ function buildProjectRowGridBackground(dates: Date[], CELL_WIDTH: number, today:
// ─── Component ──────────────────────────────────────────────────────────────
export function TimelineProjectPanel({
function TimelineProjectPanelInner({
scrollContainerRef,
dragState,
allocDragState,
@@ -785,22 +791,30 @@ export function TimelineProjectPanel({
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForProjectRow(
vacationsByResource.get(row.resource.id) ?? [],
ROW_HEIGHT,
toLeft,
toWidth,
CELL_WIDTH,
totalCanvasWidth,
filters.showVacations,
)}
{filters.showVacations &&
renderVacationBlocks(
(vacationsByResource.get(row.resource.id) ?? []).reduce<VacationBlockInfo[]>(
(acc, v) => {
const vStart = new Date(v.startDate);
const vEnd = new Date(v.endDate);
const left = toLeft(vStart);
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
if (width > 0 && left < totalCanvasWidth) {
acc.push({ vacation: v, left, width });
}
return acc;
},
[],
),
ROW_HEIGHT,
)}
{blinkOverbookedDays &&
renderOverbookingBlinkProject(
renderOverbookingBlink(
allocsByResource.get(row.resource.id) ?? [],
dates,
CELL_WIDTH,
)}
{renderRangeOverlayProject(
{renderRangeOverlay(
rangeState,
row.resource.id,
ROW_HEIGHT,
@@ -1294,127 +1308,4 @@ function renderProjectDragHandles(
});
}
// ─── Vacation blocks for project view rows ──────────────────────────────────
const TYPE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
const TYPE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
const TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
function renderVacationBlocksForProjectRow(
vacations: { id: string; type: string; startDate: Date | string; endDate: Date | string }[],
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
totalCanvasWidth: number,
showVacations: boolean,
) {
if (!showVacations || vacations.length === 0) return null;
return vacations.map((v) => {
const vStart = new Date(v.startDate);
const vEnd = new Date(v.endDate);
const left = toLeft(vStart);
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
return (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
colorClass,
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">
🏖 {label}
</span>
)}
</div>
);
});
}
// ─── Range overlay for project view ─────────────────────────────────────────
function renderOverbookingBlinkProject(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
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;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) 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 }}
/>
));
}
function renderRangeOverlayProject(
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 }}
/>
);
}
export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
@@ -1,12 +1,11 @@
"use client";
import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
useTimelineContext,
type TimelineAssignmentEntry,
type VacationEntry,
} from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
@@ -27,6 +26,12 @@ import type {
MultiSelectState,
} from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import {
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -76,7 +81,7 @@ export interface RowMouseDownInfo {
// ─── Component ──────────────────────────────────────────────────────────────
export function TimelineResourcePanel({
function TimelineResourcePanelInner({
scrollContainerRef,
dragState,
allocDragState,
@@ -487,7 +492,7 @@ export function TimelineResourcePanel({
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForRow(
{renderVacationBlocks(
vacationBlocksByResource.get(resource.id) ?? [],
rowHeight,
)}
@@ -547,12 +552,6 @@ export function TimelineResourcePanel({
// ─── Helper types ───────────────────────────────────────────────────────────
interface VacationBlockInfo {
vacation: VacationEntry;
left: number;
width: number;
}
interface AllocBlockData {
alloc: TimelineAssignmentEntry;
lane: number;
@@ -560,81 +559,6 @@ interface AllocBlockData {
// ─── Pure render functions (no hooks, extracted from TimelineView) ───────────
const TYPE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
const TYPE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
const TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
function renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) {
if (blocks.length === 0) return null;
return blocks.map(({ vacation: v, left, width }) => {
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
const isPending = v.status === "PENDING";
return (
<div
key={`vac-${v.id}`}
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>
);
});
}
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 }}
/>
);
}
function renderAllocBlocksFromData(
blockData: AllocBlockData[],
_allocs: TimelineAssignmentEntry[],
@@ -890,45 +814,6 @@ function renderHeatmapOverlay(
});
}
// ─── Overbooking blink overlay ───────────────────────────────────────────────
function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
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;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) 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,
}}
/>
));
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars(
@@ -1108,5 +993,8 @@ function renderDailyBars(
});
}
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
// ─── Re-export tooltip types for the parent ─────────────────────────────────
export type { VacationBlockInfo, AllocBlockData };
export type { AllocBlockData };
export type { VacationBlockInfo } from "./renderHelpers.js";
+22 -130
View File
@@ -7,6 +7,7 @@ import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
@@ -29,6 +30,7 @@ import {
} from "./TimelineContext.js";
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
// ─── Entry point ────────────────────────────────────────────────────────────
// Two-layer mount: the outer shell creates drag state + project context,
@@ -67,14 +69,9 @@ export function TimelineView() {
// We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40);
const outerUtils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => {
void outerUtils.timeline.getEntries.invalidate();
void outerUtils.timeline.getEntriesView.invalidate();
void outerUtils.timeline.getProjectContext.invalidate();
void outerUtils.timeline.getBudgetStatus.invalidate();
},
onSuccess: invalidateTimeline,
});
const {
@@ -327,13 +324,10 @@ function TimelineViewContent({
} | null>(null);
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const utils = trpc.useUtils();
const invalidateTimelineInner = useInvalidateTimeline();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
invalidateTimelineInner();
clearMultiSelect();
},
});
@@ -570,124 +564,22 @@ function TimelineViewContent({
};
// ─── Multi-select intersection computation ────────────────────────────────
useEffect(() => {
// Only compute when drag just ended (isSelecting false but has coordinates)
if (multiSelectState.isSelecting) return;
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
const canvasEl = canvasRef.current;
if (!canvasEl) return;
// Selection rectangle in viewport coordinates (same coordinate space as
// getBoundingClientRect). Using viewport coords directly avoids any
// coordinate transformation errors from sticky headers or virtualizer offsets.
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
// Query any row element to find the actual canvas area position.
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
// Find allocations within the rectangle by querying actual DOM positions.
// This avoids any mismatch between computed row positions and actual rendering.
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
// Compare directly in viewport coordinates
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
// Project view: query actual resource row DOM elements by data attribute.
// Each row carries data-project-id and data-resource-id for alloc lookup.
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
// Find matching group and row
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
useMultiSelectIntersection({
multiSelectState,
setMultiSelectState,
clearMultiSelect,
canvasRef,
viewMode,
resources,
allocsByResource,
projectGroups,
openDemandsByProject,
dates,
today,
CELL_WIDTH,
toLeft,
toWidth,
});
return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
@@ -0,0 +1,51 @@
/**
* Pure math utilities for timeline drag operations.
* Extracted from useTimelineDrag to make the conversion logic testable
* and reusable across different drag modes.
*/
/** Convert a pixel delta to a number of whole days based on cell width. */
export function pixelsToDays(deltaX: number, cellWidth: number): number {
return Math.round(deltaX / cellWidth);
}
/**
* Shift a date by a given number of days, returning a new Date.
* Does not mutate the input.
*/
export function shiftDate(date: Date, daysDelta: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + daysDelta);
return result;
}
/**
* Compute new start/end dates for a drag operation.
* Handles move, resize-start, and resize-end modes with clamping
* to prevent start from crossing past end (or vice versa).
*/
export function computeDragDates(
mode: "move" | "resize-start" | "resize-end",
originalStart: Date,
originalEnd: Date,
daysDelta: number,
): { start: Date; end: Date } {
const newStart = new Date(originalStart);
const newEnd = new Date(originalEnd);
if (mode === "move") {
newStart.setDate(newStart.getDate() + daysDelta);
newEnd.setDate(newEnd.getDate() + daysDelta);
} else if (mode === "resize-start") {
newStart.setDate(newStart.getDate() + daysDelta);
// Clamp: allow same-day but prevent crossing
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + daysDelta);
// Clamp: allow same-day but prevent crossing
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
}
return { start: newStart, end: newEnd };
}
@@ -0,0 +1,121 @@
/**
* 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 { 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;
}
// ─── 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}`}
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,
) {
const REF_H = 8;
const overbooked: number[] = [];
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;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) 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,
}}
/>
));
}
@@ -4,13 +4,7 @@ import { useState } from "react";
import { VacationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-500",
OTHER: "bg-purple-400",
};
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
@@ -49,7 +43,7 @@ export function TeamCalendar() {
const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
const chapters = allChapters ?? [];
const resourceList = resources?.resources ?? [];
const resourceList: { id: string; displayName: string }[] = resources?.resources ?? [];
const vacationList = (vacations ?? []).filter(
(v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED,
);
@@ -155,7 +149,7 @@ export function TeamCalendar() {
let cellClass = "w-7 h-7";
if (vac) {
const color = TYPE_COLOR[vac.type] ?? "bg-gray-400";
const color = VACATION_CALENDAR_COLORS[vac.type] ?? "bg-gray-400";
const opacity = vac.status === "PENDING" ? "opacity-50" : "";
cellClass += ` ${color} ${opacity}`;
} else if (isWeekend) {
@@ -186,7 +180,7 @@ export function TeamCalendar() {
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
{Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
interface VacationEntry {
id: string;
@@ -18,13 +19,6 @@ interface VacationCalendarProps {
initialMonth?: number; // 0-indexed
}
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-400",
OTHER: "bg-purple-400",
};
const STATUS_OPACITY: Record<string, string> = {
APPROVED: "opacity-100",
PENDING: "opacity-60",
@@ -145,7 +139,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
</span>
<div className="space-y-0.5">
{dayVacations.slice(0, 3).map((v) => {
const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400";
const colorClass = VACATION_CALENDAR_COLORS[v.type] ?? "bg-gray-400";
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
const name = v.resource?.displayName ?? "—";
return (
@@ -169,7 +163,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
{Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
@@ -7,16 +7,10 @@ import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
const VACATION_TYPES = Object.values(VacationType);
const VACATION_TYPE_LABELS: Record<VacationType, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
interface VacationModalProps {
resourceId?: string;
onClose: () => void;
@@ -118,7 +112,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = resources?.resources ?? [];
const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? [];
return (
<div