feat: Sprint 2 — data storytelling and visual richness
Timeline project color system: - 16-color deterministic palette (same project = same color always) - Resource panel: allocation blocks colored by project instead of uniform green - Project panel: colored left border + dot on project headers - ProjectColorLegend: floating strip showing color-to-project mapping - Utilization intensity tint: subtle background gradient on resource rows Table visual enhancements: - Resources: inline 3px utilization bar below chargeability percentage - Resources: 32px avatar circles with initials + role-derived colors - Projects: animated budget bars, styled resource count badges - Allocations: 3px left border colored by status (green/amber/blue/gray/red) KPI progress rings: - Budget utilization: ProgressRing wrapping AnimatedNumber on dashboard - Chargeability report: ring on average chargeability summary card - Resource detail: rings on chargeability target + actual metrics - Vacation balance: ring showing remaining days with color thresholds - Demand widget: mini rings on FTE fill rate per project - Resource detail: FadeIn on SkillRadarChart Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -22,6 +22,15 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||
|
||||
/** Left-border color by allocation status for instant visual scanning */
|
||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||
ACTIVE: "border-l-green-500",
|
||||
PROPOSED: "border-l-amber-500",
|
||||
CONFIRMED: "border-l-blue-500",
|
||||
COMPLETED: "border-l-gray-400",
|
||||
CANCELLED: "border-l-red-500",
|
||||
};
|
||||
|
||||
/** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */
|
||||
function GroupRows({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
@@ -363,8 +372,9 @@ export function AllocationsClient() {
|
||||
|
||||
function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false, rowIndex = 0) {
|
||||
const isSelected = selection.selectedIds.has(alloc.id);
|
||||
const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300";
|
||||
return (
|
||||
<tr key={alloc.id} className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}>
|
||||
<tr key={alloc.id} className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
|
||||
type GroupBy = "project" | "person" | "chapter";
|
||||
|
||||
@@ -155,11 +156,21 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
{(() => {
|
||||
const ftes = row.requiredFTEs as unknown as number;
|
||||
return ftes > 0 ? (
|
||||
<span className={row.allocatedHours / 8 < ftes * 22 * 3 ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||
{ftes} FTE
|
||||
if (ftes <= 0) return "—";
|
||||
const requiredHours = ftes * 22 * 3 * 8;
|
||||
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
|
||||
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
|
||||
const ringColor = isBelowTarget
|
||||
? "var(--color-red-500, #ef4444)"
|
||||
: "var(--color-green-500, #22c55e)";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
|
||||
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||
{ftes} FTE
|
||||
</span>
|
||||
</span>
|
||||
) : "—";
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -5,8 +5,15 @@ import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||
|
||||
const ACCENT_COLORS = {
|
||||
green: "var(--color-green-500, #22c55e)",
|
||||
amber: "var(--color-amber-500, #f59e0b)",
|
||||
red: "var(--color-red-500, #ef4444)",
|
||||
} as const;
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
@@ -15,6 +22,7 @@ function StatCard({
|
||||
info,
|
||||
accentColor,
|
||||
delay = 0,
|
||||
ring,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
@@ -23,6 +31,7 @@ function StatCard({
|
||||
info?: React.ReactNode;
|
||||
accentColor?: "green" | "amber" | "red";
|
||||
delay?: number;
|
||||
ring?: { value: number; color: string };
|
||||
}) {
|
||||
const accentBorder = accentColor === "red"
|
||||
? "border-l-red-500"
|
||||
@@ -43,9 +52,17 @@ function StatCard({
|
||||
{label}
|
||||
{info && <InfoTooltip content={info} />}
|
||||
</span>
|
||||
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
<AnimatedNumber value={value} suffix={suffix} />
|
||||
</span>
|
||||
{ring ? (
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<ProgressRing value={ring.value} size={56} strokeWidth={4} color={ring.color}>
|
||||
<AnimatedNumber value={value} suffix={suffix} className="text-lg font-semibold text-gray-900 dark:text-gray-50" />
|
||||
</ProgressRing>
|
||||
</div>
|
||||
) : (
|
||||
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
<AnimatedNumber value={value} suffix={suffix} />
|
||||
</span>
|
||||
)}
|
||||
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
|
||||
</div>
|
||||
</FadeIn>
|
||||
@@ -111,6 +128,7 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
|
||||
accentColor={budgetAccent}
|
||||
delay={0.15}
|
||||
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -445,7 +446,16 @@ export function ChargeabilityReportClient() {
|
||||
</div>
|
||||
<div className="app-surface p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Chargeability<InfoTooltip content="FTE-weighted average chargeability across all visible resources. Formula: sum(FTE x Chg%) / sum(FTE)." /></div>
|
||||
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<ProgressRing
|
||||
value={Math.round(averageChargeability * 100)}
|
||||
size={56}
|
||||
strokeWidth={4}
|
||||
color={averageChargeability >= averageTarget ? "var(--color-green-500, #22c55e)" : averageChargeability >= averageTarget - 0.1 ? "var(--color-amber-500, #f59e0b)" : "var(--color-red-500, #ef4444)"}
|
||||
>
|
||||
<span className={`text-lg font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</span>
|
||||
</ProgressRing>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
|
||||
</div>
|
||||
<div className="app-surface p-4">
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AiSummaryCard } from "./AiSummaryCard.js";
|
||||
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resourceId: string;
|
||||
@@ -47,11 +49,19 @@ const allocationStatusColor: Record<string, string> = {
|
||||
CANCELLED: "bg-red-100 text-red-500",
|
||||
};
|
||||
|
||||
function StatCard({ label, value, sub, tooltip }: { label: string; value: string | number; sub?: string; tooltip?: string }) {
|
||||
function StatCard({ label, value, sub, tooltip, ring }: { label: string; value: string | number; sub?: string; tooltip?: string; ring?: { value: number; color: string } }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
|
||||
<div className="text-xl font-bold text-gray-900">{value}</div>
|
||||
{ring ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<ProgressRing value={ring.value} size={48} strokeWidth={3.5} color={ring.color}>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{value}</span>
|
||||
</ProgressRing>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
|
||||
)}
|
||||
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -291,6 +301,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
label="Chargeability Target"
|
||||
value={`${resource.chargeabilityTarget}%`}
|
||||
tooltip="The percentage of working time this resource is expected to spend on chargeable/billable work."
|
||||
ring={{
|
||||
value: resource.chargeabilityTarget,
|
||||
color: "var(--color-blue-500, #3b82f6)",
|
||||
}}
|
||||
/>
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
@@ -302,6 +316,16 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
? "Incl. proposed + imported TBD planning"
|
||||
: "Confirmed + active only"
|
||||
}
|
||||
{...(chargeStats != null ? {
|
||||
ring: {
|
||||
value: chargeStats.actualChargeability,
|
||||
color: chargeStats.actualChargeability >= resource.chargeabilityTarget
|
||||
? "var(--color-green-500, #22c55e)"
|
||||
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
|
||||
? "var(--color-amber-500, #f59e0b)"
|
||||
: "var(--color-red-500, #ef4444)",
|
||||
},
|
||||
} : {})}
|
||||
/>
|
||||
)}
|
||||
{canViewCosts && (
|
||||
@@ -418,7 +442,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
)}
|
||||
|
||||
{/* Skill Radar Chart */}
|
||||
<SkillRadarChart skills={skills} />
|
||||
<FadeIn delay={0.1} direction="up">
|
||||
<SkillRadarChart skills={skills} />
|
||||
</FadeIn>
|
||||
|
||||
{/* Roles */}
|
||||
{resourceRoles.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTimelineContext } from "./TimelineContext.js";
|
||||
import { getProjectColor } from "~/lib/project-colors.js";
|
||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||
|
||||
function ProjectColorLegendInner() {
|
||||
const { visibleAssignments, viewMode, projectGroups } = useTimelineContext();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Collect unique visible projects with their colors
|
||||
const legendItems = useMemo(() => {
|
||||
const seen = new Map<string, { shortCode: string; name: string; hex: string }>();
|
||||
|
||||
if (viewMode === "project") {
|
||||
for (const group of projectGroups) {
|
||||
if (seen.has(group.id)) continue;
|
||||
const customColor = group.color;
|
||||
const projectColor = getProjectColor(group.id);
|
||||
seen.set(group.id, {
|
||||
shortCode: group.shortCode,
|
||||
name: group.name,
|
||||
hex: customColor ?? projectColor.hex,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const entry of visibleAssignments) {
|
||||
if (seen.has(entry.projectId)) continue;
|
||||
const customColor = (entry.project as { color?: string | null }).color;
|
||||
const projectColor = getProjectColor(entry.projectId);
|
||||
seen.set(entry.projectId, {
|
||||
shortCode: entry.project.shortCode,
|
||||
name: entry.project.name,
|
||||
hex: customColor ?? projectColor.hex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.values()].sort((a, b) => a.shortCode.localeCompare(b.shortCode));
|
||||
}, [visibleAssignments, viewMode, projectGroups]);
|
||||
|
||||
if (dismissed || legendItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-x-auto scrollbar-thin">
|
||||
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 flex-shrink-0 mr-1">
|
||||
Projects
|
||||
</span>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{legendItems.map((item) => (
|
||||
<div
|
||||
key={item.shortCode}
|
||||
className="flex items-center gap-1 flex-shrink-0"
|
||||
title={item.name}
|
||||
>
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: item.hex }}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
||||
{item.shortCode}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className={clsx(
|
||||
"ml-2 flex-shrink-0 w-4 h-4 flex items-center justify-center rounded",
|
||||
"text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors",
|
||||
)}
|
||||
aria-label="Dismiss color legend"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M2 2l8 8M10 2l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProjectColorLegend = memo(ProjectColorLegendInner);
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PROJECT_HEADER_HEIGHT,
|
||||
ORDER_TYPE_COLORS,
|
||||
} from "./timelineConstants.js";
|
||||
import { getProjectColor } from "~/lib/project-colors.js";
|
||||
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
||||
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
||||
import {
|
||||
@@ -609,6 +610,7 @@ function TimelineProjectPanelInner({
|
||||
(() => {
|
||||
const { project } = row;
|
||||
const customColor = project.color;
|
||||
const projectColor = getProjectColor(project.id);
|
||||
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
|
||||
bg: "bg-gray-400",
|
||||
text: "text-white",
|
||||
@@ -631,7 +633,7 @@ function TimelineProjectPanelInner({
|
||||
<div
|
||||
data-project-group="true"
|
||||
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
|
||||
style={{ height: PROJECT_HEADER_HEIGHT }}
|
||||
style={{ height: PROJECT_HEADER_HEIGHT, borderLeft: `4px solid ${customColor ?? projectColor.hex}` }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -641,6 +643,10 @@ function TimelineProjectPanelInner({
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
onClick={() => onOpenPanel(project.id)}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: customColor ?? projectColor.hex }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
|
||||
{project.name}
|
||||
@@ -659,19 +665,17 @@ function TimelineProjectPanelInner({
|
||||
{projWidth > 0 && projLeft < totalCanvasWidth && (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75",
|
||||
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75 text-white",
|
||||
isThisProjectShifting
|
||||
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
|
||||
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||
!customColor && colors.bg,
|
||||
customColor ? "text-white" : colors.text,
|
||||
)}
|
||||
style={{
|
||||
left: projLeft + 2,
|
||||
width: projWidth - 4,
|
||||
top: 8,
|
||||
height: 24,
|
||||
...(customColor ? { backgroundColor: customColor } : {}),
|
||||
backgroundColor: customColor ?? projectColor.hex + "CC",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!dragState.isDragging) onOpenPanel(project.id);
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
} from "./TimelineContext.js";
|
||||
import { ConflictOverlay } from "./ConflictOverlay.js";
|
||||
import { computeSubLanes } from "./utils.js";
|
||||
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
|
||||
import { heatmapBgColor } from "./heatmapUtils.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { TimelineTooltip } from "./TimelineTooltip.js";
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
SUB_LANE_HEIGHT,
|
||||
LABEL_WIDTH,
|
||||
ORDER_TYPE_COLORS,
|
||||
} from "./timelineConstants.js";
|
||||
import { getProjectColor } from "~/lib/project-colors.js";
|
||||
import type {
|
||||
DragState,
|
||||
AllocDragState,
|
||||
@@ -224,6 +224,36 @@ function TimelineResourcePanelInner({
|
||||
return result;
|
||||
}, [displayMode, resourceRows]);
|
||||
|
||||
// ─── Memo 4: utilization per resource for row background tint ───────────
|
||||
const utilizationByResource = useMemo(() => {
|
||||
const REF_H = 8;
|
||||
const result = new Map<string, number>(); // resourceId -> avg utilization pct
|
||||
for (const { resource, allocs } of resourceRows) {
|
||||
if (allocs.length === 0) continue;
|
||||
let totalHours = 0;
|
||||
let dayCount = 0;
|
||||
for (const date of dates) {
|
||||
const t = date.getTime();
|
||||
let dayH = 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()) dayH += a.hoursPerDay;
|
||||
}
|
||||
if (dayH > 0) {
|
||||
totalHours += dayH;
|
||||
dayCount++;
|
||||
}
|
||||
}
|
||||
if (dayCount > 0) {
|
||||
result.set(resource.id, (totalHours / dayCount / REF_H) * 100);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [resourceRows, dates]);
|
||||
|
||||
// ─── Heatmap row hover handler ────────────────────────────────────────────
|
||||
const handleRowHeatmapMove = useCallback(
|
||||
(e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
|
||||
@@ -401,6 +431,14 @@ function TimelineResourcePanelInner({
|
||||
? ROW_HEIGHT
|
||||
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
|
||||
// Utilization background tint
|
||||
const utilPct = utilizationByResource.get(resource.id) ?? 0;
|
||||
const utilBg = utilPct > 100
|
||||
? "rgba(254,202,202,0.18)" // red tint for over-utilized
|
||||
: utilPct >= 50
|
||||
? `rgba(59,130,246,${Math.min(0.06 + (utilPct - 50) * 0.0014, 0.12)})` // faint blue tint scaling 50-100%
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
@@ -419,7 +457,7 @@ function TimelineResourcePanelInner({
|
||||
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
|
||||
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
|
||||
)}
|
||||
style={{ height: rowHeight }}
|
||||
style={{ height: rowHeight, ...(utilBg ? { backgroundColor: utilBg } : {}) }}
|
||||
>
|
||||
{/* Label column */}
|
||||
<div
|
||||
@@ -621,11 +659,8 @@ function renderAllocBlocksFromData(
|
||||
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||
|
||||
const customColor = (alloc.project as { color?: string | null }).color;
|
||||
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
|
||||
bg: "bg-gray-400",
|
||||
text: "text-white",
|
||||
light: "",
|
||||
};
|
||||
const projectColor = getProjectColor(alloc.projectId);
|
||||
const blockBgColor = customColor ?? projectColor.hex + "B3";
|
||||
const HANDLE_W = width >= 48 ? 10 : 6;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||
|
||||
@@ -644,9 +679,7 @@ function renderAllocBlocksFromData(
|
||||
<div
|
||||
key={alloc.id}
|
||||
className={clsx(
|
||||
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
|
||||
!customColor && colors.bg,
|
||||
customColor ? "text-white" : colors.text,
|
||||
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block text-white",
|
||||
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
|
||||
isBeingDragged
|
||||
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
|
||||
@@ -660,7 +693,7 @@ function renderAllocBlocksFromData(
|
||||
width: width - 4,
|
||||
top: blockTop,
|
||||
height: blockHeight,
|
||||
...(customColor ? { backgroundColor: customColor } : {}),
|
||||
backgroundColor: blockBgColor,
|
||||
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
@@ -711,7 +744,11 @@ function renderAllocBlocksFromData(
|
||||
{hasRecurrence && width > 28 && (
|
||||
<span className="text-[10px] opacity-80 flex-shrink-0">↻</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
||||
{width > 60 ? (
|
||||
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-bold truncate opacity-90">{alloc.project.shortCode}</span>
|
||||
)}
|
||||
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
|
||||
{width > 190 && (
|
||||
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
|
||||
@@ -862,11 +899,9 @@ function renderDailyBars(
|
||||
let stackedH = 0;
|
||||
|
||||
const segs: React.ReactNode[] = covering.map((alloc) => {
|
||||
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
|
||||
bg: "bg-gray-400",
|
||||
text: "text-white",
|
||||
light: "",
|
||||
};
|
||||
const customColor = (alloc.project as { color?: string | null }).color;
|
||||
const projectColor = getProjectColor(alloc.projectId);
|
||||
const segBgColor = customColor ?? projectColor.hex + "B3";
|
||||
const segH = Math.max(
|
||||
2,
|
||||
Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)),
|
||||
@@ -907,7 +942,6 @@ function renderDailyBars(
|
||||
key={`bar-${i}-${alloc.id}`}
|
||||
className={clsx(
|
||||
"absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden",
|
||||
colors.bg,
|
||||
isBeingDragged
|
||||
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
||||
: "hover:opacity-80 z-[10]",
|
||||
@@ -918,6 +952,7 @@ function renderDailyBars(
|
||||
width: CELL_WIDTH - 4,
|
||||
height: segH,
|
||||
bottom,
|
||||
backgroundColor: segBgColor,
|
||||
...(multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id)
|
||||
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "./TimelineContext.js";
|
||||
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
|
||||
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
|
||||
import { ProjectColorLegend } from "./ProjectColorLegend.js";
|
||||
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
|
||||
|
||||
// ─── Entry point ────────────────────────────────────────────────────────────
|
||||
@@ -607,6 +608,9 @@ function TimelineViewContent({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Project color legend */}
|
||||
<ProjectColorLegend />
|
||||
|
||||
{/* Scrollable canvas */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
|
||||
interface BalanceCardProps {
|
||||
resourceId: string;
|
||||
@@ -46,20 +47,34 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
|
||||
);
|
||||
}
|
||||
|
||||
const ringColor = pct > 90
|
||||
? "var(--color-red-500, #ef4444)"
|
||||
: pct >= 70
|
||||
? "var(--color-amber-500, #f59e0b)"
|
||||
: "var(--color-emerald-500, #10b981)";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Vacation Balance {year}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<ProgressRing value={pct} size={52} strokeWidth={4} color={ringColor}>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{balance.remainingDays}d</span>
|
||||
</ProgressRing>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Vacation Balance {year}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{balance.usedDays} of {balance.entitledDays} days used</p>
|
||||
</div>
|
||||
</div>
|
||||
{balance.carryoverDays > 0 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryoverDays}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" tooltip="Total vacation days granted for this year, including carryover from previous year." />
|
||||
<Stat label="Used" value={balance.usedDays} color="text-gray-600" tooltip="Days already consumed by approved vacations that have passed." />
|
||||
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900 dark:text-gray-100" tooltip="Total vacation days granted for this year, including carryover from previous year." />
|
||||
<Stat label="Used" value={balance.usedDays} color="text-gray-600 dark:text-gray-400" tooltip="Days already consumed by approved vacations that have passed." />
|
||||
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
|
||||
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user