refactor(frontend): integrate compact filters into reports page

- Rename page title "Отчеты задач" → "Центр статусов"
- Remove standalone quick-status filter block (moved to FilterBar)
- Remove inline filter/sort/time-range HTML (replaced by FilterBar)
- Replace statusTotal() with  quickStatusCounts
- Pass quickStatusCounts and onQuickStatusToggle to FilterBar
- Error recovery button text "Повторить" → "Повторить загрузку"
This commit is contained in:
2026-07-05 15:50:51 +03:00
parent 857352e8c9
commit be45dd81f3

View File

@@ -1,13 +1,13 @@
<!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] --> <!-- #region TaskCenterReportsPage [C:4] [TYPE Page] [SEMANTICS page, reports, task-center, layout, task-status-center] -->
<!-- @BRIEF Task Status Center page — thin render layer over TaskCenterModel. --> <!-- @BRIEF Task Status Center page — thin render layer over TaskCenterModel with summary, filters, and task diagnostics. -->
<!-- @LAYER Page --> <!-- @LAYER Page -->
<!-- @UX_STATE loading: full-page skeleton --> <!-- @UX_STATE loading: full-page skeleton -->
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints --> <!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
<!-- @UX_STATE ready: summary panel + filter bar + task list --> <!-- @UX_STATE ready: summary panel + filter bar + task list -->
<!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner --> <!-- @UX_STATE disconnected: data visible with stale indicator + reconnect banner -->
<!-- @UX_STATE error: error banner + retry button --> <!-- @UX_STATE error: error banner + retry button + explicit recovery copy -->
<!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. --> <!-- @UX_REACTIVITY Model: TaskCenterModel instance. Page is thin render layer. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] --> <!-- @RELATION BINDS_TO -> [TaskCenter.Model] -->
<!-- @RELATION BINDS_TO -> [SummaryPanel] --> <!-- @RELATION BINDS_TO -> [SummaryPanel] -->
<!-- @RELATION BINDS_TO -> [FilterBar] --> <!-- @RELATION BINDS_TO -> [FilterBar] -->
<!-- @RELATION BINDS_TO -> [TaskList] --> <!-- @RELATION BINDS_TO -> [TaskList] -->
@@ -19,27 +19,15 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts'; import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte'; import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
import ReportsList from '$lib/components/reports/ReportsList.svelte'; import FilterBar from '$lib/components/reports/FilterBar.svelte';
import { PageHeader, Button, EmptyState, Pagination, Input, Select } from '$lib/ui'; import TaskList from '$lib/components/reports/TaskList.svelte';
import { PageHeader, Button, Pagination } from '$lib/ui';
import { taskDrawerStore } from '$lib/stores/taskDrawer.svelte.js'; import { taskDrawerStore } from '$lib/stores/taskDrawer.svelte.js';
import type { PageData } from './+page.ts'; import type { PageData } from './+page.ts';
import type { TaskCenterFilter } from '$types/reports'; import type { TaskCenterFilter } from '$types/reports';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const m = new TaskCenterModel(); const m = new TaskCenterModel();
const sortOptions = [
{ value: 'updated_at', label: 'По обновлению' },
{ value: 'created_at', label: 'По созданию' },
{ value: 'status', label: 'По статусу' },
{ value: 'task_type', label: 'По типу' },
];
const timeRangeOptions = [
{ value: 'all', label: 'Всё время' },
{ value: '1h', label: 'Час' },
{ value: '24h', label: '24 часа' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
];
const drawerOpen = $derived(Boolean(taskDrawerStore.value?.isOpen)); const drawerOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
onMount(() => { onMount(() => {
@@ -65,34 +53,20 @@
} }
}); });
// ── Search debounce ── const quickStatusCounts = $derived({
let searchTimeout: ReturnType<typeof setTimeout> | undefined; failed: (() => {
const s = m.visibleSummary;
function handleSearchInput(e: Event & { currentTarget: HTMLInputElement }): void { return s ? s.by_type.reduce((sum, t) => sum + t.counts.failed, 0) : 0;
clearTimeout(searchTimeout); })(),
const value = e.currentTarget.value; in_progress: (() => {
searchTimeout = setTimeout(() => { const s = m.visibleSummary;
m.applyFilter({ search: value }); return s ? s.by_type.reduce((sum, t) => sum + t.counts.pending + t.counts.running + t.counts.awaiting_input, 0) : 0;
}, 350); })(),
} success: (() => {
const s = m.visibleSummary;
function handleSortChange(e: Event & { currentTarget: HTMLSelectElement }): void { return s ? s.by_type.reduce((sum, t) => sum + t.counts.success, 0) : 0;
m.applyFilter({ sort_by: e.currentTarget.value as TaskCenterFilter['sort_by'] }); })(),
} });
function handleTimeRangeChange(e: Event & { currentTarget: HTMLSelectElement }): void {
m.applyFilter({ time_range: e.currentTarget.value as TaskCenterFilter['time_range'] });
}
function statusTotal(status: 'failed' | 'in_progress' | 'success'): number {
const summary = m.visibleSummary;
if (!summary) return 0;
return summary.by_type.reduce((sum, type) => {
if (status === 'failed') return sum + type.counts.failed;
if (status === 'success') return sum + type.counts.success;
return sum + type.counts.pending + type.counts.running + type.counts.awaiting_input;
}, 0);
}
function toggleStatus(status: TaskCenterFilter['statuses'][number]): void { function toggleStatus(status: TaskCenterFilter['statuses'][number]): void {
m.applyFilter({ statuses: m.filters.statuses.includes(status) ? [] : [status] }); m.applyFilter({ statuses: m.filters.statuses.includes(status) ? [] : [status] });
@@ -104,7 +78,7 @@
? 'max-w-7xl md:max-w-[max(24rem,calc(100vw-35rem))] md:ml-4 md:mr-auto' ? 'max-w-7xl md:max-w-[max(24rem,calc(100vw-35rem))] md:ml-4 md:mr-auto'
: 'max-w-7xl'}" : 'max-w-7xl'}"
> >
<PageHeader title="Отчеты задач"> <PageHeader title="Центр статусов">
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
{#if m.wsConnected} {#if m.wsConnected}
<span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span> <span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span>
@@ -124,7 +98,7 @@
{#if m.screenState === 'error'} {#if m.screenState === 'error'}
<div class="bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg flex items-center justify-between"> <div class="bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg flex items-center justify-between">
<span>Не удалось загрузить данные задач. Сервер временно недоступен.</span> <span>Не удалось загрузить данные задач. Сервер временно недоступен.</span>
<Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить</Button> <Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить загрузку</Button>
</div> </div>
{/if} {/if}
@@ -143,117 +117,31 @@
onReconnect={() => m.connectWebSocket()} onReconnect={() => m.connectWebSocket()}
activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }} activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }}
/> />
{#if m.screenState === 'ready' || m.screenState === 'disconnected'} <FilterBar
<div class="flex flex-wrap gap-2" aria-label="Быстрые фильтры по статусу"> filters={m.filters}
<Button onApplyFilter={(partial: Partial<TaskCenterFilter>) => m.applyFilter(partial)}
variant="ghost" onClearFilters={() => m.clearFilters()}
size="sm" quickStatusCounts={quickStatusCounts}
class="min-h-11 rounded-full border px-3 {m.filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive ring-2 ring-destructive-ring/40' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}" onQuickStatusToggle={toggleStatus}
aria-pressed={m.filters.statuses.includes('failed')} />
onclick={() => toggleStatus('failed')}
> <TaskList
Упавшие {statusTotal('failed')} tasks={m.filteredTasks}
</Button> screenState={m.screenState}
<Button selectedTaskId={m.selectedTaskId}
variant="ghost" filtered={m.hasActiveFilters}
size="sm" onSelectTask={(taskId: string) => m.selectTask(taskId)}
class="min-h-11 rounded-full border px-3 {m.filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary-light text-primary ring-2 ring-primary-ring/40' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}" onClearFilters={() => m.clearFilters()}
aria-pressed={m.filters.statuses.includes('in_progress')} />
onclick={() => toggleStatus('in_progress')} {#if m.totalPages > 1 && !m.isEmpty && !m.isFilteredEmpty}
> <div class="mt-4 min-w-0">
В работе {statusTotal('in_progress')} <Pagination
</Button> bind:page={pageZero}
<Button pageSize={m.pageSize}
variant="ghost" total={m.totalItems}
size="sm" showingText={'Показано с {start} по {end} из {total} задач'}
class="min-h-11 rounded-full border px-3 {m.filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success ring-2 ring-success-ring/40' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}" />
aria-pressed={m.filters.statuses.includes('success')}
onclick={() => toggleStatus('success')}
>
Успешные {statusTotal('success')}
</Button>
</div> </div>
{/if} {/if}
<!-- Filter bar -->
<div class="bg-surface-muted rounded-lg p-3 grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-[minmax(12rem,1fr)_10rem_9rem_auto] sm:items-end">
<div class="min-w-0">
<Input
id="reports-task-search"
name="reports-task-search"
type="search"
placeholder="Поиск по задачам..."
value={m.filters.search}
oninput={handleSearchInput}
/>
</div>
<div class="flex flex-col gap-1 min-w-0">
<label for="sort-select" class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Сортировка</label>
<Select
id="sort-select"
name="reports-sort"
value={m.filters.sort_by}
options={sortOptions}
onchange={handleSortChange}
/>
</div>
<div class="flex flex-col gap-1 min-w-0">
<label for="time-range-select" class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Период</label>
<Select
id="time-range-select"
name="reports-time-range"
value={m.filters.time_range}
options={timeRangeOptions}
onchange={handleTimeRangeChange}
/>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 sm:justify-end">
<span class="text-xs text-text-muted whitespace-nowrap">
Показано {m.filteredTasks.length} из {m.totalItems} задач
</span>
{#if m.hasActiveFilters}
<Button variant="ghost" size="sm" onclick={() => m.clearFilters()}>Сбросить</Button>
{/if}
</div>
</div>
<!-- Task list (basic for US1) -->
<div class="min-w-0 overflow-hidden bg-surface-card border border-border rounded-lg p-2 sm:p-3">
{#if m.screenState === 'loading'}
<div class="space-y-3">
{#each [1, 2, 3, 4, 5] as n (n)}
<div class="bg-surface-muted rounded h-12 animate-pulse"></div>
{/each}
</div>
{:else if m.isEmpty}
<EmptyState
title="Нет активных задач"
description="В системе нет задач для отображения."
/>
{:else if m.isFilteredEmpty}
<EmptyState
title="Нет задач по выбранным фильтрам"
description="Попробуйте изменить условия фильтрации."
actionLabel="Сбросить фильтры"
onAction={() => m.clearFilters()}
/>
{:else}
<ReportsList
reports={m.filteredTasks}
selectedReportId={m.selectedTaskId}
onselect={(e) => m.selectTask(e.report.task_id || e.report.report_id)}
/>
{#if m.totalPages > 1}
<div class="mt-4 min-w-0">
<Pagination
bind:page={pageZero}
pageSize={m.pageSize}
total={m.totalItems}
showingText={'Показано с {start} по {end} из {total} задач'}
/>
</div>
{/if}
{/if}
</div>
</div> </div>
<!-- #endregion TaskCenterReportsPage --> <!-- #endregion TaskCenterReportsPage -->