feat(allocation): implement resource allocation feature
- Add AllocationController with CRUD + bulk endpoints - Add AllocationValidationService for capacity/estimate validation - Add AllocationMatrixService for optimized matrix queries - Add AllocationPolicy for authorization - Add AllocationResource for API responses - Add frontend allocationService and matrix UI - Add E2E tests for allocation matrix (20 tests) - Add unit tests for validation service and policies - Fix month format conversion (YYYY-MM to YYYY-MM-01)
This commit is contained in:
@@ -161,3 +161,25 @@ export async function saveAvailability(
|
||||
): Promise<TeamMemberAvailability> {
|
||||
return api.post<TeamMemberAvailability>('/capacity/availability', data);
|
||||
}
|
||||
|
||||
export interface BatchAvailabilityUpdate {
|
||||
team_member_id: string;
|
||||
date: string;
|
||||
availability: 0 | 0.5 | 1;
|
||||
}
|
||||
|
||||
export interface BatchAvailabilityResponse {
|
||||
saved: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export async function batchUpdateAvailability(
|
||||
month: string,
|
||||
updates: BatchAvailabilityUpdate[]
|
||||
): Promise<BatchAvailabilityResponse> {
|
||||
const response = await api.post<{ data: BatchAvailabilityResponse }>(
|
||||
'/capacity/availability/batch',
|
||||
{ month, updates }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
269
frontend/src/lib/components/capacity/CapacityExpertGrid.svelte
Normal file
269
frontend/src/lib/components/capacity/CapacityExpertGrid.svelte
Normal file
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import type { TeamMember } from '$lib/services/teamMemberService';
|
||||
import type { Holiday } from '$lib/types/capacity';
|
||||
import { normalizeToken, type NormalizedToken } from '$lib/utils/expertModeTokens';
|
||||
import { batchUpdateAvailability } from '$lib/api/capacity';
|
||||
import { getIndividualCapacity } from '$lib/api/capacity';
|
||||
|
||||
export let month: string;
|
||||
export let teamMembers: TeamMember[];
|
||||
export let holidays: Holiday[];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
dirty: { count: number };
|
||||
valid: { allValid: boolean };
|
||||
saved: void;
|
||||
}>();
|
||||
|
||||
interface CellState {
|
||||
memberId: string;
|
||||
date: string;
|
||||
originalValue: number | null;
|
||||
currentValue: NormalizedToken;
|
||||
}
|
||||
|
||||
let cells: Map<string, CellState> = new Map();
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error: string | null = null;
|
||||
let focusedCell: string | null = null;
|
||||
|
||||
$: daysInMonth = getDaysInMonth(month);
|
||||
$: holidayDates = new Set(holidays.map((h) => h.date));
|
||||
$: dirtyCells = Array.from(cells.values()).filter(
|
||||
(c) => c.currentValue.valid && c.currentValue.numericValue !== c.originalValue
|
||||
);
|
||||
$: invalidCells = Array.from(cells.values()).filter((c) => !c.currentValue.valid);
|
||||
$: canSubmit = dirtyCells.length > 0 && invalidCells.length === 0 && !saving;
|
||||
|
||||
$: totalCapacity = calculateTotalCapacity();
|
||||
$: totalRevenue = calculateTotalRevenue();
|
||||
|
||||
$: dispatch('dirty', { count: dirtyCells.length });
|
||||
$: dispatch('valid', { allValid: invalidCells.length === 0 });
|
||||
|
||||
function getDaysInMonth(monthStr: string): string[] {
|
||||
const [year, month] = monthStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, 1);
|
||||
const days: string[] = [];
|
||||
while (date.getMonth() === month - 1) {
|
||||
const dayStr = date.toISOString().split('T')[0];
|
||||
days.push(dayStr);
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function isWeekend(dateStr: string): boolean {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
function getCellKey(memberId: string, date: string): string {
|
||||
return `${memberId}:${date}`;
|
||||
}
|
||||
|
||||
function calculateTotalCapacity(): number {
|
||||
return Array.from(cells.values())
|
||||
.filter((c) => c.currentValue.valid)
|
||||
.reduce((sum, c) => sum + (c.currentValue.numericValue ?? 0), 0);
|
||||
}
|
||||
|
||||
function calculateTotalRevenue(): number {
|
||||
return teamMembers.reduce((total, member) => {
|
||||
const memberCells = Array.from(cells.values()).filter((c) => c.memberId === member.id);
|
||||
const memberCapacity = memberCells
|
||||
.filter((c) => c.currentValue.valid)
|
||||
.reduce((sum, c) => sum + (c.currentValue.numericValue ?? 0), 0);
|
||||
const hourlyRate = parseFloat(member.hourly_rate) || 0;
|
||||
return total + memberCapacity * 8 * hourlyRate;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function loadExistingData() {
|
||||
loading = true;
|
||||
cells = new Map();
|
||||
|
||||
for (const member of teamMembers) {
|
||||
try {
|
||||
const capacity = await getIndividualCapacity(month, member.id);
|
||||
for (const detail of capacity.details) {
|
||||
const key = getCellKey(member.id, detail.date);
|
||||
const numericValue = detail.availability;
|
||||
const wknd = isWeekend(detail.date);
|
||||
const hol = holidayDates.has(detail.date);
|
||||
|
||||
let token: string;
|
||||
if (numericValue === 0) {
|
||||
if (hol) {
|
||||
token = 'H';
|
||||
} else if (wknd) {
|
||||
token = 'O';
|
||||
} else {
|
||||
token = '0';
|
||||
}
|
||||
} else if (numericValue === 0.5) {
|
||||
token = '0.5';
|
||||
} else {
|
||||
token = '1';
|
||||
}
|
||||
|
||||
cells.set(key, {
|
||||
memberId: member.id,
|
||||
date: detail.date,
|
||||
originalValue: numericValue,
|
||||
currentValue: { rawToken: token, numericValue, valid: true }
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
for (const date of daysInMonth) {
|
||||
const key = getCellKey(member.id, date);
|
||||
const wknd = isWeekend(date);
|
||||
const hol = holidayDates.has(date);
|
||||
let defaultValue: NormalizedToken;
|
||||
if (hol) {
|
||||
defaultValue = { rawToken: 'H', numericValue: 0, valid: true };
|
||||
} else if (wknd) {
|
||||
defaultValue = { rawToken: 'O', numericValue: 0, valid: true };
|
||||
} else {
|
||||
defaultValue = { rawToken: '1', numericValue: 1, valid: true };
|
||||
}
|
||||
cells.set(key, {
|
||||
memberId: member.id,
|
||||
date,
|
||||
originalValue: defaultValue.numericValue,
|
||||
currentValue: defaultValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleCellInput(memberId: string, date: string, rawValue: string) {
|
||||
const key = getCellKey(memberId, date);
|
||||
const cell = cells.get(key);
|
||||
if (!cell) return;
|
||||
|
||||
const normalized = normalizeToken(rawValue, isWeekend(date), holidayDates.has(date));
|
||||
cell.currentValue = normalized;
|
||||
cells = cells;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const updates = dirtyCells.map((cell) => ({
|
||||
team_member_id: cell.memberId,
|
||||
date: cell.date,
|
||||
availability: cell.currentValue.numericValue as 0 | 0.5 | 1
|
||||
}));
|
||||
|
||||
await batchUpdateAvailability(month, updates);
|
||||
|
||||
// Update original values to current values
|
||||
for (const cell of dirtyCells) {
|
||||
cell.originalValue = cell.currentValue.numericValue;
|
||||
}
|
||||
cells = cells;
|
||||
dispatch('saved');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save changes';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadExistingData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/60 p-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Loading expert mode data...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex gap-6">
|
||||
<span><strong>Capacity:</strong> {totalCapacity.toFixed(1)} person-days</span>
|
||||
<span><strong>Revenue:</strong> ${totalRevenue.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
disabled={!canSubmit}
|
||||
on:click={handleSubmit}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Saving...
|
||||
{:else}
|
||||
Submit ({dirtyCells.length})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto border border-base-200 rounded-lg">
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky left-0 bg-base-100 z-10 min-w-[150px]">Team Member</th>
|
||||
{#each daysInMonth as date}
|
||||
{@const day = parseInt(date.split('-')[2])}
|
||||
{@const isWknd = isWeekend(date)}
|
||||
{@const isHol = holidayDates.has(date)}
|
||||
<th
|
||||
class="text-center min-w-[40px] {isWknd ? 'bg-base-300 border-b-2 border-base-400' : ''} {isHol ? 'bg-warning/40 border-b-2 border-warning font-bold' : ''}"
|
||||
title={isHol ? holidays.find((h) => h.date === date)?.name : ''}
|
||||
>
|
||||
{day}{isHol ? ' H' : ''}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each teamMembers as member}
|
||||
<tr>
|
||||
<td class="sticky left-0 bg-base-100 z-10 font-medium">{member.name}</td>
|
||||
{#each daysInMonth as date}
|
||||
{@const key = getCellKey(member.id, date)}
|
||||
{@const cell = cells.get(key)}
|
||||
{@const isWknd = isWeekend(date)}
|
||||
{@const isHol = holidayDates.has(date)}
|
||||
{@const isFocused = focusedCell === key}
|
||||
{@const isInvalid = cell && !cell.currentValue.valid}
|
||||
{@const isDirty = cell && cell.currentValue.numericValue !== cell.originalValue}
|
||||
<td
|
||||
class="p-0 {isWknd ? 'bg-base-300' : ''} {isHol ? 'bg-warning/40' : ''}"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-xs w-full text-center p-0 h-6 min-h-0 border-0 {isInvalid ? 'border-2 border-error' : ''} {isDirty && !isInvalid ? 'bg-success/20' : ''} {isFocused ? 'ring-2 ring-primary' : ''}"
|
||||
value={cell?.currentValue.rawToken ?? ''}
|
||||
on:input={(e) => handleCellInput(member.id, date, e.currentTarget.value)}
|
||||
on:focus={() => (focusedCell = key)}
|
||||
on:blur={() => (focusedCell = null)}
|
||||
aria-label="{member.name} {date}"
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
92
frontend/src/lib/services/allocationService.ts
Normal file
92
frontend/src/lib/services/allocationService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Allocation Service
|
||||
*
|
||||
* API operations for resource allocation management.
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
|
||||
export interface Allocation {
|
||||
id: string;
|
||||
project_id: string;
|
||||
team_member_id: string;
|
||||
month: string;
|
||||
allocated_hours: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
project?: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
};
|
||||
team_member?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateAllocationRequest {
|
||||
project_id: string;
|
||||
team_member_id: string;
|
||||
month: string;
|
||||
allocated_hours: number;
|
||||
}
|
||||
|
||||
export interface UpdateAllocationRequest {
|
||||
allocated_hours: number;
|
||||
}
|
||||
|
||||
export interface BulkAllocationRequest {
|
||||
allocations: CreateAllocationRequest[];
|
||||
}
|
||||
|
||||
// Allocation API methods
|
||||
export const allocationService = {
|
||||
/**
|
||||
* Get all allocations, optionally filtered by month
|
||||
*/
|
||||
getAll: (month?: string) => {
|
||||
const query = month ? `?month=${month}` : '';
|
||||
return api.get<Allocation[]>(`/allocations${query}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single allocation by ID
|
||||
*/
|
||||
getById: (id: string) =>
|
||||
api.get<Allocation>(`/allocations/${id}`),
|
||||
|
||||
/**
|
||||
* Create a new allocation
|
||||
*/
|
||||
create: (data: CreateAllocationRequest) =>
|
||||
api.post<Allocation>('/allocations', data),
|
||||
|
||||
/**
|
||||
* Update an existing allocation
|
||||
*/
|
||||
update: (id: string, data: UpdateAllocationRequest) =>
|
||||
api.put<Allocation>(`/allocations/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete an allocation
|
||||
*/
|
||||
delete: (id: string) =>
|
||||
api.delete<{ message: string }>(`/allocations/${id}`),
|
||||
|
||||
/**
|
||||
* Bulk create allocations
|
||||
*/
|
||||
bulkCreate: (data: BulkAllocationRequest) =>
|
||||
api.post<Allocation[]>('/allocations/bulk', data),
|
||||
};
|
||||
|
||||
/**
|
||||
* Format allocated hours
|
||||
*/
|
||||
export function formatAllocatedHours(hours: string | number): string {
|
||||
const numHours = typeof hours === 'string' ? parseFloat(hours) : hours;
|
||||
return `${numHours}h`;
|
||||
}
|
||||
|
||||
export default allocationService;
|
||||
@@ -144,7 +144,15 @@ interface ApiRequestOptions {
|
||||
|
||||
// Main API request function
|
||||
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
// Ensure we have an absolute URL for server-side rendering
|
||||
let url = endpoint;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
// Get the base URL - works in both browser and server contexts
|
||||
const baseUrl = typeof window !== 'undefined'
|
||||
? ''
|
||||
: process.env['ORIGIN'] || '';
|
||||
url = `${baseUrl}${API_BASE_URL}${endpoint}`;
|
||||
}
|
||||
|
||||
// Prepare headers
|
||||
const headers: Record<string, string> = {
|
||||
|
||||
32
frontend/src/lib/stores/expertMode.ts
Normal file
32
frontend/src/lib/stores/expertMode.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const EXPERT_MODE_KEY = 'headroom.capacity.expertMode';
|
||||
|
||||
function getInitialExpertMode(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
|
||||
const stored = localStorage.getItem(EXPERT_MODE_KEY);
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const expertModeWritable = writable<boolean>(getInitialExpertMode());
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
expertModeWritable.subscribe((value) => {
|
||||
localStorage.setItem(EXPERT_MODE_KEY, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
export const expertMode = {
|
||||
subscribe: expertModeWritable.subscribe,
|
||||
};
|
||||
|
||||
export function setExpertMode(value: boolean): void {
|
||||
expertModeWritable.set(value);
|
||||
}
|
||||
|
||||
export function toggleExpertMode(): void {
|
||||
expertModeWritable.update((current) => !current);
|
||||
}
|
||||
63
frontend/src/lib/utils/expertModeTokens.ts
Normal file
63
frontend/src/lib/utils/expertModeTokens.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface NormalizedToken {
|
||||
rawToken: string;
|
||||
numericValue: number | null;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
const VALID_TOKENS = ['H', 'O', '0', '.5', '0.5', '1'];
|
||||
|
||||
export function normalizeToken(
|
||||
raw: string,
|
||||
isWeekend: boolean = false,
|
||||
isHoliday: boolean = false
|
||||
): NormalizedToken {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (!VALID_TOKENS.includes(trimmed)) {
|
||||
return {
|
||||
rawToken: trimmed,
|
||||
numericValue: null,
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
let rawToken = trimmed;
|
||||
let numericValue: number;
|
||||
|
||||
switch (trimmed) {
|
||||
case 'H':
|
||||
case 'O':
|
||||
numericValue = 0;
|
||||
break;
|
||||
case '0':
|
||||
if (isWeekend) {
|
||||
rawToken = 'O';
|
||||
} else if (isHoliday) {
|
||||
rawToken = 'H';
|
||||
}
|
||||
numericValue = 0;
|
||||
break;
|
||||
case '.5':
|
||||
rawToken = '0.5';
|
||||
numericValue = 0.5;
|
||||
break;
|
||||
case '0.5':
|
||||
numericValue = 0.5;
|
||||
break;
|
||||
case '1':
|
||||
numericValue = 1;
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
rawToken: trimmed,
|
||||
numericValue: null,
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rawToken,
|
||||
numericValue,
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,396 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||
import FilterBar from '$lib/components/common/FilterBar.svelte';
|
||||
import LoadingState from '$lib/components/common/LoadingState.svelte';
|
||||
import EmptyState from '$lib/components/common/EmptyState.svelte';
|
||||
import { Calendar } from 'lucide-svelte';
|
||||
import { Plus, X, AlertCircle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-svelte';
|
||||
import {
|
||||
allocationService,
|
||||
type Allocation,
|
||||
type CreateAllocationRequest
|
||||
} from '$lib/services/allocationService';
|
||||
import { projectService, type Project } from '$lib/services/projectService';
|
||||
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
|
||||
import { selectedPeriod, previousMonth, nextMonth, setPeriod } from '$lib/stores/period';
|
||||
|
||||
// State
|
||||
let allocations = $state<Allocation[]>([]);
|
||||
let projects = $state<Project[]>([]);
|
||||
let teamMembers = $state<TeamMember[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Month navigation
|
||||
let currentPeriod = $state('2026-02');
|
||||
|
||||
// Modal state
|
||||
let showModal = $state(false);
|
||||
let editingAllocation = $state<Allocation | null>(null);
|
||||
let formLoading = $state(false);
|
||||
let formError = $state<string | null>(null);
|
||||
|
||||
// Form state
|
||||
let formData = $state<CreateAllocationRequest>({
|
||||
project_id: '',
|
||||
team_member_id: '',
|
||||
month: currentPeriod,
|
||||
allocated_hours: 0
|
||||
});
|
||||
|
||||
// Subscribe to period store - only on client
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribe = selectedPeriod.subscribe(value => {
|
||||
currentPeriod = value;
|
||||
loadAllocations();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadProjects(), loadTeamMembers(), loadAllocations()]);
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
projects = await projectService.getAll();
|
||||
} catch (err) {
|
||||
console.error('Error loading projects:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeamMembers() {
|
||||
try {
|
||||
teamMembers = await teamMemberService.getAll(true);
|
||||
} catch (err) {
|
||||
console.error('Error loading team members:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllocations() {
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
allocations = await allocationService.getAll(currentPeriod);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load allocations';
|
||||
console.error('Error loading allocations:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllocation(projectId: string, teamMemberId: string): Allocation | undefined {
|
||||
return allocations.find(a => a.project_id === projectId && a.team_member_id === teamMemberId);
|
||||
}
|
||||
|
||||
function getProjectRowTotal(projectId: string): number {
|
||||
return allocations
|
||||
.filter(a => a.project_id === projectId)
|
||||
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
|
||||
}
|
||||
|
||||
function getTeamMemberColumnTotal(teamMemberId: string): number {
|
||||
return allocations
|
||||
.filter(a => a.team_member_id === teamMemberId)
|
||||
.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
|
||||
}
|
||||
|
||||
function getProjectTotal(): number {
|
||||
return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0);
|
||||
}
|
||||
|
||||
function handleCellClick(projectId: string, teamMemberId: string) {
|
||||
const existing = getAllocation(projectId, teamMemberId);
|
||||
if (existing) {
|
||||
// Edit existing
|
||||
editingAllocation = existing;
|
||||
formData = {
|
||||
project_id: existing.project_id,
|
||||
team_member_id: existing.team_member_id,
|
||||
month: existing.month,
|
||||
allocated_hours: parseFloat(existing.allocated_hours)
|
||||
};
|
||||
} else {
|
||||
// Create new
|
||||
editingAllocation = null;
|
||||
formData = {
|
||||
project_id: projectId,
|
||||
team_member_id: teamMemberId,
|
||||
month: currentPeriod,
|
||||
allocated_hours: 0
|
||||
};
|
||||
}
|
||||
formError = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
formLoading = true;
|
||||
formError = null;
|
||||
|
||||
if (editingAllocation) {
|
||||
await allocationService.update(editingAllocation.id, {
|
||||
allocated_hours: formData.allocated_hours
|
||||
});
|
||||
} else {
|
||||
await allocationService.create(formData);
|
||||
}
|
||||
|
||||
showModal = false;
|
||||
await loadAllocations();
|
||||
} catch (err) {
|
||||
const apiError = err as { message?: string; data?: { errors?: Record<string, string[]> } };
|
||||
if (apiError.data?.errors) {
|
||||
const errors = Object.entries(apiError.data.errors)
|
||||
.map(([field, msgs]) => `${field}: ${msgs.join(', ')}`)
|
||||
.join('; ');
|
||||
formError = errors;
|
||||
} else {
|
||||
formError = apiError.message || 'An error occurred';
|
||||
}
|
||||
} finally {
|
||||
formLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!editingAllocation) return;
|
||||
|
||||
try {
|
||||
formLoading = true;
|
||||
await allocationService.delete(editingAllocation.id);
|
||||
showModal = false;
|
||||
await loadAllocations();
|
||||
} catch (err) {
|
||||
const apiError = err as { message?: string };
|
||||
formError = apiError.message || 'Failed to delete allocation';
|
||||
} finally {
|
||||
formLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingAllocation = null;
|
||||
formError = null;
|
||||
}
|
||||
|
||||
function getProjectName(projectId: string): string {
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
return project ? `${project.code} - ${project.title}` : 'Unknown';
|
||||
}
|
||||
|
||||
function getTeamMemberName(teamMemberId: string): string {
|
||||
const member = teamMembers.find(m => m.id === teamMemberId);
|
||||
return member?.name || 'Unknown';
|
||||
}
|
||||
|
||||
function formatMonth(period: string): string {
|
||||
const [year, month] = period.split('-');
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Allocations | Headroom</title>
|
||||
</svelte:head>
|
||||
|
||||
<PageHeader title="Allocations" description="Manage resource allocations" />
|
||||
<PageHeader title="Resource Allocations" description="Manage team member allocations to projects">
|
||||
{#snippet children()}
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => previousMonth()}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span class="min-w-[140px] text-center font-medium">
|
||||
{formatMonth(currentPeriod)}
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => nextMonth()}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<EmptyState
|
||||
title="Coming Soon"
|
||||
description="Resource allocation management will be available in a future update."
|
||||
icon={Calendar}
|
||||
/>
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Allocation Matrix -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky left-0 bg-base-200 z-10 min-w-[200px]">Project</th>
|
||||
{#each teamMembers as member}
|
||||
<th class="text-center min-w-[100px]">{member.name}</th>
|
||||
{/each}
|
||||
<th class="text-center bg-base-200 font-bold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each projects as project}
|
||||
<tr class="hover">
|
||||
<td class="sticky left-0 bg-base-100 z-10 font-medium">
|
||||
{project.code} - {project.title}
|
||||
</td>
|
||||
{#each teamMembers as member}
|
||||
{@const allocation = getAllocation(project.id, member.id)}
|
||||
<td
|
||||
class="text-center cursor-pointer hover:bg-base-200 transition-colors"
|
||||
onclick={() => handleCellClick(project.id, member.id)}
|
||||
>
|
||||
{#if allocation}
|
||||
<span class="badge badge-primary badge-sm">
|
||||
{allocation.allocated_hours}h
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-base-content/30">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
<td class="text-center bg-base-200 font-bold">
|
||||
{getProjectRowTotal(project.id)}h
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="font-bold bg-base-200">
|
||||
<td class="sticky left-0 bg-base-200 z-10">Total</td>
|
||||
{#each teamMembers as member}
|
||||
<td class="text-center">
|
||||
{getTeamMemberColumnTotal(member.id)}h
|
||||
</td>
|
||||
{/each}
|
||||
<td class="text-center">
|
||||
{getProjectTotal()}h
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if projects.length === 0}
|
||||
<EmptyState
|
||||
title="No projects"
|
||||
description="Create a project first to manage allocations."
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Allocation Modal -->
|
||||
{#if showModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-md">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">
|
||||
{editingAllocation ? 'Edit Allocation' : 'New Allocation'}
|
||||
</h3>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeModal}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<div class="alert alert-error mb-4">
|
||||
<AlertCircle size={16} />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<!-- Project (read-only for existing) -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label" for="project">
|
||||
<span class="label-text font-medium">Project</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project"
|
||||
class="input input-bordered w-full"
|
||||
value={getProjectName(formData.project_id)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Team Member (read-only for existing) -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label" for="team_member">
|
||||
<span class="label-text font-medium">Team Member</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="team_member"
|
||||
class="input input-bordered w-full"
|
||||
value={getTeamMemberName(formData.team_member_id)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Month (read-only) -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label" for="month">
|
||||
<span class="label-text font-medium">Month</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="month"
|
||||
class="input input-bordered w-full"
|
||||
value={formatMonth(formData.month)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Allocated Hours -->
|
||||
<div class="form-control mb-6">
|
||||
<label class="label" for="allocated_hours">
|
||||
<span class="label-text font-medium">Allocated Hours</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="allocated_hours"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.allocated_hours}
|
||||
min="0"
|
||||
step="0.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
{#if editingAllocation}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error"
|
||||
onclick={handleDelete}
|
||||
disabled={formLoading}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="btn btn-ghost" onclick={closeModal}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={formLoading}>
|
||||
{#if formLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
{editingAllocation ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-backdrop" onclick={closeModal} onkeydown={(e) => e.key === 'Escape' && closeModal()} role="button" tabindex="-1"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import CapacitySummary from '$lib/components/capacity/CapacitySummary.svelte';
|
||||
import HolidayManager from '$lib/components/capacity/HolidayManager.svelte';
|
||||
import PTOManager from '$lib/components/capacity/PTOManager.svelte';
|
||||
import CapacityExpertGrid from '$lib/components/capacity/CapacityExpertGrid.svelte';
|
||||
import { selectedPeriod } from '$lib/stores/period';
|
||||
import { expertMode, setExpertMode } from '$lib/stores/expertMode';
|
||||
import {
|
||||
holidaysStore,
|
||||
loadHolidays,
|
||||
@@ -38,6 +40,9 @@
|
||||
let calendarError: string | null = null;
|
||||
let availabilitySaving = false;
|
||||
let availabilityError: string | null = null;
|
||||
let expertDirtyCount = 0;
|
||||
let showExpertModeConfirm = false;
|
||||
let pendingExpertModeValue = false;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -193,6 +198,29 @@
|
||||
loadPTOs($selectedPeriod, selectedMemberId);
|
||||
refreshIndividualCapacity(selectedMemberId, $selectedPeriod);
|
||||
}
|
||||
|
||||
function handleExpertModeToggle() {
|
||||
if ($expertMode && expertDirtyCount > 0) {
|
||||
pendingExpertModeValue = false;
|
||||
showExpertModeConfirm = true;
|
||||
} else {
|
||||
setExpertMode(!$expertMode);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmExpertModeSwitch() {
|
||||
setExpertMode(pendingExpertModeValue);
|
||||
expertDirtyCount = 0;
|
||||
showExpertModeConfirm = false;
|
||||
}
|
||||
|
||||
function cancelExpertModeSwitch() {
|
||||
showExpertModeConfirm = false;
|
||||
}
|
||||
|
||||
function handleExpertCellSaved() {
|
||||
expertDirtyCount = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -215,20 +243,43 @@
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="tabs relative z-40" data-testid="capacity-tabs">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class={`tab tab-lg ${tab.id === activeTab ? 'tab-active' : ''}`}
|
||||
type="button"
|
||||
on:click={() => handleTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center justify-between" data-testid="capacity-tabs">
|
||||
<div class="tabs relative z-40">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class={`tab tab-lg ${tab.id === activeTab ? 'tab-active' : ''}`}
|
||||
type="button"
|
||||
on:click={() => handleTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="hidden md:flex items-center gap-2 cursor-pointer">
|
||||
<span class="text-sm font-medium">Expert Mode</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
checked={$expertMode}
|
||||
on:change={handleExpertModeToggle}
|
||||
aria-label="Toggle Expert Mode"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-base-200 bg-base-100 p-6 shadow-sm">
|
||||
{#if activeTab === 'calendar'}
|
||||
{#if $expertMode && activeTab === 'calendar'}
|
||||
<CapacityExpertGrid
|
||||
month={$selectedPeriod}
|
||||
teamMembers={$teamMembersStore.filter((m) => m.active)}
|
||||
holidays={$holidaysStore}
|
||||
on:dirty={(e) => {
|
||||
expertDirtyCount = e.detail.count;
|
||||
}}
|
||||
on:saved={handleExpertCellSaved}
|
||||
/>
|
||||
{:else if activeTab === 'calendar'}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-sm font-semibold">Team member</label>
|
||||
@@ -284,4 +335,20 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showExpertModeConfirm}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Unsaved Changes</h3>
|
||||
<p>You have unsaved changes in Expert Mode. Discard and switch?</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" on:click={cancelExpertModeSwitch}>Cancel</button>
|
||||
<button class="btn btn-primary" on:click={confirmExpertModeSwitch}>Discard & Switch</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button on:click={cancelExpertModeSwitch}>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user