Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 109 additions & 40 deletions client/src/components/resource-profile/NamedResourcesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../lib/api'
import type { ResourceProfileRow } from '../../types/backlog'

type PricingModel = 'ACTUAL_DAYS' | 'PRO_RATA'

Expand All @@ -20,13 +21,34 @@ interface NamedResourcesPanelProps {
rtId: string
rtCount: number
columnCount: number
allocations?: ResourceProfileRow['namedResources']
}

type AllocationEntry = NonNullable<ResourceProfileRow['namedResources']>[number]

function formatWeekLabel(week: number) {
return `W${week + 1}`
}

function formatAssignedSummary(allocation?: AllocationEntry) {
const segments = allocation?.actualAllocationSegments ?? []
if (segments.length === 0) return 'No assigned weeks'
return segments
.slice(0, 2)
.map(segment => (
segment.startWeek === segment.endWeek
? `${formatWeekLabel(segment.startWeek)} (${segment.days.toFixed(1)}d)`
: `${formatWeekLabel(segment.startWeek)}-${formatWeekLabel(segment.endWeek)} (${segment.days.toFixed(1)}d)`
))
.join(', ') + (segments.length > 2 ? ` +${segments.length - 2} more` : '')
}

export default function NamedResourcesPanel({
projectId,
rtId,
rtCount,
columnCount,
allocations = [],
}: NamedResourcesPanelProps) {
const qc = useQueryClient()

Expand Down Expand Up @@ -86,6 +108,30 @@ export default function NamedResourcesPanel({
},
})

const allocationById = new Map(allocations.map(allocation => [allocation.id, allocation]))
const mergedResources = [
...resources.map(resource => ({
...resource,
allocation: allocationById.get(resource.id),
persisted: true,
})),
...allocations
.filter(allocation => !resources.some(resource => resource.id === allocation.id))
.map(allocation => ({
id: allocation.id,
resourceTypeId: rtId,
name: allocation.name,
startWeek: allocation.startWeek,
endWeek: allocation.endWeek,
allocationPct: allocation.allocationPercent,
pricingModel: 'ACTUAL_DAYS' as PricingModel,
createdAt: '',
updatedAt: '',
allocation,
persisted: false,
})),
]

return (
<tr>
<td colSpan={columnCount} className="px-10 py-4 bg-gray-50 dark:bg-gray-700 border-b border-gray-100 dark:border-gray-700">
Expand All @@ -96,100 +142,123 @@ export default function NamedResourcesPanel({

{isLoading ? (
<p className="text-sm text-gray-400 dark:text-gray-500">Loading…</p>
) : resources.length === 0 ? (
) : mergedResources.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
No named resources using aggregate count ({rtCount})
No named resources - using aggregate count ({rtCount})
</p>
) : (
<div className="space-y-0.5">
<div className="grid grid-cols-[1fr_110px_110px_80px_140px_28px] gap-2 text-xs font-medium text-gray-500 dark:text-gray-400 px-2 py-1">
<div className="grid grid-cols-[1fr_110px_110px_80px_150px_minmax(180px,1fr)_28px] gap-2 text-xs font-medium text-gray-500 dark:text-gray-400 px-2 py-1">
<span>Name</span>
<span>Start Week</span>
<span>End Week</span>
<span>Alloc %</span>
<span>Pricing</span>
<span>Assigned weeks</span>
<span />
</div>
{resources.map((r) => (
{mergedResources.map((resource) => (
<div
key={r.id}
className="grid grid-cols-[1fr_110px_110px_80px_140px_28px] gap-2 items-center px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
key={resource.id}
className="grid grid-cols-[1fr_110px_110px_80px_150px_minmax(180px,1fr)_28px] gap-2 items-center px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<input
type="text"
defaultValue={r.name}
defaultValue={resource.name}
onBlur={(e) => {
const val = e.target.value.trim()
if (val && val !== r.name)
updateResource.mutate({ id: r.id, name: val })
if (!resource.persisted) return
const value = e.target.value.trim()
if (value && value !== resource.name) {
updateResource.mutate({ id: resource.id, name: value })
}
}}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full"
disabled={!resource.persisted}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60"
/>
<input
type="number"
defaultValue={r.startWeek ?? ''}
defaultValue={resource.startWeek ?? ''}
placeholder="Project start"
onBlur={(e) => {
const val = e.target.value
? parseInt(e.target.value)
if (!resource.persisted) return
const value = e.target.value
? parseInt(e.target.value, 10)
: null
if (val !== r.startWeek)
updateResource.mutate({ id: r.id, startWeek: val })
if (value !== resource.startWeek) {
updateResource.mutate({ id: resource.id, startWeek: value })
}
}}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full"
disabled={!resource.persisted}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60"
/>
<input
type="number"
defaultValue={r.endWeek ?? ''}
defaultValue={resource.endWeek ?? ''}
placeholder="Project end"
onBlur={(e) => {
const val = e.target.value
? parseInt(e.target.value)
if (!resource.persisted) return
const value = e.target.value
? parseInt(e.target.value, 10)
: null
if (val !== r.endWeek)
updateResource.mutate({ id: r.id, endWeek: val })
if (value !== resource.endWeek) {
updateResource.mutate({ id: resource.id, endWeek: value })
}
}}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full"
disabled={!resource.persisted}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60"
/>
<input
type="number"
min={0}
max={100}
defaultValue={r.allocationPct}
defaultValue={resource.allocationPct}
onBlur={(e) => {
const val = parseInt(e.target.value)
if (!resource.persisted) return
const value = parseInt(e.target.value, 10)
if (
!isNaN(val) &&
val >= 0 &&
val <= 100 &&
val !== r.allocationPct
!isNaN(value) &&
value >= 0 &&
value <= 100 &&
value !== resource.allocationPct
) {
updateResource.mutate({ id: r.id, allocationPct: val })
updateResource.mutate({ id: resource.id, allocationPct: value })
}
}}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full"
disabled={!resource.persisted}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60"
/>
<select
defaultValue={r.pricingModel}
defaultValue={resource.pricingModel}
onChange={(e) => {
if (e.target.value !== r.pricingModel) {
if (!resource.persisted) return
if (e.target.value !== resource.pricingModel) {
updateResource.mutate({
id: r.id,
id: resource.id,
pricingModel: e.target.value,
})
}
}}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full"
disabled={!resource.persisted}
className="border border-gray-200 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-lab3-blue w-full disabled:opacity-60"
>
<option value="ACTUAL_DAYS">Actual Days</option>
<option value="PRO_RATA">Pro-rata</option>
</select>
<div className="text-xs text-gray-500 dark:text-gray-400">
<div>{formatAssignedSummary(resource.allocation)}</div>
{resource.allocation?.actualAllocatedDays ? (
<div className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">
{resource.allocation.actualAllocatedDays.toFixed(1)} assigned days
</div>
) : null}
</div>
<button
onClick={() => deleteResource.mutate(r.id)}
className="text-gray-400 dark:text-gray-500 hover:text-red-600 text-lg leading-none"
title="Delete"
onClick={() => resource.persisted && deleteResource.mutate(resource.id)}
disabled={!resource.persisted}
className="text-gray-400 dark:text-gray-500 hover:text-red-600 text-lg leading-none disabled:opacity-30"
title={resource.persisted ? 'Delete' : 'Generated slot'}
>
×
x
</button>
</div>
))}
Expand Down
27 changes: 25 additions & 2 deletions client/src/components/resource-profile/ResourceProfileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ export default function ResourceProfileTab({
{row.count > 1 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">({formatNumber(row.totalHours / row.count)}h / {formatNumber(row.totalDays / row.count)}d per person)</p>
)}
{row.namedResources && row.namedResources.some(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0) && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Assigned: {row.namedResources
.filter(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0)
.slice(0, 2)
.map(namedResource => {
const firstSegment = namedResource.actualAllocationSegments[0]
return firstSegment.startWeek === firstSegment.endWeek
? `${namedResource.name} W${firstSegment.startWeek + 1}`
: `${namedResource.name} W${firstSegment.startWeek + 1}-W${firstSegment.endWeek + 1}`
})
.join('; ')}
{row.namedResources.filter(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0).length > 2
? ` +${row.namedResources.filter(namedResource => (namedResource.actualAllocationSegments?.length ?? 0) > 0).length - 2} more`
: ''}
</p>
)}
<div className="flex items-center gap-3 mt-0.5">
<button className="text-xs text-red-500 hover:text-red-700 transition-colors" onClick={() => toggleRow(row.resourceTypeId)}>
{expandedRows.has(row.resourceTypeId) ? '▲ Hide breakdown' : '▼ Show breakdown'}
Expand All @@ -85,7 +102,7 @@ export default function ResourceProfileTab({
</td>
<td className="text-center px-4 py-3 text-gray-800 dark:text-gray-100">
<div className="flex items-center justify-center gap-1">
<button onClick={e => { e.stopPropagation(); removeLastPerson.mutate(row.resourceTypeId) }} disabled={row.count <= 1}
<button onClick={e => { e.stopPropagation(); removeLastPerson.mutate(row.resourceTypeId) }} disabled={row.count <= 0}
className="w-6 h-6 rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed text-sm font-medium" title="Remove person">−</button>
<span className="w-8 text-center text-sm font-medium">{row.count}</span>
<button onClick={e => { e.stopPropagation(); addPerson.mutate(row.resourceTypeId) }}
Expand Down Expand Up @@ -156,7 +173,13 @@ export default function ResourceProfileTab({
{hasCost && <td className="text-right px-6 py-3 text-gray-900 dark:text-white">{row.estimatedCost != null ? `$${formatNumber(row.estimatedCost, 0)}` : '—'}</td>}
</tr>
{expandedNamedResources.has(row.resourceTypeId) && (
<NamedResourcesPanel projectId={projectId} rtId={row.resourceTypeId} rtCount={row.count} columnCount={columnCount} />
<NamedResourcesPanel
projectId={projectId}
rtId={row.resourceTypeId}
rtCount={row.count}
columnCount={columnCount}
allocations={row.namedResources}
/>
)}
{expandedRows.has(row.resourceTypeId) && (
<tr className="bg-gray-50 dark:bg-gray-700">
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/timeline/GanttBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,11 @@ export default function GanttBar({
const barColor = entry.timelineColour ?? colour.hex
const isDragging = dragging?.type === 'feature' && dragging.id === entry.featureId
const effectiveStart = isDragging ? dragging!.currentStart : entry.startWeek
const effectiveEnd = effectiveStart + entry.durationWeeks
const barW = Math.max(entry.durationWeeks * colW, 4)
const isOverAllocated = weeklyDemand.some(d =>
d.week >= entry.startWeek &&
d.week < entry.startWeek + entry.durationWeeks &&
d.week >= effectiveStart &&
d.week < effectiveEnd &&
d.demandDays > d.capacityDays + 0.01,
)
const tooltipContent = buildFeatureTooltip(entry)
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/timeline/GanttChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ export default function GanttChart({
{/* Right SVG area — horizontally scrollable */}
<div className="overflow-x-auto flex-1" ref={rightPanelRef} onScroll={onRightPanelScroll}>
<svg width={totalWeeks * colW} height={totalHeight} style={{ display: 'block' }}>
{/* Background fill */}
<rect x={0} y={0} width={totalWeeks * colW} height={totalHeight} fill={svgColors.bg} style={{ pointerEvents: 'none' }} />

<GanttDependencyArrows
featureDependencies={featureDependencies}
storyDependencies={storyDependencies}
Expand All @@ -362,9 +365,6 @@ export default function GanttChart({
dragging={dragging}
/>

{/* Background fill */}
<rect x={0} y={0} width={totalWeeks * colW} height={totalHeight} fill={svgColors.bg} style={{ pointerEvents: 'none' }} />

{/* Onboarding zone */}
{weekOffset > 0 && (
<g style={{ pointerEvents: 'none' }}>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/timeline/ResourceHistogram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export default function ResourceHistogram({
setTooltip({
x: e.clientX,
y: e.clientY,
content: `Week ${w} · ${rt.name}: ${demand.toFixed(1)} / ${cap.toFixed(1)} days${pct}`,
content: `Week ${w + 1} · ${rt.name}: ${demand.toFixed(1)} / ${cap.toFixed(1)} days${pct}`,
})
}}
onMouseMove={(e) => {
Expand Down
Loading
Loading