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 86ac209615
commit 49e4ac0fe2

View File

@@ -1,13 +1,13 @@
<!-- #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 -->
<!-- @UX_STATE loading: full-page skeleton -->
<!-- @UX_STATE empty: "No tasks" empty state with navigation hints -->
<!-- @UX_STATE ready: summary panel + filter bar + task list -->
<!-- @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. -->
<!-- @RELATION BINDS_TO -> [TaskCenterModel] -->
<!-- @RELATION BINDS_TO -> [TaskCenter.Model] -->
<!-- @RELATION BINDS_TO -> [SummaryPanel] -->
<!-- @RELATION BINDS_TO -> [FilterBar] -->
<!-- @RELATION BINDS_TO -> [TaskList] -->
@@ -19,27 +19,15 @@
import { onMount } from 'svelte';
import { TaskCenterModel } from '$lib/models/TaskCenterModel.svelte.ts';
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
import ReportsList from '$lib/components/reports/ReportsList.svelte';
import { PageHeader, Button, EmptyState, Pagination, Input, Select } from '$lib/ui';
import FilterBar from '$lib/components/reports/FilterBar.svelte';
import TaskList from '$lib/components/reports/TaskList.svelte';
import { PageHeader, Button, Pagination } from '$lib/ui';
import { taskDrawerStore } from '$lib/stores/taskDrawer.svelte.js';
import type { PageData } from './+page.ts';
import type { TaskCenterFilter } from '$types/reports';
let { data }: { data: PageData } = $props();
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));
onMount(() => {
@@ -65,34 +53,20 @@
}
});
// ── Search debounce ──
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function handleSearchInput(e: Event & { currentTarget: HTMLInputElement }): void {
clearTimeout(searchTimeout);
const value = e.currentTarget.value;
searchTimeout = setTimeout(() => {
m.applyFilter({ search: value });
}, 350);
}
function handleSortChange(e: Event & { currentTarget: HTMLSelectElement }): void {
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);
}
const quickStatusCounts = $derived({
failed: (() => {
const s = m.visibleSummary;
return s ? s.by_type.reduce((sum, t) => sum + t.counts.failed, 0) : 0;
})(),
in_progress: (() => {
const s = m.visibleSummary;
return s ? s.by_type.reduce((sum, t) => sum + t.counts.pending + t.counts.running + t.counts.awaiting_input, 0) : 0;
})(),
success: (() => {
const s = m.visibleSummary;
return s ? s.by_type.reduce((sum, t) => sum + t.counts.success, 0) : 0;
})(),
});
function toggleStatus(status: TaskCenterFilter['statuses'][number]): void {
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'}"
>
<PageHeader title="Отчеты задач">
<PageHeader title="Центр статусов">
<div class="flex flex-wrap items-center justify-end gap-2">
{#if m.wsConnected}
<span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span>
@@ -124,7 +98,7 @@
{#if m.screenState === 'error'}
<div class="bg-destructive-light border border-destructive-ring text-destructive p-3 rounded-lg flex items-center justify-between">
<span>Не удалось загрузить данные задач. Сервер временно недоступен.</span>
<Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить</Button>
<Button variant="destructive" onclick={() => m.loadInitialData()}>Повторить загрузку</Button>
</div>
{/if}
@@ -143,117 +117,31 @@
onReconnect={() => m.connectWebSocket()}
activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }}
/>
{#if m.screenState === 'ready' || m.screenState === 'disconnected'}
<div class="flex flex-wrap gap-2" aria-label="Быстрые фильтры по статусу">
<Button
variant="ghost"
size="sm"
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'}"
aria-pressed={m.filters.statuses.includes('failed')}
onclick={() => toggleStatus('failed')}
>
Упавшие {statusTotal('failed')}
</Button>
<Button
variant="ghost"
size="sm"
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'}"
aria-pressed={m.filters.statuses.includes('in_progress')}
onclick={() => toggleStatus('in_progress')}
>
В работе {statusTotal('in_progress')}
</Button>
<Button
variant="ghost"
size="sm"
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>
<FilterBar
filters={m.filters}
onApplyFilter={(partial: Partial<TaskCenterFilter>) => m.applyFilter(partial)}
onClearFilters={() => m.clearFilters()}
quickStatusCounts={quickStatusCounts}
onQuickStatusToggle={toggleStatus}
/>
<TaskList
tasks={m.filteredTasks}
screenState={m.screenState}
selectedTaskId={m.selectedTaskId}
filtered={m.hasActiveFilters}
onSelectTask={(taskId: string) => m.selectTask(taskId)}
onClearFilters={() => m.clearFilters()}
/>
{#if m.totalPages > 1 && !m.isEmpty && !m.isFilteredEmpty}
<div class="mt-4 min-w-0">
<Pagination
bind:page={pageZero}
pageSize={m.pageSize}
total={m.totalItems}
showingText={'Показано с {start} по {end} из {total} задач'}
/>
</div>
{/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>
<!-- #endregion TaskCenterReportsPage -->