diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 772065b..20adc34 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -59,7 +59,7 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu return (
-
+
{utilizationPercent.toFixed(0)}% used
@@ -407,8 +407,17 @@ export function ProjectsClient() { ); case "allocations": return ( - - {project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"} + + {project.totalPersonDays > 0 ? ( + + + + + {project.totalPersonDays}d + + ) : ( + + )} ); case "responsible": diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 8851c1b..d1141c4 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -1148,20 +1148,51 @@ export function ResourcesClient() { {resource.eid} ); - case "displayName": + case "displayName": { + const initials = resource.displayName + .split(/\s+/) + .map((w) => w[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase(); + const rr = + ( + resource as unknown as { + resourceRoles?: { + isPrimary: boolean; + role: { color: string | null }; + }[]; + } + ).resourceRoles ?? []; + const primaryRole = rr.find((r) => r.isPrimary); + const avatarColor = + primaryRole?.role.color ?? + `hsl(${[...resource.displayName].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360}, 55%, 45%)`; return ( - {resource.displayName} + + {initials} + + + + {resource.displayName} + + + {resource.email} + + -
- {resource.email} -
); + } case "chapter": return ( = target - 20 ? "text-amber-600 dark:text-amber-300" : "text-red-600 dark:text-red-300"; + // Bar color based on % of target achieved + const barRatio = actual != null && target > 0 ? actual / target : 0; + const barColor = + actual == null + ? "bg-gray-300 dark:bg-gray-600" + : barRatio >= 0.8 + ? "bg-green-500" + : barRatio >= 0.5 + ? "bg-amber-500" + : "bg-red-500"; + const barWidth = actual != null ? Math.min(actual, 100) : 0; + const isOverflow = actual != null && actual > 100; return ( - +
{actual != null ? `${actual}%` : "—"} @@ -1213,7 +1256,22 @@ export function ResourcesClient() { ({expected}% exp.) )} -
Target: {target}%
+ {actual !== target && ( +
Target: {target}%
+ )} + {actual != null && ( +
+
+
+
+ {isOverflow && ( + + + )} +
+ )}
); diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 6b1970c..05977d7 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -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 = { + 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 ( - + {(() => { const ftes = row.requiredFTEs as unknown as number; - return ftes > 0 ? ( - - {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 ( + + + + {ftes} FTE + - ) : "—"; + ); })()} )} diff --git a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx index 5f45545..c8bfb28 100644 --- a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx @@ -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 && } - - - + {ring ? ( +
+ + + +
+ ) : ( + + + + )} {sub && {sub}}
@@ -111,6 +128,7 @@ export function StatCardsWidget(_props: Partial = {}) { 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] }} />
); diff --git a/apps/web/src/components/reports/ChargeabilityReportClient.tsx b/apps/web/src/components/reports/ChargeabilityReportClient.tsx index 663a4e6..d839460 100644 --- a/apps/web/src/components/reports/ChargeabilityReportClient.tsx +++ b/apps/web/src/components/reports/ChargeabilityReportClient.tsx @@ -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() {
Average Chargeability
-
{pct(averageChargeability)}
+
+ = averageTarget ? "var(--color-green-500, #22c55e)" : averageChargeability >= averageTarget - 0.1 ? "var(--color-amber-500, #f59e0b)" : "var(--color-red-500, #ef4444)"} + > + {pct(averageChargeability)} + +
Weighted across visible resources
diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx index 7c1d901..28cf979 100644 --- a/apps/web/src/components/resources/ResourceDetail.tsx +++ b/apps/web/src/components/resources/ResourceDetail.tsx @@ -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 = { 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 ( -
+
{label}{tooltip && }
-
{value}
+ {ring ? ( +
+ + {value} + +
+ ) : ( +
{value}
+ )} {sub &&
{sub}
}
); @@ -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 && ( = 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 */} - + + + {/* Roles */} {resourceRoles.length > 0 && ( diff --git a/apps/web/src/components/timeline/ProjectColorLegend.tsx b/apps/web/src/components/timeline/ProjectColorLegend.tsx new file mode 100644 index 0000000..232dbd1 --- /dev/null +++ b/apps/web/src/components/timeline/ProjectColorLegend.tsx @@ -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(); + + 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 ( + +
+ + Projects + +
+ {legendItems.map((item) => ( +
+
+ + {item.shortCode} + +
+ ))} +
+ +
+ + ); +} + +export const ProjectColorLegend = memo(ProjectColorLegendInner); diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index 60c10b0..2672834 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -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({
onOpenPanel(project.id)} > +
{project.name} @@ -659,19 +665,17 @@ function TimelineProjectPanelInner({ {projWidth > 0 && projLeft < totalCanvasWidth && (
{ if (!dragState.isDragging) onOpenPanel(project.id); diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index f01b05e..c54b2db 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -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(); // 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 (
{/* Label column */}
= 48 ? 10 : 6; const hasRecurrence = !!(alloc.metadata as Record | null)?.recurrence; @@ -644,9 +679,7 @@ function renderAllocBlocksFromData(
{ @@ -711,7 +744,11 @@ function renderAllocBlocksFromData( {hasRecurrence && width > 28 && ( )} - {alloc.project.name} + {width > 60 ? ( + {alloc.project.name} + ) : ( + {alloc.project.shortCode} + )} {width > 130 && {alloc.role}} {width > 190 && ( {alloc.hoursPerDay}h @@ -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)` } diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index e7c56f7..f46417a 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -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 */} + + {/* Scrollable canvas */}
90 + ? "var(--color-red-500, #ef4444)" + : pct >= 70 + ? "var(--color-amber-500, #f59e0b)" + : "var(--color-emerald-500, #10b981)"; + return (
-

- Vacation Balance {year} -

+
+ + {balance.remainingDays}d + +
+

+ Vacation Balance {year} +

+

{balance.usedDays} of {balance.entitledDays} days used

+
+
{balance.carryoverDays > 0 && ( +{balance.carryoverDays}d carried over )}
- - + +
diff --git a/apps/web/src/lib/project-colors.ts b/apps/web/src/lib/project-colors.ts new file mode 100644 index 0000000..5a78d97 --- /dev/null +++ b/apps/web/src/lib/project-colors.ts @@ -0,0 +1,58 @@ +/** + * Deterministic project color assignment. + * Maps a project ID to one of 16 visually distinct colors. + * Colors are chosen for readability on both light and dark backgrounds. + */ + +const PROJECT_PALETTE = [ + { bg: "bg-sky-500/70", dark: "bg-sky-400/60", border: "border-sky-600", hex: "#0ea5e9" }, + { bg: "bg-violet-500/70", dark: "bg-violet-400/60", border: "border-violet-600", hex: "#8b5cf6" }, + { bg: "bg-amber-500/70", dark: "bg-amber-400/60", border: "border-amber-600", hex: "#f59e0b" }, + { bg: "bg-rose-500/70", dark: "bg-rose-400/60", border: "border-rose-600", hex: "#f43f5e" }, + { bg: "bg-emerald-500/70", dark: "bg-emerald-400/60", border: "border-emerald-600", hex: "#10b981" }, + { bg: "bg-indigo-500/70", dark: "bg-indigo-400/60", border: "border-indigo-600", hex: "#6366f1" }, + { bg: "bg-orange-500/70", dark: "bg-orange-400/60", border: "border-orange-600", hex: "#f97316" }, + { bg: "bg-teal-500/70", dark: "bg-teal-400/60", border: "border-teal-600", hex: "#14b8a6" }, + { bg: "bg-pink-500/70", dark: "bg-pink-400/60", border: "border-pink-600", hex: "#ec4899" }, + { bg: "bg-cyan-500/70", dark: "bg-cyan-400/60", border: "border-cyan-600", hex: "#06b6d4" }, + { bg: "bg-lime-500/70", dark: "bg-lime-400/60", border: "border-lime-600", hex: "#84cc16" }, + { bg: "bg-fuchsia-500/70", dark: "bg-fuchsia-400/60", border: "border-fuchsia-600", hex: "#d946ef" }, + { bg: "bg-yellow-500/70", dark: "bg-yellow-400/60", border: "border-yellow-600", hex: "#eab308" }, + { bg: "bg-red-500/70", dark: "bg-red-400/60", border: "border-red-600", hex: "#ef4444" }, + { bg: "bg-blue-500/70", dark: "bg-blue-400/60", border: "border-blue-600", hex: "#3b82f6" }, + { bg: "bg-green-500/70", dark: "bg-green-400/60", border: "border-green-600", hex: "#22c55e" }, +] as const; + +export type ProjectColor = (typeof PROJECT_PALETTE)[number]; + +/** + * Returns a deterministic color for a project ID using a simple hash. + * Same project ID always gets the same color. + */ +export function getProjectColor(projectId: string): ProjectColor { + let hash = 0; + for (let i = 0; i < projectId.length; i++) { + hash = ((hash << 5) - hash + projectId.charCodeAt(i)) | 0; + } + return PROJECT_PALETTE[Math.abs(hash) % PROJECT_PALETTE.length]!; +} + +/** + * Returns the hex color with an alpha suffix for inline styles. + * Useful when the block uses inline `backgroundColor` instead of Tailwind classes. + */ +export function getProjectHex(projectId: string, alpha = "B3"): string { + return getProjectColor(projectId).hex + alpha; +} + +/** + * Build a lookup map for a set of project IDs. + * Call once per render with visible project IDs. + */ +export function buildProjectColorMap(projectIds: string[]): Map { + const map = new Map(); + for (const id of projectIds) { + map.set(id, getProjectColor(id)); + } + return map; +}