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:
265
frontend/src/lib/components/common/MultiSelect.svelte
Normal file
265
frontend/src/lib/components/common/MultiSelect.svelte
Normal 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>
|
||||
144
frontend/src/lib/components/common/Pagination.svelte
Normal file
144
frontend/src/lib/components/common/Pagination.svelte
Normal 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">…</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}
|
||||
@@ -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';
|
||||
|
||||
64
frontend/src/lib/services/actualsService.ts
Normal file
64
frontend/src/lib/services/actualsService.ts
Normal 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;
|
||||
87
frontend/src/lib/types/actuals.ts
Normal file
87
frontend/src/lib/types/actuals.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user