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:
107
frontend/src/lib/components/common/MultiSelectDropdown.svelte
Normal file
107
frontend/src/lib/components/common/MultiSelectDropdown.svelte
Normal 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>
|
||||
@@ -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,58 +131,66 @@
|
||||
/>
|
||||
|
||||
<!-- 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 -->
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user