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:
2026-02-25 16:28:47 -05:00
parent fedfc21425
commit 3324c4f156
35 changed files with 3337 additions and 67 deletions

View File

@@ -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;
}

View 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>

View 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;

View File

@@ -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> = {

View 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);
}

View 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,
};
}

View File

@@ -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}

View File

@@ -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>