feat(frontend): polish allocation report filters

Improve filter bar layout and multi-select UX:
- Add MultiSelectDropdown component with tag-based selection
- Fix filter grid alignment for consistent spacing
- Update allocation report page with improved filter styling

Part of enhanced-allocation UI polish.
This commit is contained in:
2026-03-08 19:13:19 -04:00
parent 72db9c2004
commit ec15386b52
2 changed files with 177 additions and 40 deletions

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
interface Option {
id: string;
label: string;
}
const dispatch = createEventDispatcher<{ change: string[] }>();
export let label = '';
export let options: Option[] = [];
export let selected: string[] = [];
export let placeholder = 'Select options';
export let disabled = false;
let open = false;
let container: HTMLDivElement | null = null;
function updateSelection(values: string[]) {
selected = values;
dispatch('change', selected);
}
function toggleValue(id: string) {
const values = selected.includes(id)
? selected.filter((value) => value !== id)
: [...selected, id];
updateSelection(values);
}
function removeValue(id: string) {
const values = selected.filter((value) => value !== id);
updateSelection(values);
}
function handleOutsideClick(event: MouseEvent) {
if (container && !container.contains(event.target as Node)) {
open = false;
}
}
onMount(() => {
document.addEventListener('click', handleOutsideClick);
return () => document.removeEventListener('click', handleOutsideClick);
});
</script>
<div class="flex flex-col gap-1.5" bind:this={container}>
{#if label}
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide">{label}</label>
{/if}
<div class="relative">
<button
type="button"
class={`w-full rounded-md border bg-white px-3 py-[0.4375rem] text-sm flex items-center justify-between gap-2 transition focus:outline-none focus:ring-2 focus:ring-indigo-500/20 ${disabled ? 'border-gray-200 text-gray-400 cursor-not-allowed' : 'border-gray-300 text-gray-900 shadow-sm hover:border-indigo-400'}`}
aria-haspopup="listbox"
aria-expanded={open}
on:click|stopPropagation={() => {
if (disabled) return;
open = !open;
}}
>
<div class="flex-1 flex flex-wrap gap-1.5 items-center min-h-[1.375rem]">
{#if selected.length === 0}
<span class="text-sm text-gray-400">{placeholder}</span>
{:else}
{#each selected as id}
<span class="inline-flex items-center gap-1 rounded border border-indigo-200 bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-800 whitespace-nowrap">
{options.find((option) => option.id === id)?.label ?? id}
<button
type="button"
class="text-indigo-400 hover:text-indigo-700 focus:outline-none ml-0.5"
on:click|preventDefault|stopPropagation={() => removeValue(id)}
>
×
</button>
</span>
{/each}
{/if}
</div>
<span class="text-gray-400 shrink-0"></span>
</button>
{#if open}
<div
class="absolute z-10 mt-1 w-full max-h-60 overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
on:click|stopPropagation
>
{#if options.length === 0}
<p class="px-3 py-2 text-sm text-gray-500">No options available</p>
{:else}
{#each options as option}
<label class="flex items-center gap-3 px-3 py-2 text-sm text-gray-900 hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selected.includes(option.id)}
on:change={() => toggleValue(option.id)}
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>{option.label}</span>
</label>
{/each}
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -1,7 +1,6 @@
<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, FileText, AlertCircle, Users, FolderKanban } from 'lucide-svelte';
@@ -16,6 +15,8 @@
import { projectService, type Project } from '$lib/services/projectService';
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
import MultiSelectDropdown from '$lib/components/common/MultiSelectDropdown.svelte';
// State
let report = $state<ReportResponse | null>(null);
let projects = $state<Project[]>([]);
@@ -83,8 +84,29 @@
loadReport();
}
function formatHours(hours: number): string {
return `${hours.toFixed(1)}h`;
function parseDateString(value: string): Date {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
function formatDisplayDate(value: string): string {
if (!value) return '';
const date = parseDateString(value);
return isNaN(date.getTime()) ? '' : date.toLocaleDateString();
}
function formatMonthLabel(value: string): string {
if (!value) return '';
const normalized = value.split('-').length === 3 ? value : `${value}-01`;
const date = parseDateString(normalized);
return isNaN(date.getTime())
? ''
: date.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
function formatHours(hours: number | null | undefined): string {
const value = typeof hours === 'number' && isFinite(hours) ? hours : 0;
return `${value.toFixed(1)}h`;
}
function getViewTypeBadgeClass(viewType: ViewType): string {
@@ -109,59 +131,67 @@
/>
<!-- Filters -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 items-end">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-5 mb-6">
<div class="grid grid-cols-[auto_auto_1fr_auto_auto] gap-x-4 gap-y-3 items-end max-lg:grid-cols-2 max-lg:gap-3 max-sm:grid-cols-1">
<!-- Start Date -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Start Date</label>
<input
type="date"
bind:value={startDate}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-[0.4375rem] text-sm text-gray-900 shadow-sm transition hover:border-indigo-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<!-- End Date -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide">End Date</label>
<input
type="date"
bind:value={endDate}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-[0.4375rem] text-sm text-gray-900 shadow-sm transition hover:border-indigo-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Projects</label>
<select
bind:value={selectedProjects}
multiple
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 h-10"
>
{#each projects as project}
<option value={project.id}>{project.code} - {project.title}</option>
{/each}
</select>
<!-- Projects multi-select — fills remaining space -->
<div class="min-w-0 max-lg:col-span-2 max-sm:col-span-1">
<MultiSelectDropdown
label="Projects"
placeholder="All projects"
options={projects.map((project) => ({ id: project.id, label: `${project.code} - ${project.title}` }))}
bind:selected={selectedProjects}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">View Mode</label>
<!-- View Mode -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide">View Mode</label>
<select
bind:value={viewMode}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-[0.4375rem] text-sm text-gray-900 shadow-sm transition hover:border-indigo-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none"
>
<option value="aggregate">📊 Aggregate</option>
<option value="detailed">📋 Detailed</option>
<option value="aggregate">Aggregate</option>
<option value="detailed">Detailed</option>
</select>
</div>
<!-- Apply -->
<div class="flex items-end">
<button
onclick={handleApplyFilters}
type="button"
on:click={handleApplyFilters}
disabled={loading || !startDate || !endDate}
class="inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300"
class="inline-flex items-center justify-center px-5 py-[0.4375rem] rounded-md text-sm font-medium text-white bg-indigo-600 shadow-sm transition hover:bg-indigo-700 active:bg-indigo-800 disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-indigo-500/20 whitespace-nowrap"
>
{#if loading}
<span class="animate-spin mr-2"></span> Loading...
<span class="animate-spin mr-2"></span> Loading
{:else}
Apply Filters
{/if}
</button>
</div>
</div>
</div>
<!-- Error State -->
{#if error}
@@ -187,7 +217,7 @@
{formatViewType(report.view_type)}
</span>
<span class="text-sm text-gray-500">
Period: {new Date(report.period.start).toLocaleDateString()} - {new Date(report.period.end).toLocaleDateString()}
Period: {formatDisplayDate(report.period.start)} - {formatDisplayDate(report.period.end)}
</span>
</div>
@@ -319,7 +349,7 @@
<tbody class="divide-y divide-gray-200">
{#each project.months as month}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-900">{new Date(month.month + '-01').toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</td>
<td class="px-4 py-2 text-sm text-gray-900">{formatMonthLabel(month.month)}</td>
<td class="px-4 py-2 text-right text-sm">
{#if month.is_blank}
<span class="text-gray-400 italic">blank</span>