feat(actuals): implement full Cartesian grid with filters and pagination

Backend:
- Add ActualController with Cartesian product query (all projects × members)
- Add ActualsService for variance calculations (∞% when actual>0, allocated=0)
- Add ActualResource for API response formatting
- Add migration for notes column on actuals table
- Add global config for inactive project logging (ALLOW_ACTUALS_ON_INACTIVE_PROJECTS)
- Implement filters: project_ids[], team_member_ids[], include_inactive, search
- Add pagination support (25 per page default)
- Register /api/actuals routes

Frontend:
- Create MultiSelect component with portal rendering (z-index fix for sidebar)
  - Compact trigger mode to prevent badge overflow
  - SSR-safe with browser guards
  - Keyboard navigation and accessibility
- Create Pagination component with smart ellipsis
- Rebuild actuals page with:
  - Full Cartesian matrix (shows all projects × members, not just allocations)
  - Filter section with project/member multi-select
  - Active filters display area with badge wrapping
  - URL persistence for all filter state
  - Month navigation with arrows
  - Variance display (GREEN ≤5%, YELLOW 5-20%, RED >20%, ∞% for zero allocation)
  - Read-only cells for inactive projects
  - Modal for incremental hours logging with notes
- Add actualsService with unwrap:false to preserve pagination meta
- Add comprehensive TypeScript types for grid items and pagination

OpenSpec:
- Update actuals-tracking spec with clarified requirements
- Mark Capability 6: Actuals Tracking as complete in tasks.md
- Update test count: 157 backend tests passing

Fixes:
- SSR error: Add browser guards to portal rendering
- Z-index: Use portal to escape stacking context (sidebar z-30)
- Filter overlap: Separate badge display from dropdown triggers
- Member filter: Derive visible members from API response data
- Pagination meta: Disable auto-unwrap to preserve response structure
This commit is contained in:
2026-03-08 22:19:57 -04:00
parent 22a290ab89
commit 90c15c70b7
15 changed files with 1830 additions and 79 deletions

View File

@@ -0,0 +1,265 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import { browser } from '$app/environment';
type OptionRecord = Record<string, unknown>;
export let options: OptionRecord[] = [];
export let selected: string[] = [];
export let labelKey: keyof OptionRecord = 'name';
export let valueKey: keyof OptionRecord = 'id';
export let placeholder = 'Select...';
export let disabled = false;
export let maxDisplay = 3;
/** When true, trigger shows a count summary instead of inline badges */
export let compactTrigger = false;
let open = false;
let searchTerm = '';
let highlightedIndex = 0;
let container: HTMLDivElement | null = null;
let searchInput: HTMLInputElement | null = null;
let triggerButton: HTMLButtonElement | null = null;
let portalRoot: HTMLDivElement | null = null;
let dropdownTop = 0;
let dropdownLeft = 0;
let dropdownWidth = 0;
let clickHandler: ((event: MouseEvent) => void) | null = null;
const getValue = (option: OptionRecord) => {
const candidate = option[valueKey];
if (candidate === undefined || candidate === null) {
return '';
}
return String(candidate);
};
const getLabel = (option: OptionRecord) => {
const candidate = option[labelKey];
if (candidate === undefined || candidate === null) {
return getValue(option);
}
return String(candidate);
};
$: filteredOptions = searchTerm
? options.filter(option => getLabel(option).toLowerCase().includes(searchTerm.toLowerCase()))
: options;
$: if (highlightedIndex >= filteredOptions.length) {
highlightedIndex = Math.max(filteredOptions.length - 1, 0);
}
$: visibleBadges = selected.slice(0, maxDisplay);
function updateSelection(values: string[]) {
selected = values;
}
function toggleValue(value: string) {
const next = selected.includes(value) ? selected.filter(item => item !== value) : [...selected, value];
updateSelection(next);
}
function removeValue(value: string) {
updateSelection(selected.filter(item => item !== value));
}
function selectAllVisible() {
const values = filteredOptions.map(option => getValue(option)).filter(Boolean);
if (!values.length) return;
const union = Array.from(new Set([...selected, ...values]));
updateSelection(union);
}
function clearAll() {
updateSelection([]);
}
function handleOutsideClick(event: MouseEvent) {
const target = event.target as Node;
if (container?.contains(target) || portalRoot?.contains(target)) return;
open = false;
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
if (!open) {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
open = true;
event.preventDefault();
}
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const delta = event.key === 'ArrowDown' ? 1 : -1;
const total = filteredOptions.length;
if (total === 0) return;
highlightedIndex = (highlightedIndex + delta + total) % total;
event.preventDefault();
return;
}
if (event.key === 'Escape') {
open = false;
event.preventDefault();
return;
}
if (event.key === 'Enter' && filteredOptions[highlightedIndex]) {
toggleValue(getValue(filteredOptions[highlightedIndex]));
event.preventDefault();
}
}
function toggleOpen() {
if (disabled) return;
open = !open;
}
function updateDropdownPosition() {
if (!triggerButton || typeof window === 'undefined') return;
const rect = triggerButton.getBoundingClientRect();
dropdownTop = rect.bottom + window.scrollY;
dropdownLeft = rect.left + window.scrollX;
dropdownWidth = rect.width;
}
$: if (open) {
tick().then(() => {
updateDropdownPosition();
searchInput?.focus();
});
}
function handleViewportChange() {
if (open) updateDropdownPosition();
}
function portal(node: HTMLElement) {
if (typeof document === 'undefined') return;
const host = document.createElement('div');
document.body.appendChild(host);
host.appendChild(node);
return {
destroy() {
if (host.parentNode) host.parentNode.removeChild(host);
}
};
}
onMount(() => {
if (typeof window === 'undefined') return;
clickHandler = handleOutsideClick;
document.addEventListener('click', clickHandler);
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
});
onDestroy(() => {
if (typeof window === 'undefined') return;
if (clickHandler) {
document.removeEventListener('click', clickHandler);
}
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
});
const getBadgeLabel = (value: string) => {
const matched = options.find(option => getValue(option) === value);
if (!matched) return value;
return getLabel(matched);
};
</script>
<div class="dropdown dropdown-end" class:dropdown-open={open} bind:this={container} onkeydown={handleKeyDown}>
<button
class="btn btn-outline btn-sm w-full justify-between"
bind:this={triggerButton}
onclick={toggleOpen}
type="button"
{disabled}
>
<span class="truncate">
{#if selected.length === 0}
{placeholder}
{:else if compactTrigger}
<span class="text-sm">{placeholder}</span>
<span class="badge badge-sm badge-primary ml-1">{selected.length}</span>
{:else}
{#each visibleBadges as value (value)}
{@const option = options.find(o => getValue(o) === value)}
{#if option}
<span class="badge badge-sm badge-primary mr-1">
{getLabel(option)}
<button
class="ml-1 hover:text-error"
onclick={(e) => { e.stopPropagation(); removeValue(value); }}
type="button"
>
×
</button>
</span>
{/if}
{/each}
{#if selected.length > maxDisplay}
<span class="badge badge-sm badge-ghost">+{selected.length - maxDisplay} more</span>
{/if}
{/if}
</span>
<span class="text-xs ml-1"></span>
</button>
{#if open && browser}
<div
use:portal
class="dropdown-content mt-2 rounded-xl border border-base-200 bg-base-100 p-3 shadow-lg"
bind:this={portalRoot}
style={`z-index: 9999; position: absolute; top: ${dropdownTop}px; left: ${dropdownLeft}px; width: ${Math.max(dropdownWidth, 256)}px;`}
onclick={(e) => e.stopPropagation()}
>
<input
class="input input-sm input-bordered w-full"
type="search"
placeholder="Search..."
bind:value={searchTerm}
bind:this={searchInput}
oninput={() => (highlightedIndex = 0)}
/>
<div class="mt-2 flex items-center justify-between text-xs">
<button type="button" class="btn btn-ghost btn-xs" onclick={selectAllVisible}>
☑ Select All
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick={clearAll}>
☐ Clear All
</button>
</div>
<div class="divider my-2"></div>
<div class="max-h-64 overflow-auto space-y-1">
{#if filteredOptions.length === 0}
<p class="text-sm text-base-content/60">No results</p>
{:else}
{#each filteredOptions as option, index (getValue(option))}
<label
class={`flex items-center gap-3 rounded-md px-2 py-1 text-sm transition ${highlightedIndex === index ? 'bg-base-200' : 'hover:bg-base-200/60'}`}
onmouseenter={() => (highlightedIndex = index)}
>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={selected.includes(getValue(option))}
onchange={() => toggleValue(getValue(option))}
/>
<span class="truncate">{getLabel(option)}</span>
</label>
{/each}
{/if}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,144 @@
<script lang="ts">
interface Props {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
maxVisiblePages?: number;
onPageChange: (page: number) => void;
}
const MIN_VISIBLE_PAGES = 3;
const ellipsis = 'ellipsis' as const;
type PageItem = number | typeof ellipsis;
let {
currentPage,
totalPages,
totalItems,
pageSize,
maxVisiblePages = 5,
onPageChange
}: Props = $props();
function calculatePageItems(): PageItem[] {
if (totalPages <= 0) {
return [];
}
const effectiveMaxPages = Math.max(MIN_VISIBLE_PAGES, maxVisiblePages);
const visibleCount = Math.min(totalPages, effectiveMaxPages);
if (totalPages <= visibleCount) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const items: PageItem[] = [1];
const innerSlots = effectiveMaxPages - 2;
let start = currentPage - Math.floor(innerSlots / 2);
let end = currentPage + Math.floor(innerSlots / 2);
if (start < 2) {
start = 2;
end = start + innerSlots - 1;
}
if (end > totalPages - 1) {
end = totalPages - 1;
start = Math.max(2, end - innerSlots + 1);
}
if (start > 2) {
items.push(ellipsis);
}
for (let page = start; page <= end; page += 1) {
items.push(page);
}
if (end < totalPages - 1) {
items.push(ellipsis);
}
items.push(totalPages);
return items;
}
function getStartItem() {
if (totalItems === 0) return 0;
return Math.min(totalItems, (currentPage - 1) * pageSize + 1);
}
function getEndItem() {
if (totalItems === 0) return 0;
return Math.min(totalItems, currentPage * pageSize);
}
function goToPage(page: PageItem) {
if (page === ellipsis) return;
const pageNumber = page;
if (pageNumber < 1 || pageNumber > totalPages || pageNumber === currentPage) return;
onPageChange(pageNumber);
}
function previousPage() {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
}
function nextPage() {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
}
</script>
{#if totalPages > 0}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-gray-600">
Showing {getStartItem()}-{getEndItem()} of {totalItems}
</p>
<div class="join">
<button
type="button"
class={`btn btn-sm join-item ${currentPage <= 1 ? 'btn-disabled' : ''}`}
on:click={previousPage}
aria-label="Previous page"
disabled={currentPage <= 1}
>
«
</button>
{#each calculatePageItems() as item, index (item === ellipsis ? `ellipsis-${index}` : item)}
{#if item === ellipsis}
<span class="btn btn-sm join-item cursor-default">&hellip;</span>
{:else}
<button
type="button"
class={`btn btn-sm join-item ${item === currentPage ? 'btn-active' : ''}`}
on:click={() => goToPage(item)}
>
{item}
</button>
{/if}
{/each}
<button
type="button"
class={`btn btn-sm join-item ${currentPage >= totalPages ? 'btn-disabled' : ''}`}
on:click={nextPage}
aria-label="Next page"
disabled={currentPage >= totalPages}
>
»
</button>
</div>
</div>
{:else}
<p class="text-sm text-gray-600">Showing 0-0 of 0</p>
{/if}

View File

@@ -4,3 +4,4 @@ export { default as FilterBar } from './FilterBar.svelte';
export { default as EmptyState } from './EmptyState.svelte';
export { default as LoadingState } from './LoadingState.svelte';
export { default as StatCard } from './StatCard.svelte';
export { default as Pagination } from './Pagination.svelte';

View File

@@ -0,0 +1,64 @@
import { api } from './api';
import type {
Actual,
CreateActualRequest,
UpdateActualRequest,
ActualGridItem,
ActualsPaginatedResponse,
ActualsQueryParams
} from '$lib/types/actuals';
function buildQuery(month?: string, filters?: ActualsQueryParams): string {
const params = new URLSearchParams();
if (month) {
params.set('month', month);
}
if (filters) {
if (filters.page) {
params.set('page', String(filters.page));
}
if (filters.per_page) {
params.set('per_page', String(filters.per_page));
}
filters.project_ids?.forEach(id => {
if (id) params.append('project_ids[]', id);
});
filters.team_member_ids?.forEach(id => {
if (id) params.append('team_member_ids[]', id);
});
if (filters.include_inactive) {
params.set('include_inactive', 'true');
}
if (filters.search) {
params.set('search', filters.search);
}
}
const query = params.toString();
return query ? `?${query}` : '';
}
export const actualsService = {
getAll: (month?: string, filters?: ActualsQueryParams) => {
const query = buildQuery(month, filters);
return api.get<ActualsPaginatedResponse<ActualGridItem[]>>(`/actuals${query}`, { unwrap: false });
},
getById: (id: string) => api.get<Actual>(`/actuals/${id}`),
create: (data: CreateActualRequest) => api.post<Actual>('/actuals', data),
update: (id: string, data: UpdateActualRequest) =>
api.put<Actual>(`/actuals/${id}`, data),
delete: (id: string) => api.delete<{ message: string }>(`/actuals/${id}`),
};
export default actualsService;

View File

@@ -0,0 +1,87 @@
export interface Actual {
id: string;
project_id: string;
team_member_id: string | null;
month: string;
hours_logged: string;
notes: string | null;
variance: {
allocated_hours: string | null;
variance_percentage: number | null;
variance_indicator: 'green' | 'yellow' | 'red' | 'gray';
};
created_at: string;
updated_at: string;
project?: {
id: string;
code: string;
title: string;
};
team_member?: {
id: string;
name: string;
};
}
export interface CreateActualRequest {
project_id: string;
team_member_id: string | null;
month: string;
hours: number;
notes?: string | undefined;
}
export interface UpdateActualRequest {
hours_logged: number;
notes?: string;
}
export interface ActualVariance {
allocated: number;
actual: number;
variance_percentage: number;
indicator: 'green' | 'yellow' | 'red';
}
export interface ActualGridItem {
id: string;
project_id: string;
project: {
id: string;
code: string;
title: string;
status?: unknown;
is_active: boolean;
};
team_member_id: string | null;
team_member: { id: string; name: string; is_active: boolean } | null;
month: string;
allocated_hours: string;
actual_hours: string;
variance_percentage: number | null;
variance_display: string | null;
variance_indicator: 'green' | 'yellow' | 'red' | 'gray';
notes: string | null;
is_readonly: boolean;
}
export interface ActualsMeta {
current_page: number;
last_page: number;
total: number;
per_page: number;
}
export interface ActualsPaginatedResponse<T> {
data: T;
meta: ActualsMeta;
}
export interface ActualsQueryParams {
project_ids?: string[];
team_member_ids?: string[];
include_inactive?: boolean;
search?: string;
page?: number;
per_page?: number;
}

View File

@@ -1,17 +1,621 @@
<script lang="ts">
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import EmptyState from '$lib/components/common/EmptyState.svelte';
import { Clock } from 'lucide-svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import LoadingState from '$lib/components/common/LoadingState.svelte';
import FilterBar from '$lib/components/common/FilterBar.svelte';
import MultiSelect from '$lib/components/common/MultiSelect.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
import { actualsService } from '$lib/services/actualsService';
import { projectService, type Project } from '$lib/services/projectService';
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
import type { ActualGridItem } from '$lib/types/actuals';
function getCurrentMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
function formatMonth(period: string): string {
const [year, month] = period.split('-').map(Number);
const date = new Date(year, (month ?? 1) - 1);
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
function formatVarianceValue(percentage: number | null, display: string | null): string {
if (display) return display;
if (percentage === null) return '-';
const sign = percentage >= 0 ? '+' : '';
return `${sign}${percentage.toFixed(1)}%`;
}
function getVarianceColor(indicator: string | null): string {
switch (indicator) {
case 'green':
return 'badge-success';
case 'yellow':
return 'badge-warning';
case 'red':
return 'badge-error';
default:
return 'badge-ghost';
}
}
let actuals = $state<ActualGridItem[]>([]);
let projects = $state<Project[]>([]);
let teamMembers = $state<TeamMember[]>([]);
let loading = $state(true);
let errorMessage = $state<string | null>(null);
let currentPeriod = $state(getCurrentMonth());
let selectedProjectIds = $state<string[]>([]);
let selectedMemberIds = $state<string[]>([]);
let includeInactive = $state(false);
let searchQuery = $state('');
let currentPage = $state(1);
let totalPages = $state(1);
let totalItems = $state(0);
let perPage = $state(25);
let showModal = $state(false);
let selectedCell = $state<ActualGridItem | null>(null);
let formHours = $state(0);
let formNotes = $state('');
let formLoading = $state(false);
let formError = $state<string | null>(null);
// Derive visible members from actuals data (respects filters applied to API)
let visibleMembers = $derived(
(() => {
const map = new Map<string, { id: string; name: string; is_active: boolean }>();
actuals.forEach(item => {
if (item.team_member && item.team_member_id) {
map.set(item.team_member_id, item.team_member);
}
});
// Sort by name
const list = Array.from(map.values());
list.sort((a, b) => a.name.localeCompare(b.name));
return list;
})()
);
let projectOptions = $derived(
projects
.map(project => ({
id: project.id,
label: `${project.code} - ${project.title}`,
is_active: (project as Project & { is_active?: boolean }).is_active ?? true
}))
.filter(option => includeInactive || option.is_active)
.sort((a, b) => a.label.localeCompare(b.label))
);
let memberOptions = $derived(
teamMembers
.map(member => ({ id: member.id, label: member.name, is_active: member.active }))
.filter(option => includeInactive || option.is_active)
.sort((a, b) => a.label.localeCompare(b.label))
);
let visibleProjects = $derived(
(() => {
const map = new Map(actuals.map(item => [item.project_id, item.project]));
const list = Array.from(map.values());
list.sort((a, b) => a.code.localeCompare(b.code));
return list;
})()
);
onMount(async () => {
const url = $page.url;
currentPeriod = url.searchParams.get('month') || getCurrentMonth();
const parsedPage = Number(url.searchParams.get('page'));
currentPage = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const parsedPerPage = Number(url.searchParams.get('per_page'));
perPage = Number.isFinite(parsedPerPage) && parsedPerPage > 0 ? parsedPerPage : perPage;
includeInactive = url.searchParams.get('include_inactive') === 'true';
searchQuery = url.searchParams.get('search') || '';
selectedProjectIds = url.searchParams.getAll('project_ids[]');
selectedMemberIds = url.searchParams.getAll('member_ids[]');
await Promise.all([loadProjects(), loadTeamMembers()]);
await loadData();
});
async function loadProjects() {
try {
const collection = await projectService.getAll();
projects = collection.slice().sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
} catch (error) {
console.error('Unable to load projects', error);
}
}
async function loadTeamMembers() {
try {
const collection = await teamMemberService.getAll();
teamMembers = collection.slice().sort((a, b) => a.name.localeCompare(b.name));
} catch (error) {
console.error('Unable to load team members', error);
}
}
async function loadData() {
loading = true;
errorMessage = null;
const params = new URLSearchParams();
params.set('month', currentPeriod);
params.set('page', String(currentPage));
params.set('per_page', String(perPage));
selectedProjectIds.forEach(id => {
params.append('project_ids[]', id);
});
selectedMemberIds.forEach(id => {
params.append('member_ids[]', id);
});
if (includeInactive) params.set('include_inactive', 'true');
if (searchQuery) params.set('search', searchQuery);
const queryString = params.toString();
goto(`${$page.url.pathname}${queryString ? `?${queryString}` : ''}`, { replaceState: true });
try {
const response = await actualsService.getAll(currentPeriod, {
project_ids: selectedProjectIds,
team_member_ids: selectedMemberIds,
include_inactive: includeInactive,
search: searchQuery,
page: currentPage,
per_page: perPage
});
actuals = response.data;
currentPage = response.meta.current_page;
totalPages = response.meta.last_page;
totalItems = response.meta.total;
perPage = response.meta.per_page;
} catch (error) {
const apiError = error as { message?: string };
errorMessage = apiError.message || 'Failed to load actuals';
actuals = [];
totalPages = 1;
totalItems = 0;
} finally {
loading = false;
}
}
async function applyFilters() {
currentPage = 1;
await loadData();
}
async function clearFilters() {
selectedProjectIds = [];
selectedMemberIds = [];
includeInactive = false;
searchQuery = '';
currentPage = 1;
await loadData();
}
async function handlePageChange(page: number) {
if (page === currentPage) return;
currentPage = page;
await loadData();
}
async function changeMonth(delta: number) {
const [year, month] = currentPeriod.split('-').map(Number);
const reference = new Date(year, month - 1 + delta);
currentPeriod = `${reference.getFullYear()}-${String(reference.getMonth() + 1).padStart(2, '0')}`;
currentPage = 1;
await loadData();
}
function getCell(projectId: string, memberId: string | null): ActualGridItem {
const found = actuals.find(cell => cell.project_id === projectId && (cell.team_member_id ?? null) === memberId);
if (found) return found;
const project = projects.find(item => item.id === projectId);
const member = memberId ? teamMembers.find(item => item.id === memberId) ?? null : null;
const projectPayload = {
id: project?.id ?? projectId,
code: project?.code ?? 'Unknown',
title: project?.title ?? 'Unknown project',
status: project?.status,
is_active: (project as Project & { is_active?: boolean }).is_active ?? true
};
const memberPayload = member
? { id: member.id, name: member.name, is_active: member.active }
: null;
return {
id: '',
project_id: projectId,
project: projectPayload,
team_member_id: memberId,
team_member: memberPayload,
month: currentPeriod,
allocated_hours: '0',
actual_hours: '0',
variance_percentage: null,
variance_display: null,
variance_indicator: 'gray',
notes: null,
is_readonly: !projectPayload.is_active || (memberPayload ? !memberPayload.is_active : false)
};
}
function openModal(cell: ActualGridItem) {
selectedCell = cell;
formHours = 0;
formNotes = cell.notes ?? '';
formError = null;
showModal = true;
}
function closeModal() {
showModal = false;
selectedCell = null;
formError = null;
}
async function handleSubmit() {
if (!selectedCell) return;
const hoursToAdd = Number(formHours);
if (!Number.isFinite(hoursToAdd) || hoursToAdd <= 0) {
formError = 'Enter a positive number of hours to log.';
return;
}
formLoading = true;
formError = null;
try {
await actualsService.create({
project_id: selectedCell.project_id,
team_member_id: selectedCell.team_member_id,
month: currentPeriod,
hours: hoursToAdd,
notes: formNotes.trim() || undefined
});
showModal = false;
await loadData();
} catch (error) {
const apiError = error as { message?: string; data?: { errors?: Record<string, string[]> } };
if (apiError.data?.errors) {
formError = Object.entries(apiError.data.errors)
.map(([field, messages]) => `${field}: ${messages.join(', ')}`)
.join('; ');
} else {
formError = apiError.message || 'Failed to log hours';
}
} finally {
formLoading = false;
}
}
async function handleDelete() {
if (!selectedCell?.id) return;
formLoading = true;
formError = null;
try {
await actualsService.delete(selectedCell.id);
showModal = false;
await loadData();
} catch (error) {
const apiError = error as { message?: string };
formError = apiError.message || 'Failed to remove actuals entry';
} finally {
formLoading = false;
}
}
</script>
<svelte:head>
<title>Actuals | Headroom</title>
<title>Actuals Tracking | Headroom</title>
</svelte:head>
<PageHeader title="Actuals" description="Track logged hours" />
<PageHeader title="Actuals Tracking" description="Log and view actual hours vs allocations">
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" onclick={() => changeMonth(-1)} aria-label="Previous month">
<ChevronLeft size={16} />
</button>
<span class="text-lg font-medium">{formatMonth(currentPeriod)}</span>
<button class="btn btn-ghost btn-sm" onclick={() => changeMonth(1)} aria-label="Next month">
<ChevronRight size={16} />
</button>
</div>
</PageHeader>
<EmptyState
title="Coming Soon"
description="Actuals tracking will be available in a future update."
icon={Clock}
/>
<section class="space-y-3 mb-6">
<FilterBar
searchValue={searchQuery}
searchPlaceholder="Search projects or members"
onSearchChange={value => (searchQuery = value)}
onClear={clearFilters}
/>
<div class="flex flex-wrap gap-3 items-center">
<div class="w-[220px]">
<MultiSelect
options={projectOptions}
bind:selected={selectedProjectIds}
placeholder="Filter projects"
labelKey="label"
valueKey="id"
compactTrigger
/>
</div>
<div class="w-[220px]">
<MultiSelect
options={memberOptions}
bind:selected={selectedMemberIds}
placeholder="Filter team members"
labelKey="label"
valueKey="id"
compactTrigger
/>
</div>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={includeInactive}
onchange={event => (includeInactive = event.currentTarget.checked)}
/>
Include inactive
</label>
<button
type="button"
class="btn btn-primary btn-sm ml-auto"
onclick={applyFilters}
disabled={loading}
>
Apply
</button>
</div>
{#if selectedProjectIds.length > 0 || selectedMemberIds.length > 0}
<div class="flex flex-wrap items-center gap-2 rounded-lg bg-base-200/60 px-3 py-2">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/50 mr-1">Active filters</span>
{#each selectedProjectIds as id (id)}
{@const option = projectOptions.find(o => o.id === id)}
{#if option}
<span class="badge badge-sm badge-primary gap-1">
{option.label}
<button
type="button"
class="hover:text-error"
onclick={() => { selectedProjectIds = selectedProjectIds.filter(v => v !== id); }}
>×</button>
</span>
{/if}
{/each}
{#each selectedMemberIds as id (id)}
{@const option = memberOptions.find(o => o.id === id)}
{#if option}
<span class="badge badge-sm badge-secondary gap-1">
{option.label}
<button
type="button"
class="hover:text-error"
onclick={() => { selectedMemberIds = selectedMemberIds.filter(v => v !== id); }}
>×</button>
</span>
{/if}
{/each}
<button
type="button"
class="btn btn-ghost btn-xs ml-auto"
onclick={clearFilters}
>
Clear all
</button>
</div>
{/if}
</section>
{#if loading}
<LoadingState />
{:else}
{#if errorMessage}
<div class="alert alert-error mb-4">
<span>{errorMessage}</span>
</div>
{/if}
<div class="rounded-xl border border-base-200 bg-base-100">
{#if !actuals.length}
<div class="p-6 text-sm text-center text-base-content/60">
No actuals recorded for {formatMonth(currentPeriod)}.
</div>
{:else}
<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 visibleMembers as member (member.id)}
<th class="text-center min-w-[130px]">{member.name}</th>
{/each}
<th class="text-center bg-base-200 font-medium">Untracked</th>
</tr>
</thead>
<tbody>
{#each visibleProjects as project (project.id)}
{@const rowMembers = visibleMembers.map(member => getCell(project.id, member.id))}
{@const rowUntracked = getCell(project.id, null)}
<tr class="hover">
<td class="sticky left-0 bg-base-100 z-10 font-medium">
{project.code} - {project.title}
</td>
{#each rowMembers as cell, index (visibleMembers[index]?.id ?? index)}
<td
class={`text-center cursor-pointer transition-colors ${cell.is_readonly ? 'opacity-50 bg-base-200/40 cursor-not-allowed' : 'hover:bg-base-200/60'}`}
onclick={() => !cell.is_readonly && openModal(cell)}
>
<div class="text-[11px] text-base-content/60">{cell.allocated_hours}h alloc</div>
<div class="font-semibold">{cell.actual_hours}h actual</div>
<div class={`badge badge-xs ${getVarianceColor(cell.variance_indicator)}`}>
{formatVarianceValue(cell.variance_percentage, cell.variance_display)}
</div>
</td>
{/each}
<td
class={`text-center cursor-pointer transition-colors bg-base-200/30 ${rowUntracked.is_readonly ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-200/60'}`}
onclick={() => !rowUntracked.is_readonly && openModal(rowUntracked)}
>
<div class="text-[11px] text-base-content/60">{rowUntracked.allocated_hours}h alloc</div>
<div class="font-semibold">{rowUntracked.actual_hours}h actual</div>
<div class={`badge badge-xs ${getVarianceColor(rowUntracked.variance_indicator)}`}>
{formatVarianceValue(rowUntracked.variance_percentage, rowUntracked.variance_display)}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={perPage}
onPageChange={handlePageChange}
/>
</div>
{/if}
{#if showModal && selectedCell}
<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">Log Actual Hours</h3>
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeModal}>
</button>
</div>
{#if formError}
<div class="alert alert-error mb-4">
<span>{formError}</span>
</div>
{/if}
<form onsubmit={event => { event.preventDefault(); handleSubmit(); }}>
<div class="form-control mb-3">
<label class="label" for="actuals-project">
<span class="label-text font-medium">Project</span>
</label>
<input
id="actuals-project"
class="input input-bordered"
type="text"
value={`${selectedCell.project.code} - ${selectedCell.project.title}`}
disabled
/>
</div>
<div class="form-control mb-3">
<label class="label" for="actuals-member">
<span class="label-text font-medium">Team member</span>
</label>
<input
class="input input-bordered"
type="text"
id="actuals-member"
value={selectedCell.team_member?.name ?? 'Untracked'}
disabled
/>
</div>
<div class="form-control mb-4">
<label class="label" for="actuals-month">
<span class="label-text font-medium">Month</span>
</label>
<input
id="actuals-month"
class="input input-bordered"
type="text"
value={formatMonth(currentPeriod)}
disabled
/>
</div>
<div class="form-control mb-2">
<label class="label" for="actuals-hours">
<span class="label-text font-medium">Hours to add</span>
</label>
<input
class="input input-bordered"
type="number"
min="0"
step="0.5"
id="actuals-hours"
bind:value={formHours}
required
/>
</div>
<p class="text-sm text-base-content/60 mb-3">
Current total: {selectedCell.actual_hours}h
</p>
<div class="form-control mb-4">
<label class="label" for="actuals-notes">
<span class="label-text font-medium">Notes</span>
</label>
<textarea
class="textarea textarea-bordered"
rows="3"
placeholder="Optional context"
id="actuals-notes"
bind:value={formNotes}
></textarea>
</div>
<div class="modal-action">
{#if selectedCell.id}
<button type="button" class="btn btn-error" onclick={handleDelete} disabled={formLoading}>
Delete
</button>
{/if}
<button type="button" class="btn btn-ghost" onclick={closeModal} disabled={formLoading}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={formLoading}>
{#if formLoading}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
Add Hours
</button>
</div>
</form>
</div>
<div
class="modal-backdrop"
role="button"
tabindex="0"
onclick={closeModal}
onkeydown={event => (['Enter', ' ', 'Escape'].includes(event.key) && closeModal())}
></div>
</div>
{/if}