- Replace raw <button> with <Button variant="ghost"> - Dim zero-count badges with opacity-50 - Add transition-colors duration-300 to count numbers - Replace inline reconnect link with themed <Button>
112 lines
6.0 KiB
Svelte
112 lines
6.0 KiB
Svelte
<!-- #region SummaryPanel [C:4] [TYPE Component] [SEMANTICS component, summary, dashboard, websocket-reactive, task-status-center] -->
|
||
<!-- @BRIEF Summary dashboard panel — grid of clickable cards showing task counts by type × status. -->
|
||
<!-- @LAYER UI -->
|
||
<!-- @UX_STATE idle: cards rendered normally, clickable -->
|
||
<!-- @UX_STATE loading: skeleton placeholders (4 cards with pulse animation) -->
|
||
<!-- @UX_STATE empty: all cards show zero counts -->
|
||
<!-- @UX_STATE reconnecting: last known counts visible, yellow pulsing indicator -->
|
||
<!-- @UX_STATE disconnected: last known counts visible, opacity reduced, "disconnected" badge -->
|
||
<!-- @UX_FEEDBACK Count change: number animates (CSS transition). Card click: emits filter event. Zero-count badges remain dimmed but visible. -->
|
||
<!-- @UX_RECOVERY Disconnected: auto-reconnect indicator. Click → manual reconnect button. -->
|
||
<!-- @UX_REACTIVITY Props: summary: TaskSummary | null, screenState: ScreenState. Derived: by_type grid. -->
|
||
<!-- @RELATION BINDS_TO -> [TaskCenter.Model] -->
|
||
<!-- @PRE summary is non-null when screenState is 'ready' or 'disconnected'. -->
|
||
<!-- @POST Clicking a card calls onFilterByTypeAndStatus(taskType, status). -->
|
||
<!-- @DATA_CONTRACT Input: TaskSummary + ScreenState → Output: clickable status summary cards. -->
|
||
<!-- @SIDE_EFFECT Calls parent callbacks only; no direct network or store mutation. -->
|
||
<script lang="ts">
|
||
import { Button } from '$lib/ui';
|
||
import type { TaskType, ReportStatus, ScreenState, StatusCounts, TaskSummary } from '$types/reports';
|
||
|
||
const STATUS_CONFIG: { key: keyof StatusCounts; label: string; colorClass: string }[] = [
|
||
{ key: 'running', label: 'Выполняется', colorClass: 'bg-primary/10 text-primary' },
|
||
{ key: 'pending', label: 'Ожидает', colorClass: 'bg-surface-muted text-text-muted' },
|
||
{ key: 'awaiting_input', label: 'Ожидает ввода', colorClass: 'bg-warning-light text-warning' },
|
||
{ key: 'success', label: 'Успешно', colorClass: 'bg-success-light text-success' },
|
||
{ key: 'failed', label: 'Упало', colorClass: 'bg-destructive-light text-destructive' },
|
||
];
|
||
|
||
interface Props {
|
||
summary: TaskSummary | null;
|
||
screenState: ScreenState;
|
||
onFilterByTypeAndStatus: (_type: TaskType, _status: ReportStatus) => void;
|
||
onReconnect?: () => void;
|
||
activeFilters?: { task_types: TaskType[]; statuses: ReportStatus[] };
|
||
}
|
||
|
||
let { summary, screenState, onFilterByTypeAndStatus, onReconnect = () => {}, activeFilters = { task_types: [], statuses: [] } }: Props = $props();
|
||
|
||
function getCount(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): number {
|
||
const c = typeSummary.counts;
|
||
return c[statusKey] ?? 0;
|
||
}
|
||
|
||
function handleCardClick(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): void {
|
||
// Map summary status keys to ReportStatus for filtering
|
||
onFilterByTypeAndStatus(typeSummary.task_type, mapStatusKeyToReportStatus(statusKey));
|
||
}
|
||
|
||
function isStatusActive(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): boolean {
|
||
const status = mapStatusKeyToReportStatus(statusKey);
|
||
const typeMatches = activeFilters.task_types.length === 0 || activeFilters.task_types.includes(typeSummary.task_type);
|
||
return typeMatches && activeFilters.statuses.includes(status);
|
||
}
|
||
|
||
function mapStatusKeyToReportStatus(statusKey: keyof StatusCounts): ReportStatus {
|
||
const statusMap: Record<string, ReportStatus> = {
|
||
running: 'in_progress',
|
||
pending: 'in_progress',
|
||
awaiting_input: 'in_progress',
|
||
success: 'success',
|
||
failed: 'failed',
|
||
};
|
||
return statusMap[statusKey] ?? 'in_progress';
|
||
}
|
||
</script>
|
||
|
||
{#if screenState === 'loading'}
|
||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||
{#each [1, 2, 3, 4] as n (n)}
|
||
<div class="bg-surface-muted rounded-lg h-20 animate-pulse"></div>
|
||
{/each}
|
||
</div>
|
||
{:else if summary && summary.by_type.length > 0}
|
||
<div class="space-y-3">
|
||
{#each summary.by_type as typeSummary (typeSummary.task_type)}
|
||
<!-- Type header -->
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="text-text text-sm font-medium">{typeSummary.display_label}</span>
|
||
<span class="text-text-muted text-xs">({typeSummary.total})</span>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||
{#each STATUS_CONFIG as status (status.key)}
|
||
{@const count = getCount(typeSummary, status.key)}
|
||
{@const isActive = isStatusActive(typeSummary, status.key)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="flex min-h-11 w-full items-center justify-start gap-2 rounded-lg border px-2 py-2 text-left cursor-pointer transition-all hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {screenState === 'disconnected' ? 'opacity-60' : ''} {status.colorClass} {count === 0 ? 'opacity-50' : ''} {isActive ? 'border-primary-ring ring-2 ring-primary-ring/50' : 'border-border'}"
|
||
onclick={() => handleCardClick(typeSummary, status.key)}
|
||
aria-label="{typeSummary.display_label}, {status.label}: {count}"
|
||
aria-pressed={isActive}
|
||
>
|
||
<span class="text-sm font-mono min-w-[1.5rem] text-center font-semibold transition-colors duration-300">{count}</span>
|
||
<span class="text-xs truncate">{status.label}</span>
|
||
</Button>
|
||
{/each}
|
||
</div>
|
||
{/each}
|
||
{#if screenState === 'disconnected'}
|
||
<div class="flex items-center gap-2 text-xs text-text-muted mt-2">
|
||
<span class="w-2 h-2 rounded-full bg-destructive"></span>
|
||
Данные могут быть неактуальны
|
||
<Button variant="ghost" size="sm" class="h-auto p-0 underline" onclick={onReconnect}>Подключиться</Button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<!-- empty state - just placeholder -->
|
||
<div class="text-text-muted text-sm py-4">Нет активных задач</div>
|
||
{/if}
|
||
<!-- #endregion SummaryPanel -->
|