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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import PageHeader from '$lib/components/layout/PageHeader.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 LoadingState from '$lib/components/common/LoadingState.svelte';
|
||||||
import EmptyState from '$lib/components/common/EmptyState.svelte';
|
import EmptyState from '$lib/components/common/EmptyState.svelte';
|
||||||
import { Calendar, FileText, AlertCircle, Users, FolderKanban } from 'lucide-svelte';
|
import { Calendar, FileText, AlertCircle, Users, FolderKanban } from 'lucide-svelte';
|
||||||
@@ -16,6 +15,8 @@
|
|||||||
import { projectService, type Project } from '$lib/services/projectService';
|
import { projectService, type Project } from '$lib/services/projectService';
|
||||||
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
|
import { teamMemberService, type TeamMember } from '$lib/services/teamMemberService';
|
||||||
|
|
||||||
|
import MultiSelectDropdown from '$lib/components/common/MultiSelectDropdown.svelte';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let report = $state<ReportResponse | null>(null);
|
let report = $state<ReportResponse | null>(null);
|
||||||
let projects = $state<Project[]>([]);
|
let projects = $state<Project[]>([]);
|
||||||
@@ -83,8 +84,29 @@
|
|||||||
loadReport();
|
loadReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHours(hours: number): string {
|
function parseDateString(value: string): Date {
|
||||||
return `${hours.toFixed(1)}h`;
|
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 {
|
function getViewTypeBadgeClass(viewType: ViewType): string {
|
||||||
@@ -109,59 +131,67 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-5 mb-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 items-end">
|
<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">
|
||||||
<div>
|
<!-- Start Date -->
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Start Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={startDate}
|
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>
|
||||||
<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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={endDate}
|
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>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Projects</label>
|
<!-- Projects multi-select — fills remaining space -->
|
||||||
<select
|
<div class="min-w-0 max-lg:col-span-2 max-sm:col-span-1">
|
||||||
bind:value={selectedProjects}
|
<MultiSelectDropdown
|
||||||
multiple
|
label="Projects"
|
||||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 h-10"
|
placeholder="All projects"
|
||||||
>
|
options={projects.map((project) => ({ id: project.id, label: `${project.code} - ${project.title}` }))}
|
||||||
{#each projects as project}
|
bind:selected={selectedProjects}
|
||||||
<option value={project.id}>{project.code} - {project.title}</option>
|
/>
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</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
|
<select
|
||||||
bind:value={viewMode}
|
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="aggregate">Aggregate</option>
|
||||||
<option value="detailed">📋 Detailed</option>
|
<option value="detailed">Detailed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Apply -->
|
||||||
|
<div class="flex items-end">
|
||||||
<button
|
<button
|
||||||
onclick={handleApplyFilters}
|
type="button"
|
||||||
|
on:click={handleApplyFilters}
|
||||||
disabled={loading || !startDate || !endDate}
|
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}
|
{#if loading}
|
||||||
<span class="animate-spin mr-2">⟳</span> Loading...
|
<span class="animate-spin mr-2">⟳</span> Loading…
|
||||||
{:else}
|
{:else}
|
||||||
Apply Filters
|
Apply Filters
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -187,7 +217,7 @@
|
|||||||
{formatViewType(report.view_type)}
|
{formatViewType(report.view_type)}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-500">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -319,7 +349,7 @@
|
|||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
{#each project.months as month}
|
{#each project.months as month}
|
||||||
<tr class="hover:bg-gray-50">
|
<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">
|
<td class="px-4 py-2 text-right text-sm">
|
||||||
{#if month.is_blank}
|
{#if month.is_blank}
|
||||||
<span class="text-gray-400 italic">blank</span>
|
<span class="text-gray-400 italic">blank</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user