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:
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user