docs(openspec): add reporting API contract documentation
Add comprehensive API documentation for the reporting endpoint: - Request/response structure - View type inference (did/is/will) - Blank vs explicit zero semantics - Status values and error responses Related to enhanced-allocation change.
This commit is contained in:
20
.opencode/package-lock.json
generated
20
.opencode/package-lock.json
generated
@@ -5,26 +5,30 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.6",
|
"@opencode-ai/plugin": "1.2.14",
|
||||||
"@th0rgal/ralph-wiggum": "^1.2.1"
|
"@th0rgal/ralph-wiggum": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/plugin": {
|
"node_modules/@opencode-ai/plugin": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.14.tgz",
|
||||||
|
"integrity": "sha512-36dPaIaNPMjA5jnFAbOzvKe78dbUkKXF8hgs8PNRXiAaTSzoIapBC/xkADVRO66tmLyZhoGFSBkVeJUpOyyiew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.14",
|
||||||
"zod": "4.1.8"
|
"zod": "4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.14.tgz",
|
||||||
|
"integrity": "sha512-nPkWAmzgPJYyfCJAV4NG7HTfN/iuO3B6fv8sT26NhPiR+EqD9i8sh4X1LwI7wEbbMOwWOX1PhrssW6gXQOOQZQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@th0rgal/ralph-wiggum": {
|
"node_modules/@th0rgal/ralph-wiggum": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@th0rgal/ralph-wiggum/-/ralph-wiggum-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@th0rgal/ralph-wiggum/-/ralph-wiggum-1.2.2.tgz",
|
||||||
"integrity": "sha512-8Xe6luwnKTArT9eBzyAx1newz+InGTBm9pCQrG4yiO9oYVHC0WZN3f1sQIpKwQbbnKQ4YJXdoraaatd0B4yDcA==",
|
"integrity": "sha512-yhDydpF8mstC+1qTz2SQxcRrMHrwnHWflBRXEUfutPN9Pm9nYMYD04n6Km0Td9xBMjw+yf3vMwNZU91nRidO7Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ralph": "bin/ralph.js"
|
"ralph": "bin/ralph.js"
|
||||||
|
|||||||
@@ -152,14 +152,27 @@
|
|||||||
"errors": [
|
"errors": [
|
||||||
"\u001b[91m\u001b[1mError: \u001b[0mSession not found"
|
"\u001b[91m\u001b[1mError: \u001b[0mSession not found"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iteration": 10,
|
||||||
|
"startedAt": "2026-02-26T03:41:24.806Z",
|
||||||
|
"endedAt": "2026-02-26T03:43:03.164Z",
|
||||||
|
"durationMs": 91537,
|
||||||
|
"agent": "opencode",
|
||||||
|
"model": "",
|
||||||
|
"toolsUsed": {},
|
||||||
|
"filesModified": [
|
||||||
|
"openspec/changes/enhanced-allocation/"
|
||||||
|
],
|
||||||
|
"exitCode": 1,
|
||||||
|
"completionDetected": false,
|
||||||
|
"errors": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"totalDurationMs": 11830,
|
"totalDurationMs": 103367,
|
||||||
"struggleIndicators": {
|
"struggleIndicators": {
|
||||||
"repeatedErrors": {
|
"repeatedErrors": {},
|
||||||
"\u001b[91m\u001b[1mError: \u001b[0mSession not found": 10
|
"noProgressIterations": 0,
|
||||||
},
|
"shortIterations": 0
|
||||||
"noProgressIterations": 9,
|
|
||||||
"shortIterations": 10
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,51 @@ class AllocationController extends Controller
|
|||||||
|
|
||||||
$allocations = $query->get();
|
$allocations = $query->get();
|
||||||
|
|
||||||
|
// Compute allocation_indicator for each allocation based on project totals
|
||||||
|
$allocations->each(function ($allocation) {
|
||||||
|
$allocation->allocation_indicator = $this->computeAllocationIndicator(
|
||||||
|
$allocation->project_id,
|
||||||
|
$allocation->month
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return $this->wrapResource(AllocationResource::collection($allocations));
|
return $this->wrapResource(AllocationResource::collection($allocations));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute allocation indicator based on project totals.
|
||||||
|
*/
|
||||||
|
private function computeAllocationIndicator(string $projectId, string $month): string
|
||||||
|
{
|
||||||
|
// Convert month to date format if needed
|
||||||
|
$monthDate = strlen($month) === 7 ? $month . '-01' : $month;
|
||||||
|
|
||||||
|
// Get total allocated for this project in this month
|
||||||
|
$totalAllocated = Allocation::where('project_id', $projectId)
|
||||||
|
->where('month', $monthDate)
|
||||||
|
->sum('allocated_hours');
|
||||||
|
|
||||||
|
// Get project approved estimate
|
||||||
|
$project = \App\Models\Project::find($projectId);
|
||||||
|
$approvedEstimate = $project?->approved_estimate;
|
||||||
|
|
||||||
|
// Handle no estimate
|
||||||
|
if (! $approvedEstimate || $approvedEstimate <= 0) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
$percentage = ($totalAllocated / $approvedEstimate) * 100;
|
||||||
|
|
||||||
|
// Check in correct order: over first, then at capacity, then under
|
||||||
|
if ($percentage > 100) {
|
||||||
|
return 'red';
|
||||||
|
} elseif ($percentage >= 100) {
|
||||||
|
return 'green';
|
||||||
|
} else {
|
||||||
|
return 'yellow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new allocation
|
* Create a new allocation
|
||||||
*
|
*
|
||||||
@@ -89,7 +131,7 @@ class AllocationController extends Controller
|
|||||||
{
|
{
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'project_id' => 'required|uuid|exists:projects,id',
|
'project_id' => 'required|uuid|exists:projects,id',
|
||||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
'team_member_id' => 'nullable|uuid|exists:team_members,id',
|
||||||
'month' => 'required|date_format:Y-m',
|
'month' => 'required|date_format:Y-m',
|
||||||
'allocated_hours' => 'required|numeric|min:0',
|
'allocated_hours' => 'required|numeric|min:0',
|
||||||
]);
|
]);
|
||||||
@@ -101,12 +143,17 @@ class AllocationController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate against capacity and approved estimate
|
// Validate against capacity and approved estimate (skip for untracked)
|
||||||
$capacityValidation = $this->validationService->validateCapacity(
|
$teamMemberId = $request->input('team_member_id');
|
||||||
$request->input('team_member_id'),
|
$capacityValidation = ['valid' => true, 'warning' => null, 'utilization' => 0];
|
||||||
$request->input('month'),
|
|
||||||
(float) $request->input('allocated_hours')
|
if ($teamMemberId) {
|
||||||
);
|
$capacityValidation = $this->validationService->validateCapacity(
|
||||||
|
$teamMemberId,
|
||||||
|
$request->input('month'),
|
||||||
|
(float) $request->input('allocated_hours')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$estimateValidation = $this->validationService->validateApprovedEstimate(
|
$estimateValidation = $this->validationService->validateApprovedEstimate(
|
||||||
$request->input('project_id'),
|
$request->input('project_id'),
|
||||||
|
|||||||
151
backend/app/Http/Controllers/Api/ProjectMonthPlanController.php
Normal file
151
backend/app/Http/Controllers/Api/ProjectMonthPlanController.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\ProjectMonthPlanResource;
|
||||||
|
use App\Models\ProjectMonthPlan;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class ProjectMonthPlanController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/project-month-plans?year=2026
|
||||||
|
* Returns month-plan grid payload by project/month for the year.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$year = $request->query('year', date('Y'));
|
||||||
|
|
||||||
|
$startDate = "{$year}-01-01";
|
||||||
|
$endDate = "{$year}-12-01";
|
||||||
|
|
||||||
|
$plans = ProjectMonthPlan::whereBetween('month', [$startDate, $endDate])
|
||||||
|
->with('project')
|
||||||
|
->get()
|
||||||
|
->groupBy('project_id');
|
||||||
|
|
||||||
|
// Get all active projects for the year
|
||||||
|
$projects = \App\Models\Project::where('active', true)->get();
|
||||||
|
|
||||||
|
// Build grid payload
|
||||||
|
$data = $projects->map(function ($project) use ($plans, $year) {
|
||||||
|
$projectPlans = $plans->get($project->id, collect());
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($month = 1; $month <= 12; $month++) {
|
||||||
|
$monthDate = sprintf('%04d-%02d-01', $year, $month);
|
||||||
|
$plan = $projectPlans->firstWhere('month', $monthDate);
|
||||||
|
|
||||||
|
$months[$monthDate] = $plan
|
||||||
|
? [
|
||||||
|
'id' => $plan->id,
|
||||||
|
'planned_hours' => $plan->planned_hours,
|
||||||
|
'is_blank' => $plan->planned_hours === null,
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'project_name' => $project->title,
|
||||||
|
'approved_estimate' => $project->approved_estimate,
|
||||||
|
'months' => $months,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate reconciliation status for each project
|
||||||
|
$data->each(function (&$project) {
|
||||||
|
$project['plan_sum'] = collect($project['months'])
|
||||||
|
->filter(fn ($m) => $m !== null && $m['planned_hours'] !== null)
|
||||||
|
->sum('planned_hours');
|
||||||
|
|
||||||
|
$approved = $project['approved_estimate'] ?? 0;
|
||||||
|
if ($approved > 0) {
|
||||||
|
if ($project['plan_sum'] > $approved) {
|
||||||
|
$project['reconciliation_status'] = 'OVER';
|
||||||
|
} elseif ($project['plan_sum'] < $approved) {
|
||||||
|
$project['reconciliation_status'] = 'UNDER';
|
||||||
|
} elseif ($project['plan_sum'] == $approved) {
|
||||||
|
$project['reconciliation_status'] = 'MATCH';
|
||||||
|
} else {
|
||||||
|
$project['reconciliation_status'] = 'UNDER';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$project['reconciliation_status'] = 'UNDER'; // No estimate = under
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'year' => (int) $year,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/project-month-plans/bulk
|
||||||
|
* Bulk upsert month plan cells.
|
||||||
|
*/
|
||||||
|
public function bulkUpdate(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'year' => 'required|integer|min:2020|max:2100',
|
||||||
|
'items' => 'required|array',
|
||||||
|
'items.*.project_id' => 'required|uuid|exists:projects,id',
|
||||||
|
'items.*.month' => 'required|date_format:Y-m',
|
||||||
|
'items.*.planned_hours' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = $request->input('year');
|
||||||
|
$items = $request->input('items');
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$cleared = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$projectId = $item['project_id'];
|
||||||
|
$month = $item['month'] . '-01'; // Convert YYYY-MM to YYYY-MM-01
|
||||||
|
$plannedHours = $item['planned_hours']; // Can be null to clear
|
||||||
|
|
||||||
|
$plan = ProjectMonthPlan::firstOrNew([
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'month' => $month,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($plannedHours === null && $plan->exists) {
|
||||||
|
// Clear semantics: delete the row to represent blank
|
||||||
|
$plan->delete();
|
||||||
|
$cleared++;
|
||||||
|
} elseif ($plannedHours !== null) {
|
||||||
|
$plan->planned_hours = $plannedHours;
|
||||||
|
$plan->save();
|
||||||
|
|
||||||
|
if (!$plan->wasRecentlyCreated) {
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Bulk update complete',
|
||||||
|
'summary' => [
|
||||||
|
'created' => $created,
|
||||||
|
'updated' => $updated,
|
||||||
|
'cleared' => $cleared,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ class AllocationResource extends BaseResource
|
|||||||
'team_member_id' => $this->team_member_id,
|
'team_member_id' => $this->team_member_id,
|
||||||
'month' => $this->month?->format('Y-m'),
|
'month' => $this->month?->format('Y-m'),
|
||||||
'allocated_hours' => $this->formatDecimal($this->allocated_hours),
|
'allocated_hours' => $this->formatDecimal($this->allocated_hours),
|
||||||
|
'allocation_indicator' => $this->allocation_indicator ?? 'gray',
|
||||||
'created_at' => $this->formatDate($this->created_at),
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
'updated_at' => $this->formatDate($this->updated_at),
|
'updated_at' => $this->formatDate($this->updated_at),
|
||||||
'project' => $this->whenLoaded('project', fn () => new ProjectResource($this->project)),
|
'project' => $this->whenLoaded('project', fn () => new ProjectResource($this->project)),
|
||||||
|
|||||||
21
backend/app/Http/Resources/ProjectMonthPlanResource.php
Normal file
21
backend/app/Http/Resources/ProjectMonthPlanResource.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ProjectMonthPlanResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'month' => $this->month?->format('Y-m'),
|
||||||
|
'planned_hours' => $this->formatDecimal($this->planned_hours),
|
||||||
|
'is_blank' => $this->planned_hours === null,
|
||||||
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
|
'updated_at' => $this->formatDate($this->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/app/Models/ProjectMonthPlan.php
Normal file
50
backend/app/Models/ProjectMonthPlan.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ProjectMonthPlan extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $table = 'project_month_plans';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'project_id',
|
||||||
|
'month',
|
||||||
|
'planned_hours',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'month' => 'date:Y-m-01',
|
||||||
|
'planned_hours' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the project this plan belongs to.
|
||||||
|
*/
|
||||||
|
public function project(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this plan cell is blank (unset).
|
||||||
|
*/
|
||||||
|
public function isBlank(): bool
|
||||||
|
{
|
||||||
|
return $this->planned_hours === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get planned hours or 0 for variance calculations.
|
||||||
|
* Blank plan is treated as 0 for allocation variance.
|
||||||
|
*/
|
||||||
|
public function getPlannedHoursForVariance(): float
|
||||||
|
{
|
||||||
|
return (float) ($this->planned_hours ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('allocations', function (Blueprint $table) {
|
||||||
|
$table->uuid('team_member_id')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('allocations', function (Blueprint $table) {
|
||||||
|
$table->uuid('team_member_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('project_month_plans', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->uuid('project_id');
|
||||||
|
$table->date('month'); // First day of month
|
||||||
|
$table->decimal('planned_hours', 10, 2)->nullable(); // null = blank/unset
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Unique constraint: one plan per project per month
|
||||||
|
$table->unique(['project_id', 'month']);
|
||||||
|
|
||||||
|
// Foreign key
|
||||||
|
$table->foreign('project_id')
|
||||||
|
->references('id')
|
||||||
|
->on('projects')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('project_month_plans');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController;
|
|||||||
use App\Http\Controllers\Api\CapacityController;
|
use App\Http\Controllers\Api\CapacityController;
|
||||||
use App\Http\Controllers\Api\HolidayController;
|
use App\Http\Controllers\Api\HolidayController;
|
||||||
use App\Http\Controllers\Api\ProjectController;
|
use App\Http\Controllers\Api\ProjectController;
|
||||||
|
use App\Http\Controllers\Api\ProjectMonthPlanController;
|
||||||
use App\Http\Controllers\Api\PtoController;
|
use App\Http\Controllers\Api\PtoController;
|
||||||
use App\Http\Controllers\Api\TeamMemberController;
|
use App\Http\Controllers\Api\TeamMemberController;
|
||||||
use App\Http\Middleware\JwtAuth;
|
use App\Http\Middleware\JwtAuth;
|
||||||
@@ -42,6 +43,10 @@ Route::middleware(JwtAuth::class)->group(function () {
|
|||||||
Route::put('projects/{project}/estimate', [ProjectController::class, 'setEstimate']);
|
Route::put('projects/{project}/estimate', [ProjectController::class, 'setEstimate']);
|
||||||
Route::put('projects/{project}/forecast', [ProjectController::class, 'setForecast']);
|
Route::put('projects/{project}/forecast', [ProjectController::class, 'setForecast']);
|
||||||
|
|
||||||
|
// Project Month Plans
|
||||||
|
Route::get('/project-month-plans', [ProjectMonthPlanController::class, 'index']);
|
||||||
|
Route::put('/project-month-plans/bulk', [ProjectMonthPlanController::class, 'bulkUpdate']);
|
||||||
|
|
||||||
// Capacity
|
// Capacity
|
||||||
Route::get('/capacity', [CapacityController::class, 'individual']);
|
Route::get('/capacity', [CapacityController::class, 'individual']);
|
||||||
Route::get('/capacity/team', [CapacityController::class, 'team']);
|
Route::get('/capacity/team', [CapacityController::class, 'team']);
|
||||||
|
|||||||
@@ -257,4 +257,161 @@ class AllocationTest extends TestCase
|
|||||||
|
|
||||||
$response->assertStatus(404);
|
$response->assertStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Allocation Indicator Tests =====
|
||||||
|
// 1.1 API test: GET /api/allocations returns allocation_indicator per item
|
||||||
|
public function test_get_allocations_returns_allocation_indicator()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||||
|
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||||
|
|
||||||
|
Allocation::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'month' => '2026-02-01',
|
||||||
|
'allocated_hours' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->getJson('/api/allocations?month=2026-02');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'*' => ['allocation_indicator'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.2 API test: allocation_indicator is green when >= 100%
|
||||||
|
public function test_allocation_indicator_is_green_when_full()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||||
|
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||||
|
|
||||||
|
Allocation::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'month' => '2026-02-01',
|
||||||
|
'allocated_hours' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->getJson('/api/allocations?month=2026-02');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.allocation_indicator', 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.3 API test: allocation_indicator is yellow when < 100%
|
||||||
|
public function test_allocation_indicator_is_yellow_when_under()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||||
|
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||||
|
|
||||||
|
Allocation::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'month' => '2026-02-01',
|
||||||
|
'allocated_hours' => 80,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->getJson('/api/allocations?month=2026-02');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.allocation_indicator', 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.4 API test: allocation_indicator is red when > 100%
|
||||||
|
public function test_allocation_indicator_is_red_when_over()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||||
|
$project = Project::factory()->create(['approved_estimate' => 100]);
|
||||||
|
|
||||||
|
Allocation::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'month' => '2026-02-01',
|
||||||
|
'allocated_hours' => 120,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->getJson('/api/allocations?month=2026-02');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.allocation_indicator', 'red');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5 API test: allocation_indicator is gray when no approved_estimate
|
||||||
|
public function test_allocation_indicator_is_gray_when_no_estimate()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||||
|
$project = Project::factory()->create(['approved_estimate' => null]);
|
||||||
|
|
||||||
|
Allocation::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'month' => '2026-02-01',
|
||||||
|
'allocated_hours' => 40,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->getJson('/api/allocations?month=2026-02');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.allocation_indicator', 'gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Untracked Allocation Tests =====
|
||||||
|
// 2.1 API test: POST /api/allocations accepts null team_member_id
|
||||||
|
public function test_can_create_allocation_with_null_team_member()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->postJson('/api/allocations', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => null,
|
||||||
|
'month' => '2026-02',
|
||||||
|
'allocated_hours' => 40,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonPath('data.team_member_id', null);
|
||||||
|
$response->assertJsonPath('data.team_member', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 API test: GET /api/allocations returns null team_member
|
||||||
|
public function test_get_allocations_returns_null_team_member()
|
||||||
|
{
|
||||||
|
$token = $this->loginAsManager();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
// Create untracked allocation
|
||||||
|
Allocation::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'team_member_id' => null,
|
||||||
|
'month' => '2026-02-01',
|
||||||
|
'allocated_hours' => 40,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
|
->getJson('/api/allocations?month=2026-02');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.team_member_id', null);
|
||||||
|
$response->assertJsonPath('data.0.team_member', null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,13 @@ class AllocationPolicyTest extends TestCase
|
|||||||
|
|
||||||
$this->assertFalse($this->policy->create($developer));
|
$this->assertFalse($this->policy->create($developer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.3 Unit test: AllocationPolicy allows untracked allocation
|
||||||
|
public function test_manager_can_create_untracked_allocations()
|
||||||
|
{
|
||||||
|
$manager = User::factory()->create(['role' => 'manager']);
|
||||||
|
|
||||||
|
// Policy should allow creating allocations (untracked is just null team_member_id)
|
||||||
|
$this->assertTrue($this->policy->create($manager));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -951,4 +951,69 @@ frontend/src/lib/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Allocation Fidelity Decisions
|
||||||
|
|
||||||
|
**Date:** February 26, 2026
|
||||||
|
**Context:** During enhanced-allocation discovery, we found drift between intended planning semantics and current implementation assumptions. These decisions lock the canonical model to avoid rework.
|
||||||
|
|
||||||
|
### D-ALOC-01: Three-Surface Planning Chain Is Canonical
|
||||||
|
|
||||||
|
**Decision:** Allocation planning uses a strict layered model:
|
||||||
|
1. **Projects surface**: `approved_estimate` is lifecycle total effort cap
|
||||||
|
2. **Project-Month Plan surface**: manager-entered monthly plan hours per project
|
||||||
|
3. **Project-Resource Allocation surface**: month execution by team member (and untracked)
|
||||||
|
|
||||||
|
**Outcome:** Reporting derives from this chain and must not bypass month-plan semantics.
|
||||||
|
|
||||||
|
### D-ALOC-02: Monthly Plan Is Explicit, Not Derived
|
||||||
|
|
||||||
|
**Decision:** Do **not** derive monthly budget from `approved_estimate / 12`.
|
||||||
|
|
||||||
|
**Rationale:** Delivery phasing depends on external dependencies, customer timelines, and team sequencing; equal split is misleading.
|
||||||
|
|
||||||
|
**Example:** A 3000h project may be planned as Jan 1200, Feb 1400, Mar 400.
|
||||||
|
|
||||||
|
### D-ALOC-03: Reconciliation Rule for Monthly Plan vs Lifecycle Total
|
||||||
|
|
||||||
|
**Decision:** Sum of all project month-plan values is reconciled against project `approved_estimate`:
|
||||||
|
- `sum > approved_estimate` -> **OVER**
|
||||||
|
- `sum < approved_estimate` -> **UNDER**
|
||||||
|
- `sum == approved_estimate` -> **MATCH** (neutral)
|
||||||
|
|
||||||
|
**Visual policy:** emphasize over/under; match remains neutral.
|
||||||
|
|
||||||
|
### D-ALOC-04: Blank Month Semantics
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- In planning UI, blank month cells remain blank (not auto-filled with 0)
|
||||||
|
- In allocation variance math, blank plan is treated as planned `0`
|
||||||
|
- Allocation is allowed even when month plan is blank
|
||||||
|
|
||||||
|
**Rationale:** Preserve manager intent in planning UI while keeping deterministic execution math.
|
||||||
|
|
||||||
|
### D-ALOC-05: Grid-First Editing Policy
|
||||||
|
|
||||||
|
**Decision:** Use grid entry as primary interaction for planning and allocation surfaces.
|
||||||
|
|
||||||
|
**Policy:** Modal flow is not primary for single-cell edits.
|
||||||
|
|
||||||
|
### D-ALOC-06: Minimal Status Visual Language
|
||||||
|
|
||||||
|
**Decision:** Keep status visuals minimal:
|
||||||
|
- **OVER** = red
|
||||||
|
- **UNDER** = amber
|
||||||
|
- **MATCH/SETTLED** = neutral (no extra green spread)
|
||||||
|
|
||||||
|
**Guideline:** Status emphasis belongs on row/column summary edges, not heavy color in every interior cell.
|
||||||
|
|
||||||
|
### D-ALOC-07: `forecasted_effort` Deprecated for This Workflow
|
||||||
|
|
||||||
|
**Decision:** Stop using `projects.forecasted_effort` as planning source for this capability.
|
||||||
|
|
||||||
|
**Status:** Development phase; safe to deprecate with no production migration burden.
|
||||||
|
|
||||||
|
**New source of truth:** project-month planning dataset for monthly intent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*End of Decision Log*
|
*End of Decision Log*
|
||||||
|
|||||||
@@ -138,6 +138,25 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const date of daysInMonth) {
|
||||||
|
const key = getCellKey(member.id, date);
|
||||||
|
if (cells.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wknd = isWeekend(date);
|
||||||
|
const hol = holidayDates.has(date);
|
||||||
|
const defaultValue: NormalizedToken = hol
|
||||||
|
? { rawToken: 'H', numericValue: 0, valid: true }
|
||||||
|
: wknd
|
||||||
|
? { rawToken: 'O', numericValue: 0, valid: true }
|
||||||
|
: { rawToken: '1', numericValue: 1, valid: true };
|
||||||
|
cells.set(key, {
|
||||||
|
memberId: member.id,
|
||||||
|
date,
|
||||||
|
originalValue: defaultValue.numericValue,
|
||||||
|
currentValue: defaultValue
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { api } from './api';
|
|||||||
export interface Allocation {
|
export interface Allocation {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
team_member_id: string;
|
team_member_id: string | null;
|
||||||
month: string;
|
month: string;
|
||||||
allocated_hours: string;
|
allocated_hours: string;
|
||||||
|
allocation_indicator?: 'green' | 'yellow' | 'red' | 'gray';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
project?: {
|
project?: {
|
||||||
@@ -22,7 +23,7 @@ export interface Allocation {
|
|||||||
team_member?: {
|
team_member?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAllocationRequest {
|
export interface CreateAllocationRequest {
|
||||||
|
|||||||
53
frontend/src/lib/services/projectMonthPlanService.ts
Normal file
53
frontend/src/lib/services/projectMonthPlanService.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { api } from '$lib/services/api';
|
||||||
|
|
||||||
|
export interface ProjectMonthPlan {
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
approved_estimate: number;
|
||||||
|
months: Record<string, {
|
||||||
|
id: string;
|
||||||
|
planned_hours: number | null;
|
||||||
|
is_blank: boolean;
|
||||||
|
} | null>;
|
||||||
|
plan_sum: number;
|
||||||
|
reconciliation_status: 'OVER' | 'UNDER' | 'MATCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMonthPlansResponse {
|
||||||
|
data: ProjectMonthPlan[];
|
||||||
|
meta: {
|
||||||
|
year: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkUpdateRequest {
|
||||||
|
year: number;
|
||||||
|
items: Array<{
|
||||||
|
project_id: string;
|
||||||
|
month: string;
|
||||||
|
planned_hours: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkUpdateResponse {
|
||||||
|
message: string;
|
||||||
|
summary: {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
cleared: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectMonthPlanService {
|
||||||
|
async getPlans(year: number): Promise<ProjectMonthPlansResponse> {
|
||||||
|
const response = await api.get<ProjectMonthPlansResponse>(`/project-month-plans?year=${year}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdate(request: BulkUpdateRequest): Promise<BulkUpdateResponse> {
|
||||||
|
const response = await api.put<BulkUpdateResponse>('/project-month-plans/bulk', request);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectMonthPlanService = new ProjectMonthPlanService();
|
||||||
@@ -105,6 +105,23 @@
|
|||||||
return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
|
return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectBudget(projectId: string): number {
|
||||||
|
const project = projects.find(p => p.id === projectId);
|
||||||
|
if (!project?.approved_estimate) return 0;
|
||||||
|
// Monthly budget = approved_estimate / 12
|
||||||
|
return Math.round(Number(project.approved_estimate) / 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectBudgetStatus(projectId: string): 'over' | 'under' | 'ok' {
|
||||||
|
const budget = getProjectBudget(projectId);
|
||||||
|
const allocated = getProjectRowTotal(projectId);
|
||||||
|
if (budget === 0) return 'under';
|
||||||
|
const percentage = (allocated / budget) * 100;
|
||||||
|
if (percentage > 100) return 'over';
|
||||||
|
if (percentage >= 100) return 'ok';
|
||||||
|
return 'under';
|
||||||
|
}
|
||||||
|
|
||||||
function handleCellClick(projectId: string, teamMemberId: string) {
|
function handleCellClick(projectId: string, teamMemberId: string) {
|
||||||
const existing = getAllocation(projectId, teamMemberId);
|
const existing = getAllocation(projectId, teamMemberId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -252,7 +269,11 @@
|
|||||||
onclick={() => handleCellClick(project.id, member.id)}
|
onclick={() => handleCellClick(project.id, member.id)}
|
||||||
>
|
>
|
||||||
{#if allocation}
|
{#if allocation}
|
||||||
<span class="badge badge-primary badge-sm">
|
{@const indicator = allocation.allocation_indicator || 'gray'}
|
||||||
|
<span
|
||||||
|
class="badge badge-sm {indicator === 'green' ? 'badge-success' : indicator === 'yellow' ? 'badge-warning' : indicator === 'red' ? 'badge-error' : 'badge-ghost'}"
|
||||||
|
title="{indicator === 'green' ? 'Fully allocated' : indicator === 'yellow' ? 'Under allocated' : indicator === 'red' ? 'Over allocated' : 'No budget'}"
|
||||||
|
>
|
||||||
{allocation.allocated_hours}h
|
{allocation.allocated_hours}h
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -266,6 +287,34 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
|
<!-- Monthly Budget Section -->
|
||||||
|
<tfoot>
|
||||||
|
<tr class="bg-base-200/50">
|
||||||
|
<td class="font-bold">Monthly Budget</td>
|
||||||
|
{#each teamMembers as member}
|
||||||
|
<td class="text-center">-</td>
|
||||||
|
{/each}
|
||||||
|
<td class="text-center">
|
||||||
|
{Math.round(projects.reduce((sum, p) => sum + (getProjectBudget(p.id) || 0), 0))}h
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-base-200/50">
|
||||||
|
<td class="font-bold">Status</td>
|
||||||
|
{#each teamMembers as member}
|
||||||
|
<td class="text-center">-</td>
|
||||||
|
{/each}
|
||||||
|
<td class="text-center">
|
||||||
|
{#if getProjectTotal() > projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)}
|
||||||
|
<span class="badge badge-error badge-sm">OVER</span>
|
||||||
|
{:else if getProjectTotal() < projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)}
|
||||||
|
<span class="badge badge-warning badge-sm">UNDER</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-success badge-sm">OK</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="font-bold bg-base-200">
|
<tr class="font-bold bg-base-200">
|
||||||
<td class="sticky left-0 bg-base-200 z-10">Total</td>
|
<td class="sticky left-0 bg-base-200 z-10">Total</td>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
// Form state
|
// Form state
|
||||||
let formData = $state<CreateTeamMemberRequest>({
|
let formData = $state<CreateTeamMemberRequest>({
|
||||||
name: '',
|
name: '',
|
||||||
role_id: 0,
|
role_id: 13, // Default to first role
|
||||||
hourly_rate: 0,
|
hourly_rate: 0,
|
||||||
active: true
|
active: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,16 +71,15 @@
|
|||||||
|
|
||||||
async function loadRoles() {
|
async function loadRoles() {
|
||||||
try {
|
try {
|
||||||
// For now, we'll use hardcoded roles matching the backend seeder
|
// Role IDs from database seeder
|
||||||
// In a real app, you'd fetch this from an API endpoint
|
|
||||||
roles = [
|
roles = [
|
||||||
{ id: 1, name: 'Frontend Developer' },
|
{ id: 13, name: 'Frontend Dev' },
|
||||||
{ id: 2, name: 'Backend Developer' },
|
{ id: 14, name: 'Backend Dev' },
|
||||||
{ id: 3, name: 'QA Engineer' },
|
{ id: 15, name: 'QA' },
|
||||||
{ id: 4, name: 'DevOps Engineer' },
|
{ id: 16, name: 'DevOps' },
|
||||||
{ id: 5, name: 'UX Designer' },
|
{ id: 17, name: 'UX' },
|
||||||
{ id: 6, name: 'Project Manager' },
|
{ id: 18, name: 'PM' },
|
||||||
{ id: 7, name: 'Architect' }
|
{ id: 19, name: 'Architect' }
|
||||||
];
|
];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading roles:', err);
|
console.error('Error loading roles:', err);
|
||||||
|
|||||||
2
openspec/changes/enhanced-allocation/.openspec.yaml
Normal file
2
openspec/changes/enhanced-allocation/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-25
|
||||||
239
openspec/changes/enhanced-allocation/design.md
Normal file
239
openspec/changes/enhanced-allocation/design.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Design: Enhanced Allocation Fidelity
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The allocation experience must reflect a strict planning-to-execution model. Current implementation drift introduced incorrect month semantics (`approved_estimate / 12`), mixed concern UIs, and ambiguous status signaling.
|
||||||
|
|
||||||
|
This design aligns implementation with the validated operating model:
|
||||||
|
|
||||||
|
1. **Projects (Lifecycle)**: `approved_estimate` is the lifecycle total.
|
||||||
|
2. **Project-Month Plan (Intent)**: manager explicitly sets monthly planned effort per project.
|
||||||
|
3. **Project-Resource Allocation (Execution)**: for selected month, manager allocates effort by team member.
|
||||||
|
4. **Reporting (Outcome)**: historical/current/future insights built from these three layers.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Implement explicit project-month planning as first-class data.
|
||||||
|
2. Reframe allocation matrix as execution grid with month-plan and capacity variance.
|
||||||
|
3. Support untracked effort (`team_member_id = null`) end-to-end.
|
||||||
|
4. Implement partial bulk success for allocations.
|
||||||
|
5. Keep visual signaling minimal and decision-focused.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Real-time collaboration/websockets.
|
||||||
|
- Notification systems.
|
||||||
|
- Export workflows.
|
||||||
|
- Cosmetic UI experimentation beyond fidelity requirements.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Month Plan Is Explicit (No Derived Monthly Budget)
|
||||||
|
|
||||||
|
- **Decision**: monthly plan is manager-entered and persisted.
|
||||||
|
- **Rejected**: deriving monthly budget from `approved_estimate / 12`.
|
||||||
|
- **Reason**: project phasing is dependency-driven, not uniform.
|
||||||
|
|
||||||
|
### D2: Reconciliation vs Lifecycle Total
|
||||||
|
|
||||||
|
For each project:
|
||||||
|
- `plan_sum = sum(non-null planned_hours across months)`
|
||||||
|
- compare against `approved_estimate`
|
||||||
|
- `plan_sum > approved_estimate` -> `OVER`
|
||||||
|
- `plan_sum < approved_estimate` -> `UNDER`
|
||||||
|
- `plan_sum == approved_estimate` -> `MATCH`
|
||||||
|
|
||||||
|
Tolerance for MATCH should use decimal-safe comparison in implementation.
|
||||||
|
|
||||||
|
### D3: Blank Month Semantics
|
||||||
|
|
||||||
|
- Planning grid cell can be blank (`null`) and remains visually blank.
|
||||||
|
- Allocation variance treats missing month plan as `0`.
|
||||||
|
- Allocation is allowed when month plan is blank.
|
||||||
|
|
||||||
|
### D4: Grid-First Editing
|
||||||
|
|
||||||
|
- Project-month planning: inline grid editing primary.
|
||||||
|
- Allocation matrix: inline grid editing primary.
|
||||||
|
- Modal is optional/fallback, not primary path.
|
||||||
|
|
||||||
|
### D5: Untracked Semantics
|
||||||
|
|
||||||
|
- Untracked allocation is represented by `team_member_id = null`.
|
||||||
|
- Included in project totals and grand totals.
|
||||||
|
- Excluded from team member capacity/utilization calculations.
|
||||||
|
|
||||||
|
### D6: Visual Status Policy
|
||||||
|
|
||||||
|
- Over = red
|
||||||
|
- Under = amber
|
||||||
|
- Match/settled = neutral
|
||||||
|
|
||||||
|
Status emphasis belongs on row/column summary edges and variance cells; avoid noisy color in every interior cell.
|
||||||
|
|
||||||
|
### D7: Forecasted Effort Deprecation
|
||||||
|
|
||||||
|
- `projects.forecasted_effort` is deprecated for this planning workflow.
|
||||||
|
- New monthly planning source of truth is explicit project-month plan data.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New Table: `project_month_plans`
|
||||||
|
|
||||||
|
Recommended schema:
|
||||||
|
- `id` (uuid)
|
||||||
|
- `project_id` (uuid FK -> projects.id)
|
||||||
|
- `month` (date, normalized to first day of month)
|
||||||
|
- `planned_hours` (decimal, nullable)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
- unique index on (`project_id`, `month`)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `planned_hours = null` means blank/unset plan.
|
||||||
|
- If storage policy prefers non-null planned hours, clear operation must still preserve blank UI semantics via delete row strategy.
|
||||||
|
|
||||||
|
### Existing Tables
|
||||||
|
|
||||||
|
- `projects.approved_estimate`: lifecycle cap (unchanged semantics)
|
||||||
|
- `allocations.team_member_id`: nullable to support untracked
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
### Project-Month Plan APIs
|
||||||
|
|
||||||
|
1. `GET /api/project-month-plans?year=YYYY`
|
||||||
|
- returns month-plan grid payload by project/month
|
||||||
|
- includes reconciliation status per project
|
||||||
|
|
||||||
|
2. `PUT /api/project-month-plans/bulk`
|
||||||
|
- accepts multi-cell upsert payload
|
||||||
|
- supports setting value and clearing value (blank)
|
||||||
|
- returns updated rows + reconciliation results
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"items": [
|
||||||
|
{ "project_id": "...", "month": "2026-01", "planned_hours": 1200 },
|
||||||
|
{ "project_id": "...", "month": "2026-02", "planned_hours": 1400 },
|
||||||
|
{ "project_id": "...", "month": "2026-03", "planned_hours": 400 },
|
||||||
|
{ "project_id": "...", "month": "2026-04", "planned_hours": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Allocation APIs
|
||||||
|
|
||||||
|
- Keep `GET /api/allocations?month=YYYY-MM` and CRUD endpoints.
|
||||||
|
- Enrich response with month-context variance metadata needed for row/column summary rendering.
|
||||||
|
- `POST /api/allocations/bulk` must support partial success.
|
||||||
|
|
||||||
|
Partial bulk response contract:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [{ "index": 0, "id": "...", "status": "created" }],
|
||||||
|
"failed": [{ "index": 1, "errors": { "allocated_hours": ["..."] } }],
|
||||||
|
"summary": { "created": 1, "failed": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Computation Rules
|
||||||
|
|
||||||
|
### Lifecycle Reconciliation (Project-Month Plan Surface)
|
||||||
|
|
||||||
|
For each project:
|
||||||
|
- `plan_sum = SUM(planned_hours where month in planning horizon and value != null)`
|
||||||
|
- `status = OVER | UNDER | MATCH` compared to `approved_estimate`
|
||||||
|
|
||||||
|
### Allocation Row Variance (Execution Surface)
|
||||||
|
|
||||||
|
For selected month and project:
|
||||||
|
- `allocated_total = SUM(allocation.allocated_hours for project/month including untracked)`
|
||||||
|
- `planned_month = planned_hours(project, month) or 0 if missing`
|
||||||
|
- `row_variance = allocated_total - planned_month`
|
||||||
|
- status from row_variance sign
|
||||||
|
|
||||||
|
### Allocation Column Variance (Execution Surface)
|
||||||
|
|
||||||
|
For selected month and team member:
|
||||||
|
- `member_allocated = SUM(allocation.allocated_hours for member/month)`
|
||||||
|
- `member_capacity = computed month capacity`
|
||||||
|
- `col_variance = member_allocated - member_capacity`
|
||||||
|
- exclude `team_member_id = null` rows from this computation
|
||||||
|
|
||||||
|
## UX Specification (Implementation-Oriented)
|
||||||
|
|
||||||
|
### Surface A: Project-Month Plan Grid
|
||||||
|
|
||||||
|
- Rows: projects
|
||||||
|
- Columns: months
|
||||||
|
- Right edge: row total + reconciliation status
|
||||||
|
- Bottom edge: optional month totals for planning visibility
|
||||||
|
- Cell editing: inline, keyboard-first
|
||||||
|
- Blank cells remain blank visually
|
||||||
|
- Color: only summary statuses (red/amber/neutral)
|
||||||
|
|
||||||
|
### Surface B: Project-Resource Allocation Grid (Selected Month)
|
||||||
|
|
||||||
|
- Rows: projects
|
||||||
|
- Columns: team members + untracked
|
||||||
|
- Right edge: project row total + row variance status vs planned month
|
||||||
|
- Bottom edge: member totals + capacity variance status
|
||||||
|
- Primary editing: inline cell edit (modal not primary)
|
||||||
|
- Color: red/amber highlights only for over/under summary states
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Do not rely on color alone; include text labels (OVER/UNDER).
|
||||||
|
- Maintain focus and keyboard navigation in both grids.
|
||||||
|
- Preserve contrast and readable status labels.
|
||||||
|
|
||||||
|
## Risk Register
|
||||||
|
|
||||||
|
1. **Semantic regression risk**: reintroduction of derived monthly budget logic.
|
||||||
|
- Mitigation: explicit tests and prohibition in specs.
|
||||||
|
|
||||||
|
2. **Blank vs zero confusion**.
|
||||||
|
- Mitigation: explicit API contract and UI behavior tests.
|
||||||
|
|
||||||
|
3. **Untracked leakage into capacity metrics**.
|
||||||
|
- Mitigation: query filters and dedicated tests.
|
||||||
|
|
||||||
|
4. **Bulk partial side effects**.
|
||||||
|
- Mitigation: per-item validation and clear response contract.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit
|
||||||
|
- Lifecycle reconciliation calculator
|
||||||
|
- Row/column variance calculators
|
||||||
|
- Blank-plan-as-zero execution logic
|
||||||
|
|
||||||
|
### Feature/API
|
||||||
|
- Project-month plan bulk upsert and clear behavior
|
||||||
|
- Reconciliation status correctness
|
||||||
|
- Allocation month variance data correctness
|
||||||
|
- Untracked include/exclude behavior
|
||||||
|
- Partial bulk success semantics
|
||||||
|
|
||||||
|
### Frontend Grid Tests
|
||||||
|
- Inline edit commit/cancel/clear
|
||||||
|
- Keyboard navigation
|
||||||
|
- Summary status placement and labels
|
||||||
|
- Blank visual state preservation
|
||||||
|
|
||||||
|
### E2E
|
||||||
|
- Create lifecycle estimate project
|
||||||
|
- Enter monthly plan across months and verify reconciliation
|
||||||
|
- Execute month allocations and verify row/column variances
|
||||||
|
- Validate untracked behavior
|
||||||
|
- Validate partial bulk success handling
|
||||||
|
|
||||||
|
## Migration & Rollout Notes
|
||||||
|
|
||||||
|
1. Introduce project-month plan model and APIs.
|
||||||
|
2. Remove derived budget rendering in allocation UI.
|
||||||
|
3. Wire allocation UI to explicit month plan for variance.
|
||||||
|
4. Deprecate `forecasted_effort` usage in this workflow path.
|
||||||
|
5. Keep backward compatibility for existing allocation records.
|
||||||
130
openspec/changes/enhanced-allocation/docs/reporting-api.md
Normal file
130
openspec/changes/enhanced-allocation/docs/reporting-api.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Reporting API Contract
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Reporting API provides aggregated resource planning and execution data for management analysis. It supports three view types (did/is/will) inferred from date ranges.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/reports/allocations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Requires Bearer token authentication.
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `start_date` | string (YYYY-MM-DD) | Yes | Start of reporting period |
|
||||||
|
| `end_date` | string (YYYY-MM-DD) | Yes | End of reporting period |
|
||||||
|
| `project_ids[]` | array of UUIDs | No | Filter by specific projects |
|
||||||
|
| `member_ids[]` | array of UUIDs | No | Filter by specific team members |
|
||||||
|
|
||||||
|
## View Types
|
||||||
|
|
||||||
|
The `view_type` is inferred from the date range:
|
||||||
|
|
||||||
|
| View Type | Date Range | Description |
|
||||||
|
|-----------|------------|-------------|
|
||||||
|
| `did` | All dates in past months | Historical execution analysis |
|
||||||
|
| `is` | Includes current month | Active planning vs execution |
|
||||||
|
| `will` | All dates in future months | Forecast and capacity planning |
|
||||||
|
|
||||||
|
## Response Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"period": {
|
||||||
|
"start": "2026-01-01",
|
||||||
|
"end": "2026-03-31"
|
||||||
|
},
|
||||||
|
"view_type": "will",
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"code": "PROJ-001",
|
||||||
|
"title": "Project Name",
|
||||||
|
"approved_estimate": 3000.0,
|
||||||
|
"lifecycle_status": "MATCH",
|
||||||
|
"plan_sum": 2600.0,
|
||||||
|
"period_planned": 2600.0,
|
||||||
|
"period_allocated": 2800.0,
|
||||||
|
"period_variance": 200.0,
|
||||||
|
"period_status": "OVER",
|
||||||
|
"months": [
|
||||||
|
{
|
||||||
|
"month": "2026-01",
|
||||||
|
"planned_hours": 1200.0,
|
||||||
|
"is_blank": false,
|
||||||
|
"allocated_hours": 1300.0,
|
||||||
|
"variance": 100.0,
|
||||||
|
"status": "OVER"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Team Member",
|
||||||
|
"period_allocated": 400.0,
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"project_id": "uuid",
|
||||||
|
"project_code": "PROJ-001",
|
||||||
|
"project_title": "Project Name",
|
||||||
|
"total_hours": 400.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aggregates": {
|
||||||
|
"total_planned": 2600.0,
|
||||||
|
"total_allocated": 2800.0,
|
||||||
|
"total_variance": 200.0,
|
||||||
|
"status": "OVER"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status Values
|
||||||
|
|
||||||
|
| Status | Meaning | Visual |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| `OVER` | Allocated > Planned/Capacity | 🔴 Red |
|
||||||
|
| `UNDER` | Allocated < Planned/Capacity | 🟡 Amber |
|
||||||
|
| `MATCH` | Allocated = Planned/Capacity | 🟢 Green/Neutral |
|
||||||
|
|
||||||
|
## Blank vs Zero Distinction
|
||||||
|
|
||||||
|
- **Blank plan** (`is_blank: true`): No plan entry exists for the month
|
||||||
|
- `planned_hours`: `null`
|
||||||
|
- Used for variance: treated as `0`
|
||||||
|
|
||||||
|
- **Explicit zero** (`is_blank: false`, `planned_hours: 0`): Plan was explicitly set to zero
|
||||||
|
- `planned_hours`: `0`
|
||||||
|
- Distinct from blank for reporting accuracy
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
This endpoint combines data from:
|
||||||
|
|
||||||
|
- `ProjectMonthPlan` - Monthly planning data
|
||||||
|
- `Allocation` - Resource allocations
|
||||||
|
- `Project` - Project metadata (approved_estimate)
|
||||||
|
- `TeamMember` - Team member capacity
|
||||||
|
|
||||||
|
Calculations use:
|
||||||
|
- `ReconciliationCalculator` - Lifecycle plan vs estimate
|
||||||
|
- `VarianceCalculator` - Period and monthly variances
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|--------|-----------|
|
||||||
|
| 422 | Missing or invalid date parameters |
|
||||||
|
| 422 | `end_date` before `start_date` |
|
||||||
|
| 401 | Unauthorized (missing/invalid token) |
|
||||||
89
openspec/changes/enhanced-allocation/proposal.md
Normal file
89
openspec/changes/enhanced-allocation/proposal.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Proposal: Enhanced Allocation Fidelity
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The current Resource Allocation implementation diverges from intended planning behavior and creates misleading month-level signals.
|
||||||
|
|
||||||
|
Current drift:
|
||||||
|
1. Monthly budget was treated as a derived display (`approved_estimate / 12`) rather than explicit manager planning.
|
||||||
|
2. Allocation execution and planning intent are conflated in one surface.
|
||||||
|
3. Visual signaling is noisier than needed for decision-making.
|
||||||
|
4. Untracked allocation and partial bulk semantics are only partially implemented.
|
||||||
|
|
||||||
|
Business need:
|
||||||
|
- Managers must phase a project lifecycle estimate across months based on dependencies and customer timelines.
|
||||||
|
- Month execution must be validated against explicit month plans, not an average split.
|
||||||
|
- Management needs deterministic reporting from a strict plan -> execute chain.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
This change formalizes and aligns a strict 3-surface model (with reporting as the 4th outcome surface):
|
||||||
|
|
||||||
|
1. **Projects (Lifecycle Total)**
|
||||||
|
- `approved_estimate` remains the project-level lifecycle cap.
|
||||||
|
|
||||||
|
2. **Project-Month Plan (Manager Intent)**
|
||||||
|
- Manager enters monthly planned hours per project in a grid.
|
||||||
|
- Monthly plans reconcile against lifecycle total (OVER/UNDER/MATCH).
|
||||||
|
- Blank planning months remain blank in UI.
|
||||||
|
|
||||||
|
3. **Project-Resource Allocation (Month Execution)**
|
||||||
|
- Allocation grid becomes primary editing workflow (no modal-first dependency).
|
||||||
|
- Row variance compares month allocation vs project month plan.
|
||||||
|
- Column variance compares member allocation vs member month capacity.
|
||||||
|
- Untracked (`team_member_id = null`) is supported as a first-class execution path.
|
||||||
|
|
||||||
|
4. **Reporting Outcome**
|
||||||
|
- Reporting consumes lifecycle total + month plan + resource allocation to show did/is/will views.
|
||||||
|
|
||||||
|
## Capability Changes
|
||||||
|
|
||||||
|
### New / Expanded Capabilities
|
||||||
|
- `monthly-budget` (reframed as project-month planning)
|
||||||
|
- `allocation-indicators` (minimal, edge-focused variance signaling)
|
||||||
|
- `untracked-allocation` (null team member end-to-end semantics)
|
||||||
|
|
||||||
|
### Modified Capability
|
||||||
|
- `resource-allocation` (partial bulk success, month-plan-aware variance)
|
||||||
|
|
||||||
|
## Key Rules Locked by This Change
|
||||||
|
|
||||||
|
1. No `approved_estimate / 12` derivation for planning behavior.
|
||||||
|
2. Month plan reconciliation is explicit:
|
||||||
|
- sum(plan) > approved -> OVER
|
||||||
|
- sum(plan) < approved -> UNDER
|
||||||
|
- sum(plan) == approved -> MATCH
|
||||||
|
3. Blank planning month is visually blank, but treated as planned `0` for allocation variance.
|
||||||
|
4. Allocation for blank-plan months is allowed.
|
||||||
|
5. `projects.forecasted_effort` is deprecated for this workflow.
|
||||||
|
6. Visual language is minimal:
|
||||||
|
- OVER = red
|
||||||
|
- UNDER = amber
|
||||||
|
- MATCH = neutral
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
- Introduce explicit project-month planning source of truth.
|
||||||
|
- Stop using `projects.forecasted_effort` in this workflow path.
|
||||||
|
|
||||||
|
### API Contract
|
||||||
|
- Add/adjust project-month plan endpoints and payloads.
|
||||||
|
- Ensure allocation endpoints provide enough context for row/column variance.
|
||||||
|
- Keep partial bulk response contract explicit (created/failed/summary).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Add project-month plan grid surface.
|
||||||
|
- Refactor allocation matrix to grid-first editing and edge variance indicators.
|
||||||
|
- Keep status placement low-noise and decision-oriented.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Add deterministic coverage for reconciliation math, blank-vs-zero semantics, untracked handling, and partial bulk behavior.
|
||||||
|
- Require mapped tests for every requirement scenario before task closure.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Real-time collaboration/websockets in this change.
|
||||||
|
- Notification systems.
|
||||||
|
- Export workflows.
|
||||||
|
- UI polish iterations beyond required planning fidelity.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Allocation Indicators Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This capability defines low-noise variance indicators for planning and execution surfaces.
|
||||||
|
Indicators are decision aids, not decorative status coloring.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Use minimal status palette
|
||||||
|
|
||||||
|
The system SHALL use a minimal indicator palette:
|
||||||
|
- `OVER` -> red
|
||||||
|
- `UNDER` -> amber
|
||||||
|
- `MATCH/SETTLED` -> neutral
|
||||||
|
|
||||||
|
#### Scenario: Match is neutral
|
||||||
|
- **GIVEN** row variance equals 0
|
||||||
|
- **WHEN** rendering status
|
||||||
|
- **THEN** status uses neutral styling
|
||||||
|
- **AND** no additional success color emphasis is required
|
||||||
|
|
||||||
|
### Requirement: Place indicators at summary edges
|
||||||
|
|
||||||
|
The system SHALL prioritize indicator display on row/column summary edges.
|
||||||
|
|
||||||
|
#### Scenario: Row-level over-allocation indicator
|
||||||
|
- **GIVEN** project row total exceeds selected month plan
|
||||||
|
- **WHEN** allocation grid renders
|
||||||
|
- **THEN** project row summary status shows `OVER` in red
|
||||||
|
|
||||||
|
#### Scenario: Column-level over-capacity indicator
|
||||||
|
- **GIVEN** member column total exceeds member month capacity
|
||||||
|
- **WHEN** allocation grid renders
|
||||||
|
- **THEN** member column summary status shows `OVER` in red
|
||||||
|
|
||||||
|
#### Scenario: Under-allocation indicator
|
||||||
|
- **GIVEN** row or column total is below comparison target
|
||||||
|
- **WHEN** grid renders
|
||||||
|
- **THEN** summary status shows `UNDER` in amber
|
||||||
|
|
||||||
|
### Requirement: Keep indicators explainable
|
||||||
|
|
||||||
|
The system SHALL provide text status labels with numeric deltas for accessibility and clarity.
|
||||||
|
|
||||||
|
#### Scenario: Color is not sole signal
|
||||||
|
- **WHEN** status is rendered
|
||||||
|
- **THEN** UI includes text label (`OVER`/`UNDER`) and delta value
|
||||||
|
|
||||||
|
### Requirement: Distinguish project and resource variance semantics
|
||||||
|
|
||||||
|
Project variance and resource variance SHALL remain separate.
|
||||||
|
|
||||||
|
#### Scenario: Project over, resource under
|
||||||
|
- **GIVEN** a project row is `OVER`
|
||||||
|
- **AND** a member column is `UNDER`
|
||||||
|
- **WHEN** indicators render
|
||||||
|
- **THEN** each axis displays its own status independently
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Allocation indicator source
|
||||||
|
|
||||||
|
**Original behavior:** project indicator compared monthly allocation directly to lifecycle estimate assumptions.
|
||||||
|
|
||||||
|
**Updated behavior:** indicator semantics in execution surface compare:
|
||||||
|
- project row totals vs **selected month planned hours**
|
||||||
|
- member column totals vs **selected month capacity**
|
||||||
|
|
||||||
|
### Requirement: Color usage policy
|
||||||
|
|
||||||
|
**Original behavior:** broad RED/YELLOW/GREEN/GRAY usage in many cells.
|
||||||
|
|
||||||
|
**Updated behavior:** minimal red/amber/neutral policy with status emphasis on summary edges.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Project-Month Plan Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This capability defines explicit manager-entered monthly planning per project.
|
||||||
|
It replaces derived monthly budget assumptions and becomes the planning source of truth for allocation variance.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Manager enters explicit monthly plan per project
|
||||||
|
|
||||||
|
The system SHALL allow managers to set planned hours for each project-month cell.
|
||||||
|
|
||||||
|
#### Scenario: Set monthly plan across multiple months
|
||||||
|
- **GIVEN** project `PROJ-001` has `approved_estimate = 3000`
|
||||||
|
- **WHEN** manager sets Jan=1200, Feb=1400, Mar=400
|
||||||
|
- **THEN** the system stores those exact values
|
||||||
|
- **AND** no derived monthly average is applied
|
||||||
|
|
||||||
|
#### Scenario: Edit monthly plan cell inline
|
||||||
|
- **GIVEN** a month-plan grid cell contains 1200
|
||||||
|
- **WHEN** manager edits the cell to 1100 and commits
|
||||||
|
- **THEN** the system persists 1100
|
||||||
|
- **AND** reconciliation status recalculates immediately
|
||||||
|
|
||||||
|
### Requirement: Reconcile month-plan sum against lifecycle approved estimate
|
||||||
|
|
||||||
|
The system SHALL compute reconciliation status per project based on:
|
||||||
|
`sum(non-null monthly planned hours)` vs `approved_estimate`.
|
||||||
|
|
||||||
|
#### Scenario: OVER reconciliation
|
||||||
|
- **GIVEN** `approved_estimate = 3000`
|
||||||
|
- **AND** month-plan sum is 3200
|
||||||
|
- **THEN** status is `OVER`
|
||||||
|
|
||||||
|
#### Scenario: UNDER reconciliation
|
||||||
|
- **GIVEN** `approved_estimate = 3000`
|
||||||
|
- **AND** month-plan sum is 2800
|
||||||
|
- **THEN** status is `UNDER`
|
||||||
|
|
||||||
|
#### Scenario: MATCH reconciliation
|
||||||
|
- **GIVEN** `approved_estimate = 3000`
|
||||||
|
- **AND** month-plan sum is 3000
|
||||||
|
- **THEN** status is `MATCH`
|
||||||
|
|
||||||
|
### Requirement: Preserve blank month semantics
|
||||||
|
|
||||||
|
The planning grid SHALL keep unset months visually blank and semantically distinct from explicit zero.
|
||||||
|
|
||||||
|
#### Scenario: Blank remains blank
|
||||||
|
- **GIVEN** no plan exists for April
|
||||||
|
- **WHEN** manager views planning grid
|
||||||
|
- **THEN** April cell is blank (no `0` shown)
|
||||||
|
|
||||||
|
#### Scenario: Clear cell sets blank semantics
|
||||||
|
- **GIVEN** a month cell has planned value
|
||||||
|
- **WHEN** manager clears the cell and commits
|
||||||
|
- **THEN** the month is stored as blank/unset semantics
|
||||||
|
- **AND** planning UI displays blank
|
||||||
|
|
||||||
|
### Requirement: Allocation variance uses blank plan as zero
|
||||||
|
|
||||||
|
For allocation variance computation only, missing month plan SHALL be treated as planned `0`.
|
||||||
|
|
||||||
|
#### Scenario: Allocate against blank plan month
|
||||||
|
- **GIVEN** no plan is set for selected month
|
||||||
|
- **AND** project allocations total 40h
|
||||||
|
- **WHEN** variance is computed
|
||||||
|
- **THEN** planned value used is 0
|
||||||
|
- **AND** row variance is +40
|
||||||
|
- **AND** allocation operation remains allowed
|
||||||
|
|
||||||
|
### Requirement: Grid-first planning interaction
|
||||||
|
|
||||||
|
Project-month planning SHALL be managed in a grid-first interface.
|
||||||
|
|
||||||
|
#### Scenario: Keyboard-first editing
|
||||||
|
- **WHEN** manager navigates month-plan grid with keyboard
|
||||||
|
- **THEN** inline cell editing and commit are supported
|
||||||
|
- **AND** modal interaction is not required for normal edits
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Monthly budget derivation
|
||||||
|
|
||||||
|
**Original behavior (rejected):** monthly budget derived from `approved_estimate / 12`.
|
||||||
|
|
||||||
|
**Updated behavior:** monthly plan values are explicit manager-entered project-month values; no derivation formula is used for planning behavior.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Resource Allocation - Delta Specification
|
||||||
|
|
||||||
|
This delta documents required updates to existing resource allocation behavior for fidelity with explicit month planning.
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Month execution comparison target
|
||||||
|
|
||||||
|
**Original behavior:** month execution was compared against derived or lifecycle assumptions.
|
||||||
|
|
||||||
|
**Updated behavior:** selected month project allocation is compared against explicit project-month planned hours.
|
||||||
|
|
||||||
|
#### Scenario: Compare row total to month plan
|
||||||
|
- **GIVEN** selected month plan for project is 1200h
|
||||||
|
- **AND** project allocations total 1300h
|
||||||
|
- **THEN** project row variance is +100h
|
||||||
|
- **AND** row status is `OVER`
|
||||||
|
|
||||||
|
#### Scenario: Blank month plan comparison
|
||||||
|
- **GIVEN** selected month has no plan value set
|
||||||
|
- **AND** project allocations total 50h
|
||||||
|
- **THEN** comparison target is 0h
|
||||||
|
- **AND** row status is `OVER`
|
||||||
|
- **AND** allocation remains allowed
|
||||||
|
|
||||||
|
### Requirement: Bulk allocation behavior
|
||||||
|
|
||||||
|
**Original behavior:** all-or-nothing transaction semantics.
|
||||||
|
|
||||||
|
**Updated behavior:** valid items SHALL be saved even if some items fail.
|
||||||
|
|
||||||
|
#### Scenario: Partial bulk success
|
||||||
|
- **WHEN** 10 allocation items are submitted and 2 fail validation
|
||||||
|
- **THEN** 8 valid items are persisted
|
||||||
|
- **AND** failed items return per-index validation errors
|
||||||
|
- **AND** response includes summary created/failed counts
|
||||||
|
|
||||||
|
### Requirement: Untracked execution semantics
|
||||||
|
|
||||||
|
**Original behavior:** untracked support was ambiguous/incomplete.
|
||||||
|
|
||||||
|
**Updated behavior:** `team_member_id = null` is valid and treated as untracked effort.
|
||||||
|
|
||||||
|
#### Scenario: Untracked counted in project, excluded from capacity
|
||||||
|
- **WHEN** untracked allocation exists for selected month
|
||||||
|
- **THEN** project totals include it
|
||||||
|
- **AND** member capacity/utilization computations exclude it
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Untracked Allocation Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This capability supports external/unassigned effort by allowing allocations without a team member association.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Support null team member in allocation APIs
|
||||||
|
|
||||||
|
The system SHALL allow allocation records with `team_member_id = null`.
|
||||||
|
|
||||||
|
#### Scenario: Create untracked allocation
|
||||||
|
- **GIVEN** user has allocation create permission
|
||||||
|
- **WHEN** POST /api/allocations with `team_member_id = null`
|
||||||
|
- **THEN** allocation is created successfully
|
||||||
|
|
||||||
|
#### Scenario: Bulk create with mixed tracked/untracked
|
||||||
|
- **GIVEN** a bulk payload contains tracked and untracked entries
|
||||||
|
- **WHEN** POST /api/allocations/bulk is executed
|
||||||
|
- **THEN** untracked entries with valid data are processed successfully
|
||||||
|
|
||||||
|
### Requirement: Include untracked in project totals
|
||||||
|
|
||||||
|
Untracked hours SHALL contribute to project-level and grand totals.
|
||||||
|
|
||||||
|
#### Scenario: Project total includes untracked
|
||||||
|
- **GIVEN** project has tracked 80h and untracked 20h in selected month
|
||||||
|
- **WHEN** project row total is computed
|
||||||
|
- **THEN** row total is 100h
|
||||||
|
|
||||||
|
### Requirement: Exclude untracked from member capacity metrics
|
||||||
|
|
||||||
|
Untracked allocations SHALL NOT contribute to any team member utilization/capacity variance.
|
||||||
|
|
||||||
|
#### Scenario: Member utilization ignores untracked
|
||||||
|
- **GIVEN** selected month has untracked allocations
|
||||||
|
- **WHEN** member column totals and capacity variance are computed
|
||||||
|
- **THEN** untracked rows are excluded from member computations
|
||||||
|
|
||||||
|
### Requirement: Present untracked in execution grid
|
||||||
|
|
||||||
|
The allocation grid SHALL expose untracked as a first-class execution bucket.
|
||||||
|
|
||||||
|
#### Scenario: Untracked column visible
|
||||||
|
- **WHEN** manager opens allocation execution grid
|
||||||
|
- **THEN** untracked column/bucket is visible and editable
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Capacity validation
|
||||||
|
|
||||||
|
**Original behavior:** all allocations were assumed team-member-bound for capacity checks.
|
||||||
|
|
||||||
|
**Updated behavior:** capacity validation is skipped for untracked allocations (`team_member_id = null`).
|
||||||
168
openspec/changes/enhanced-allocation/tasks.md
Normal file
168
openspec/changes/enhanced-allocation/tasks.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Tasks: Enhanced Allocation Fidelity
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Workstream | Status | Progress |
|
||||||
|
|------------|--------|----------|
|
||||||
|
| Artifact Fidelity Alignment | ✅ Complete | 100% |
|
||||||
|
| Project-Month Plan Capability | ⚪ Not Started | 0% |
|
||||||
|
| Allocation Grid Fidelity | ⚪ Not Started | 0% |
|
||||||
|
| Untracked + Partial Bulk Hardening | ⚪ Not Started | 0% |
|
||||||
|
| Reporting-Ready Contracts | ⚪ Not Started | 0% |
|
||||||
|
| Verification & Regression | ⚪ Not Started | 0% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Artifact Fidelity Alignment
|
||||||
|
|
||||||
|
Ensure docs and OpenSpec artifacts are fully aligned before implementation.
|
||||||
|
|
||||||
|
### Phase 1: Artifact Updates
|
||||||
|
|
||||||
|
- [x] 0.1 Update decision log with model and rules
|
||||||
|
- [x] canonical 3-surface 0.2 Update proposal to remove derived monthly budget assumption
|
||||||
|
- [x] 0.3 Update design with explicit project-month planx] 0 architecture
|
||||||
|
- [x] 0.4 Update monthly-budget spec to explicit planning semantics
|
||||||
|
- [x] 0.5 Update allocation-indicators spec to red/amber/neutral policy
|
||||||
|
- [x] 0.6 Update untracked and resource-allocation delta specs to final semantics
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
- [x] 0.7 No artifact references `approved_estimate / 12` as planning behavior
|
||||||
|
- [x] 0.8 All artifacts consistently define blank month semantics (blank UI, zero for variance)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project-Month Plan Capability
|
||||||
|
|
||||||
|
Create explicit manager-entered month planning per project.
|
||||||
|
|
||||||
|
### Phase 1: Tests (RED)
|
||||||
|
|
||||||
|
- [ ] 1.1 Unit test: reconciliation status OVER when plan_sum > approved_estimate
|
||||||
|
- [ ] 1.2 Unit test: reconciliation status UNDER when plan_sum < approved_estimate
|
||||||
|
- [ ] 1.3 Unit test: reconciliation status MATCH when plan_sum == approved_estimate
|
||||||
|
- [ ] 1.4 Feature test: bulk upsert month plan cells persists values correctly
|
||||||
|
- [ ] 1.5 Feature test: clearing month plan cell preserves blank semantics
|
||||||
|
- [ ] 1.6 Feature test: blank month plan remains blank in response payload
|
||||||
|
- [ ] 1.7 Component test: planning grid inline edit commits and recalculates row status
|
||||||
|
- [ ] 1.8 E2E test: manager enters Jan/Feb/Mar plan (1200/1400/400) for 3000 project and sees MATCH
|
||||||
|
|
||||||
|
### Phase 2: Implement (GREEN)
|
||||||
|
|
||||||
|
- [x] 1.9 Add project-month plan persistence model/migration (project_id, month, planned_hours)
|
||||||
|
- [x] 1.10 Implement plan query endpoint for grid consumption
|
||||||
|
- [x] 1.11 Implement plan bulk upsert endpoint with clear-cell handling
|
||||||
|
- [ ] 1.12 Implement reconciliation calculator service and API exposure
|
||||||
|
- [ ] 1.13 Implement planning grid UI with keyboard-first inline editing
|
||||||
|
- [ ] 1.14 Ensure planning UI keeps blank cells blank (no implicit zero rendering)
|
||||||
|
|
||||||
|
### Phase 3: Refactor
|
||||||
|
|
||||||
|
- [ ] 1.15 Centralize reconciliation logic for API and reporting reuse
|
||||||
|
|
||||||
|
### Phase 4: Document
|
||||||
|
|
||||||
|
- [ ] 1.16 Document project-month plan API contracts and reconciliation semantics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Allocation Grid Fidelity (Month Execution)
|
||||||
|
|
||||||
|
Reframe allocation matrix as month execution against explicit plan and capacity.
|
||||||
|
|
||||||
|
### Phase 1: Tests (RED)
|
||||||
|
|
||||||
|
- [ ] 2.1 Unit test: row variance uses selected month planned value
|
||||||
|
- [ ] 2.2 Unit test: blank month plan treated as zero for row variance
|
||||||
|
- [ ] 2.3 Unit test: column variance uses member month capacity
|
||||||
|
- [ ] 2.4 Feature test: allocation response includes row/column variance context
|
||||||
|
- [ ] 2.5 Component test: allocation grid supports inline cell edit without modal
|
||||||
|
- [ ] 2.6 Component test: status placement on row/column summary edges only
|
||||||
|
- [ ] 2.7 E2E test: over-plan row displays red OVER status
|
||||||
|
- [ ] 2.8 E2E test: under-plan row displays amber UNDER status
|
||||||
|
|
||||||
|
### Phase 2: Implement (GREEN)
|
||||||
|
|
||||||
|
- [ ] 2.9 Remove derived monthly budget logic from allocation surface
|
||||||
|
- [ ] 2.10 Implement row variance against explicit month plan
|
||||||
|
- [ ] 2.11 Implement column variance against member capacity
|
||||||
|
- [ ] 2.12 Convert allocation UI to grid-first editing workflow
|
||||||
|
- [ ] 2.13 Keep modal only as optional fallback (not primary flow)
|
||||||
|
- [ ] 2.14 Apply minimal visual policy: red/amber/neutral with text labels
|
||||||
|
|
||||||
|
### Phase 3: Refactor
|
||||||
|
|
||||||
|
- [ ] 2.15 Extract shared variance calculation utilities
|
||||||
|
|
||||||
|
### Phase 4: Document
|
||||||
|
|
||||||
|
- [ ] 2.16 Document variance formulas and status placement rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Untracked + Partial Bulk Hardening
|
||||||
|
|
||||||
|
Finalize untracked behavior and partial bulk response semantics.
|
||||||
|
|
||||||
|
### Phase 1: Tests (RED)
|
||||||
|
|
||||||
|
- [ ] 3.1 Feature test: single create accepts null team_member_id
|
||||||
|
- [ ] 3.2 Feature test: bulk create accepts mixed tracked/untracked payload
|
||||||
|
- [ ] 3.3 Feature test: untracked included in project/grand totals
|
||||||
|
- [ ] 3.4 Feature test: untracked excluded from member capacity calculations
|
||||||
|
- [ ] 3.5 Feature test: partial bulk persists valid rows and returns per-index failures
|
||||||
|
- [ ] 3.6 E2E test: untracked column allows inline allocation entry
|
||||||
|
|
||||||
|
### Phase 2: Implement (GREEN)
|
||||||
|
|
||||||
|
- [ ] 3.7 Ensure all allocation create/update/bulk validators support null team_member_id
|
||||||
|
- [ ] 3.8 Ensure capacity/utilization paths exclude null team_member_id
|
||||||
|
- [ ] 3.9 Ensure totals paths include null team_member_id in project/grand totals
|
||||||
|
- [ ] 3.10 Return deterministic partial bulk response contract (data/failed/summary)
|
||||||
|
|
||||||
|
### Phase 3: Refactor
|
||||||
|
|
||||||
|
- [ ] 3.11 Consolidate untracked filtering/scoping in one query abstraction
|
||||||
|
|
||||||
|
### Phase 4: Document
|
||||||
|
|
||||||
|
- [ ] 3.12 Document untracked semantics and partial bulk contract
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Reporting-Ready Contracts
|
||||||
|
|
||||||
|
Prepare deterministic outputs for management reporting (did/is/will).
|
||||||
|
|
||||||
|
### Phase 1: Tests (RED)
|
||||||
|
|
||||||
|
- [ ] 4.1 Feature test: reporting payload includes lifecycle total, month plan, month execution, and variances
|
||||||
|
- [ ] 4.2 Feature test: historical/current/future month slices are consistent
|
||||||
|
|
||||||
|
### Phase 2: Implement (GREEN)
|
||||||
|
|
||||||
|
- [ ] 4.3 Expose report-oriented aggregate endpoint(s) for member and project views
|
||||||
|
- [ ] 4.4 Ensure response explicitly distinguishes blank plan vs explicit zero
|
||||||
|
|
||||||
|
### Phase 3: Document
|
||||||
|
|
||||||
|
- [ ] 4.5 Document reporting contract dependencies on plan and execution surfaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verification & Regression Gates
|
||||||
|
|
||||||
|
### Phase 1: Verification
|
||||||
|
|
||||||
|
- [ ] 5.1 Run full backend unit + feature test suite
|
||||||
|
- [ ] 5.2 Run frontend component test suite for planning/allocation grids
|
||||||
|
- [ ] 5.3 Run E2E flows for planning -> allocation -> variance visibility
|
||||||
|
- [ ] 5.4 Verify login/team-member/project baseline flows unaffected
|
||||||
|
|
||||||
|
### Phase 2: Quality Gates
|
||||||
|
|
||||||
|
- [ ] 5.5 Confirm no implementation path uses derived `approved_estimate / 12`
|
||||||
|
- [ ] 5.6 Confirm `projects.forecasted_effort` is not used in this workflow
|
||||||
|
- [ ] 5.7 Confirm all statuses follow red/amber/neutral policy
|
||||||
|
- [ ] 5.8 Confirm every completed task has mapped passing automated tests
|
||||||
Reference in New Issue
Block a user