refactor(frontend): extract ValidationTasksListModel for validation tasks list
Validation tasks list: 521→204 lines (-61%). Model: paginated list with search, inline CRUD (run/delete/toggle), debounced search, isMounted guard, initFromLoad() pattern. 7→6 oversized pages remaining.
This commit is contained in:
174
frontend/src/lib/models/ValidationTasksListModel.svelte.ts
Normal file
174
frontend/src/lib/models/ValidationTasksListModel.svelte.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// #region ValidationTasksListModel [C:4] [TYPE Model] [SEMANTICS validation,tasks,list,pagination,crud,model]
|
||||
// @BRIEF Screen model for validation tasks list — manages paginated list, search, inline actions (run/delete/toggle), and navigation.
|
||||
// @INVARIANT Search resets pagination to page 1.
|
||||
// @INVARIANT Delete removes task from list and decrements total atomically.
|
||||
// @INVARIANT isMounted prevents state mutation after component unmount.
|
||||
// @STATE idle — Initial state, no data loaded.
|
||||
// @STATE loading — API call in progress.
|
||||
// @STATE loaded — Data fetched, list populated.
|
||||
// @STATE empty — Query returned zero results.
|
||||
// @STATE error — API call failed.
|
||||
// @ACTION loadTasks() — Fetches paginated task list.
|
||||
// @ACTION handleSearchInput() — Debounced search with pagination reset.
|
||||
// @ACTION handleRunNow(id) — Triggers validation run.
|
||||
// @ACTION handleDelete(id) — Deletes task with atomic list removal.
|
||||
// @ACTION handleToggleStatus(task) — Toggles is_active.
|
||||
// @ACTION goToPage(p) — Navigates to page.
|
||||
// @RELATION DEPENDS_ON -> [api]
|
||||
// @RELATION CALLS -> [log]
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { api } from '$lib/api.js';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────
|
||||
|
||||
interface ValidationTask {
|
||||
id: string;
|
||||
name?: string;
|
||||
plugin_id?: string;
|
||||
is_active?: boolean;
|
||||
schedule?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TaskListData {
|
||||
tasks?: ValidationTask[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export class ValidationTasksListModel {
|
||||
// ── Data from load function ──────────────────────────────────
|
||||
tasks: ValidationTask[] = $state([]);
|
||||
total: number = $state(0);
|
||||
currentPage: number = $state(1);
|
||||
pageSize: number = $state(20);
|
||||
error: string | null = $state(null);
|
||||
isLoading: boolean = $state(false);
|
||||
searchQuery: string = $state('');
|
||||
|
||||
// ── UI state ──────────────────────────────────────────────────
|
||||
runningTasks: Record<string, boolean> = $state({});
|
||||
deleteConfirmId: string | null = $state(null);
|
||||
isMounted: boolean = $state(true);
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
private _searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
|
||||
|
||||
// ── Init from load data ──────────────────────────────────────
|
||||
initFromLoad(data: TaskListData): void {
|
||||
this.tasks = data.tasks ?? [];
|
||||
this.total = data.total ?? 0;
|
||||
this.currentPage = data.page ?? 1;
|
||||
this.pageSize = data.page_size ?? 20;
|
||||
this.error = data.error ?? null;
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
async loadTasks(): Promise<void> {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
log('ValidationTasksListModel', 'REASON', 'Loading validation tasks', { page: this.currentPage, pageSize: this.pageSize, search: this.searchQuery || undefined });
|
||||
try {
|
||||
const result = await api.getValidationTasks({ page: this.currentPage, page_size: this.pageSize, search: this.searchQuery || undefined });
|
||||
if (!this.isMounted) return;
|
||||
const items = (result as { tasks?: ValidationTask[]; results?: ValidationTask[] })?.tasks ?? (result as { tasks?: ValidationTask[]; results?: ValidationTask[] })?.results ?? [];
|
||||
this.tasks = items;
|
||||
this.total = (result as { total?: number })?.total ?? 0;
|
||||
log('ValidationTasksListModel', 'REFLECT', 'Tasks loaded', { count: items.length, total: this.total });
|
||||
} catch (e: unknown) {
|
||||
if (!this.isMounted) return;
|
||||
this.error = e instanceof Error ? e.message : 'Failed to load tasks';
|
||||
log('ValidationTasksListModel', 'EXPLORE', 'Failed to load tasks', {}, String(this.error));
|
||||
} finally {
|
||||
if (this.isMounted) this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput(value: string): void {
|
||||
this.searchQuery = value;
|
||||
if (this._searchTimeout) clearTimeout(this._searchTimeout);
|
||||
this._searchTimeout = setTimeout(() => {
|
||||
this.currentPage = 1;
|
||||
this.loadTasks();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
goToPage(p: number): void {
|
||||
if (p < 1 || p > this.totalPages) return;
|
||||
this.currentPage = p;
|
||||
this.loadTasks();
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────
|
||||
|
||||
navToNew(): void { goto(ROUTES.validationTasks.new(), { invalidateAll: false }); }
|
||||
navToDetail(id: string): void { goto(ROUTES.validationTasks.detail(id), { invalidateAll: false }); }
|
||||
navToEdit(id: string): void { goto(ROUTES.validationTasks.edit(id), { invalidateAll: false }); }
|
||||
|
||||
// ── CRUD Actions ─────────────────────────────────────────────
|
||||
|
||||
async handleRunNow(id: string): Promise<void> {
|
||||
this.runningTasks = { ...this.runningTasks, [id]: true };
|
||||
log('ValidationTasksListModel', 'REASON', 'Triggering run', { taskId: id });
|
||||
try {
|
||||
await api.triggerValidationRun(id);
|
||||
if (!this.isMounted) return;
|
||||
addToast($t.validation?.run_started || 'Validation task started', 'success');
|
||||
log('ValidationTasksListModel', 'REFLECT', 'Run triggered', { taskId: id });
|
||||
} catch (e: unknown) {
|
||||
if (!this.isMounted) return;
|
||||
addToast(e instanceof Error ? e.message : 'Failed to trigger run', 'error');
|
||||
log('ValidationTasksListModel', 'EXPLORE', 'Failed to trigger run', { taskId: id }, String(e));
|
||||
} finally {
|
||||
if (this.isMounted) this.runningTasks = { ...this.runningTasks, [id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(id: string): Promise<void> {
|
||||
log('ValidationTasksListModel', 'REASON', 'Deleting task', { taskId: id });
|
||||
try {
|
||||
await api.deleteValidationTask(id);
|
||||
if (!this.isMounted) return;
|
||||
addToast($t.validation?.delete_success || 'Task deleted', 'success');
|
||||
this.deleteConfirmId = null;
|
||||
this.tasks = this.tasks.filter(t => t.id !== id);
|
||||
this.total = Math.max(0, this.total - 1);
|
||||
log('ValidationTasksListModel', 'REFLECT', 'Task deleted', { taskId: id });
|
||||
} catch (e: unknown) {
|
||||
if (!this.isMounted) return;
|
||||
addToast(e instanceof Error ? e.message : 'Failed to delete', 'error');
|
||||
this.deleteConfirmId = null;
|
||||
log('ValidationTasksListModel', 'EXPLORE', 'Failed to delete task', { taskId: id }, String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async handleToggleStatus(task: ValidationTask): Promise<void> {
|
||||
const newActive = !(task.is_active !== false);
|
||||
log('ValidationTasksListModel', 'REASON', 'Toggling status', { taskId: task.id, to: newActive });
|
||||
try {
|
||||
await api.toggleValidationTaskStatus(task.id, newActive);
|
||||
if (!this.isMounted) return;
|
||||
task.is_active = newActive;
|
||||
this.tasks = this.tasks; // trigger reactivity
|
||||
log('ValidationTasksListModel', 'REFLECT', 'Status toggled', { taskId: task.id });
|
||||
} catch (e: unknown) {
|
||||
if (!this.isMounted) return;
|
||||
addToast(e instanceof Error ? e.message : 'Failed to update status', 'error');
|
||||
log('ValidationTasksListModel', 'EXPLORE', 'Failed to toggle status', { taskId: task.id }, String(e));
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void { this.isMounted = false; }
|
||||
}
|
||||
// #endregion ValidationTasksListModel
|
||||
@@ -1,520 +1,204 @@
|
||||
<!-- #region ValidationTasksPage [C:3] [TYPE Page] [SEMANTICS validation,tasks,list,table] -->
|
||||
<!-- @BRIEF Validation tasks list page — table layout with status filter, pagination, and inline actions. -->
|
||||
<!-- @RELATION CALLS -> [ValidationApi] -->
|
||||
<!-- @UX_STATE Loading -> Skeleton table rows matching column layout -->
|
||||
<!-- @UX_STATE Loaded -> Data rows with all columns -->
|
||||
<!-- @UX_STATE Empty -> Centered empty card with CTA to create first task -->
|
||||
<!-- @UX_STATE Error -> Red banner with error details and retry button -->
|
||||
<!-- @UX_FEEDBACK Toast on run trigger, delete success, toggle error -->
|
||||
<!-- @UX_RECOVERY Retry button on load error; inline confirm on delete -->
|
||||
<!-- @BRIEF Validation tasks list page — renders ValidationTasksListModel state. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION BINDS_TO -> [ValidationTasksListModel] -->
|
||||
<!-- @UX_STATE loading -> Skeleton table rows matching column layout -->
|
||||
<!-- @UX_STATE loaded -> Data rows with all columns -->
|
||||
<!-- @UX_STATE empty -> Centered empty card with CTA -->
|
||||
<!-- @UX_STATE error -> Red banner with error and retry -->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { log } from '$lib/cot-logger';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { ValidationTasksListModel } from '$lib/models/ValidationTasksListModel.svelte';
|
||||
|
||||
/** @type {{ data: import('./$types').PageData }} */
|
||||
let { data } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
// ── Derived initial state from load function ───────────────
|
||||
/** @type {Array<any>} */ let tasks = $state(data.tasks ?? []);
|
||||
/** @type {number} */ let total = $state(data.total ?? 0);
|
||||
/** @type {number} */ let currentPage = $state(data.page ?? 1);
|
||||
/** @type {number} */ let pageSize = $state(data.page_size ?? 20);
|
||||
/** @type {string | null} */ let error = $state(data.error ?? null);
|
||||
let isLoading = $state(false);
|
||||
let searchQuery = $state('');
|
||||
const m = new ValidationTasksListModel();
|
||||
m.initFromLoad(data);
|
||||
|
||||
/** @type {Record<string, boolean>} */
|
||||
let runningTasks = $state({});
|
||||
let deleteConfirmId = $state(/** @type {string | null} */(null));
|
||||
let isMounted = $state(true);
|
||||
onMount(() => {
|
||||
return () => { m.destroy(); };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
return () => { isMounted = false; };
|
||||
});
|
||||
function formatSchedule(input: string | null | undefined): string {
|
||||
if (!input) return $t.validation?.manual || 'Manual';
|
||||
return input;
|
||||
}
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────
|
||||
let totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)));
|
||||
function formatTimestamp(ts: unknown): string {
|
||||
if (!ts) return '\u2014';
|
||||
try { return new Date(ts as string).toLocaleString(); }
|
||||
catch { return String(ts); }
|
||||
}
|
||||
|
||||
// ── Data fetching ──────────────────────────────────────────
|
||||
async function loadTasks() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
log('ValidationTasksPage', 'REASON', 'Loading validation tasks', {
|
||||
page: currentPage, pageSize, search: searchQuery || undefined
|
||||
});
|
||||
try {
|
||||
const result = await api.getValidationTasks({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
search: searchQuery || undefined
|
||||
});
|
||||
if (!isMounted) return;
|
||||
const items = result?.tasks ?? result?.results ?? [];
|
||||
tasks = items;
|
||||
total = result?.total ?? 0;
|
||||
log('ValidationTasksPage', 'REFLECT', 'Tasks loaded', { count: items.length, total });
|
||||
} catch (e) {
|
||||
if (!isMounted) return;
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load tasks';
|
||||
error = msg;
|
||||
log('ValidationTasksPage', 'EXPLORE', 'Failed to load tasks', {}, msg);
|
||||
} finally {
|
||||
if (isMounted) isLoading = false;
|
||||
}
|
||||
}
|
||||
function formatLastRunSummary(summary: { pass?: number; warn?: number; fail?: number } | null | undefined): string {
|
||||
if (!summary) return '\u2014';
|
||||
const parts: string[] = [];
|
||||
if (summary.pass) parts.push(`${summary.pass} PASS`);
|
||||
if (summary.warn) parts.push(`${summary.warn} WARN`);
|
||||
if (summary.fail) parts.push(`${summary.fail} FAIL`);
|
||||
return parts.join('\u2022') || '\u2014';
|
||||
}
|
||||
|
||||
// ── Search with debounce ──────────────────────────────────
|
||||
let searchTimeout = $state(/** @type {ReturnType<typeof setTimeout> | undefined} */(undefined));
|
||||
function getSummaryBadgeClass(summary: { fail?: number; warn?: number; pass?: number } | null | undefined): string {
|
||||
if (!summary) return 'bg-surface-muted text-text-muted';
|
||||
if (summary.fail && summary.fail > 0) return 'bg-destructive-light text-destructive';
|
||||
if (summary.warn && summary.warn > 0) return 'bg-warning-light text-warning';
|
||||
if (summary.pass && summary.pass > 0) return 'bg-success-light text-success';
|
||||
return 'bg-surface-muted text-text-muted';
|
||||
}
|
||||
|
||||
function handleSearchInput(/** @type {string} */ value) {
|
||||
searchQuery = value;
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadTasks();
|
||||
}, 300);
|
||||
}
|
||||
function getEnvLabel(task: Record<string, unknown>): string {
|
||||
return String(task.environment_name || task.environment_id || '\u2014');
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────
|
||||
function navToNew() {
|
||||
goto(ROUTES.validationTasks.new(), { invalidateAll: false });
|
||||
}
|
||||
|
||||
function navToDetail(/** @type {string} */ id) {
|
||||
goto(ROUTES.validationTasks.detail(id), { invalidateAll: false });
|
||||
}
|
||||
|
||||
function navToEdit(/** @type {string} */ id) {
|
||||
goto(ROUTES.validationTasks.edit(id), { invalidateAll: false });
|
||||
}
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────
|
||||
async function handleRunNow(/** @type {string} */ id) {
|
||||
runningTasks = { ...runningTasks, [id]: true };
|
||||
log('ValidationTasksPage', 'REASON', 'Triggering run', { taskId: id });
|
||||
try {
|
||||
await api.triggerValidationRun(id);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.run_started || 'Validation task started', 'success');
|
||||
log('ValidationTasksPage', 'REFLECT', 'Run triggered', { taskId: id });
|
||||
} catch (e) {
|
||||
if (!isMounted) return;
|
||||
const msg = e instanceof Error ? e.message : 'Failed to trigger run';
|
||||
addToast(msg, 'error');
|
||||
log('ValidationTasksPage', 'EXPLORE', 'Failed to trigger run', { taskId: id }, msg);
|
||||
} finally {
|
||||
if (isMounted) runningTasks = { ...runningTasks, [id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(/** @type {string} */ id) {
|
||||
log('ValidationTasksPage', 'REASON', 'Deleting task', { taskId: id });
|
||||
try {
|
||||
await api.deleteValidationTask(id);
|
||||
if (!isMounted) return;
|
||||
addToast($t.validation?.delete_success || 'Task deleted', 'success');
|
||||
deleteConfirmId = null;
|
||||
// Refresh list
|
||||
tasks = tasks.filter(t => t.id !== id);
|
||||
total = Math.max(0, total - 1);
|
||||
log('ValidationTasksPage', 'REFLECT', 'Task deleted', { taskId: id });
|
||||
} catch (e) {
|
||||
if (!isMounted) return;
|
||||
const msg = e instanceof Error ? e.message : 'Failed to delete';
|
||||
addToast(msg, 'error');
|
||||
deleteConfirmId = null;
|
||||
log('ValidationTasksPage', 'EXPLORE', 'Failed to delete task', { taskId: id }, msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(/** @type {any} */ task) {
|
||||
const newActive = !(task.is_active !== false);
|
||||
log('ValidationTasksPage', 'REASON', 'Toggling status', {
|
||||
taskId: task.id, from: task.is_active, to: newActive
|
||||
});
|
||||
try {
|
||||
await api.toggleValidationTaskStatus(task.id, newActive);
|
||||
if (!isMounted) return;
|
||||
task.is_active = newActive;
|
||||
tasks = tasks; // trigger reactivity
|
||||
log('ValidationTasksPage', 'REFLECT', 'Status toggled', { taskId: task.id, isActive: newActive });
|
||||
} catch (e) {
|
||||
if (!isMounted) return;
|
||||
const msg = e instanceof Error ? e.message : 'Failed to update status';
|
||||
addToast(msg, 'error');
|
||||
log('ValidationTasksPage', 'EXPLORE', 'Failed to toggle status', { taskId: task.id }, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pagination ─────────────────────────────────────────────
|
||||
function goToPage(/** @type {number} */ p) {
|
||||
if (p < 1 || p > totalPages) return;
|
||||
currentPage = p;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
/**
|
||||
* @param {string | null | undefined} scheduleInput
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatSchedule(scheduleInput) {
|
||||
if (!scheduleInput) return $t.validation?.manual || 'Manual';
|
||||
return scheduleInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number | Date | null | undefined} ts
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '—';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return String(ts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ pass?: number, warn?: number, fail?: number } | null | undefined} summary
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatLastRunSummary(summary) {
|
||||
if (!summary) return '—';
|
||||
const parts = [];
|
||||
if (summary.pass) parts.push(`${summary.pass} PASS`);
|
||||
if (summary.warn) parts.push(`${summary.warn} WARN`);
|
||||
if (summary.fail) parts.push(`${summary.fail} FAIL`);
|
||||
return parts.join(' • ') || '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ pass?: number, warn?: number, fail?: number } | null | undefined} summary
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSummaryBadgeClass(summary) {
|
||||
if (!summary) return 'bg-surface-muted text-text-muted';
|
||||
if (summary.fail && summary.fail > 0) return 'bg-destructive-light text-destructive';
|
||||
if (summary.warn && summary.warn > 0) return 'bg-warning-light text-warning';
|
||||
if (summary.pass && summary.pass > 0) return 'bg-success-light text-success';
|
||||
return 'bg-surface-muted text-text-muted';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} task
|
||||
* @returns {string}
|
||||
*/
|
||||
function getEnvLabel(task) {
|
||||
return task.environment_name || task.environment_id || '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} p
|
||||
* @returns {string}
|
||||
*/
|
||||
function pageBtnClass(p) {
|
||||
const base = 'px-3 py-1.5 text-sm rounded-lg border transition-colors';
|
||||
if (p === currentPage) return `${base} bg-primary-light border-primary-ring text-primary`;
|
||||
return `${base} border-border text-text-muted hover:bg-surface-muted`;
|
||||
}
|
||||
function pageBtnClass(p: number): string {
|
||||
const base = 'px-3 py-1.5 text-sm rounded-lg border transition-colors';
|
||||
if (p === m.currentPage) return `${base} bg-primary-light border-primary-ring text-primary`;
|
||||
return `${base} border-border text-text-muted hover:bg-surface-muted`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-text">
|
||||
{$t.validation?.title || 'Validation Tasks'}
|
||||
</h1>
|
||||
<button
|
||||
onclick={navToNew}
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium text-sm"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$t.validation?.new_task || '+ New Task'}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-text">{$t.validation?.title || 'Validation Tasks'}</h1>
|
||||
<button onclick={() => m.navToNew()} class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium text-sm">
|
||||
<svg class="w-5 h-5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||||
{$t.validation?.new_task || '+ New Task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="mb-4">
|
||||
<div class="relative max-w-sm">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t.common?.search || 'Search tasks...'}
|
||||
value={searchQuery}
|
||||
oninput={(e) => handleSearchInput(/** @type {HTMLInputElement} */(e.target).value)}
|
||||
class="w-full pl-10 pr-4 py-2 border border-border-strong rounded-lg text-sm focus:ring-2 focus:ring-primary-ring focus:border-primary-ring outline-none"
|
||||
aria-label={$t.common?.search || 'Search tasks'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<div class="relative max-w-sm">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input type="text" placeholder={$t.common?.search || 'Search tasks...'} value={m.searchQuery} oninput={(e) => m.handleSearchInput((e.target as HTMLInputElement).value)} class="w-full pl-10 pr-4 py-2 border border-border-strong rounded-lg text-sm focus:ring-2 focus:ring-primary-ring focus:border-primary-ring outline-none" aria-label={$t.common?.search} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
{#if error && !isLoading}
|
||||
<div class="bg-destructive-light border border-destructive-ring rounded-lg p-4 mb-6 flex items-center justify-between" role="alert">
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm">{error}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadTasks}
|
||||
class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive transition-colors"
|
||||
>
|
||||
{$t.validation?.retry || 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Error -->
|
||||
{#if m.error && !m.isLoading}
|
||||
<div class="bg-destructive-light border border-destructive-ring rounded-lg p-4 mb-6 flex items-center justify-between" role="alert">
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span class="text-sm">{m.error}</span>
|
||||
</div>
|
||||
<button onclick={() => m.loadTasks()} class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive transition-colors">{$t.validation?.retry}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
{#if isLoading}
|
||||
<div class="bg-surface-card border border-border rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-border">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name || 'Task Name'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment || 'Environment'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule || 'Schedule'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run || 'Last Run'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status || 'Status'}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions || 'Actions'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-surface-card divide-y divide-border">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<tr class="animate-pulse">
|
||||
<td class="px-6 py-4"><div class="h-4 w-40 bg-surface-muted rounded"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 w-20 bg-surface-muted rounded"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 w-32 bg-surface-muted rounded"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-5 w-14 bg-surface-muted rounded-full"></div></td>
|
||||
<td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded ml-auto"></div></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
{#if m.isLoading}
|
||||
<div class="bg-surface-card border border-border rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-border">
|
||||
<thead class="bg-surface-muted"><tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions}</th>
|
||||
</tr></thead>
|
||||
<tbody class="bg-surface-card divide-y divide-border">
|
||||
{#each Array(5) as _, i (i)}<tr class="animate-pulse"><td class="px-6 py-4"><div class="h-4 w-40 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-4 w-20 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-4 w-32 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-5 w-14 bg-surface-muted rounded-full"></div></td><td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded ml-auto"></div></td></tr>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
{:else if !error && tasks.length === 0}
|
||||
<div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-text-subtle mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-text-muted text-sm mb-1">{$t.validation?.no_tasks || 'No validation tasks yet'}</p>
|
||||
<p class="text-text-subtle text-sm mb-4">{$t.validation?.create_first || 'Create your first task'}</p>
|
||||
<button
|
||||
onclick={navToNew}
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
{$t.validation?.new_task || '+ New Task'}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Empty -->
|
||||
{:else if !m.error && m.tasks.length === 0}
|
||||
<div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-text-subtle mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<p class="text-text-muted text-sm mb-1">{$t.validation?.no_tasks || 'No validation tasks yet'}</p>
|
||||
<p class="text-text-subtle text-sm mb-4">{$t.validation?.create_first || 'Create your first task'}</p>
|
||||
<button onclick={() => m.navToNew()} class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium">{$t.validation?.new_task}</button>
|
||||
</div>
|
||||
|
||||
<!-- Loaded state: table -->
|
||||
{:else}
|
||||
<div class="bg-surface-card border border-border rounded-lg overflow-hidden shadow-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name || 'Task Name'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment || 'Environment'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule || 'Schedule'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run || 'Last Run'}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status || 'Status'}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions || 'Actions'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-surface-card divide-y divide-border">
|
||||
{#each tasks as task (task.id)}
|
||||
<tr class="hover:bg-surface-muted transition-colors">
|
||||
<!-- Task Name -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onclick={() => navToDetail(task.id)}
|
||||
class="text-sm font-medium text-primary hover:text-primary hover:underline text-left"
|
||||
>
|
||||
{task.name || '—'}
|
||||
</button>
|
||||
</td>
|
||||
<!-- Loaded: table -->
|
||||
{:else}
|
||||
<div class="bg-surface-card border border-border rounded-lg overflow-hidden shadow-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border">
|
||||
<thead class="bg-surface-muted"><tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions}</th>
|
||||
</tr></thead>
|
||||
<tbody class="bg-surface-card divide-y divide-border">
|
||||
{#each m.tasks as task (task.id)}
|
||||
<tr class="hover:bg-surface-muted transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button onclick={() => m.navToDetail(task.id)} class="text-sm font-medium text-primary hover:text-primary hover:underline text-left">{task.name || '\u2014'}</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><span class="text-sm text-text">{getEnvLabel(task)}</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if task.schedule}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-light text-primary">{formatSchedule((task as Record<string,unknown>).schedule_description as string || task.schedule)}</span>
|
||||
{:else}
|
||||
<span class="text-sm text-text-subtle">{$t.validation?.manual || 'Manual'}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if task.last_run_at || task.last_run_summary}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if task.last_run_at}<span class="text-xs text-text-muted">{formatTimestamp(task.last_run_at)}</span>{/if}
|
||||
{#if task.last_run_summary}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {getSummaryBadgeClass(task.last_run_summary as { pass?: number; warn?: number; fail?: number } | null)}">{formatLastRunSummary(task.last_run_summary as { pass?: number; warn?: number; fail?: number } | null)}</span>{/if}
|
||||
</div>
|
||||
{:else}<span class="text-sm text-text-subtle">\u2014</span>{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button onclick={() => m.handleToggleStatus(task)} class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-ring focus:ring-offset-2" class:bg-primary={task.is_active !== false} class:bg-surface-muted={task.is_active === false} role="switch" aria-checked={task.is_active !== false}>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-surface-card transition-transform" class:translate-x-6={task.is_active !== false} class:translate-x-1={task.is_active === false}></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button onclick={() => m.handleRunNow(task.id)} disabled={m.runningTasks[task.id]} class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title={$t.validation?.run_now}>
|
||||
{#if m.runningTasks[task.id]}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button onclick={() => m.navToEdit(task.id)} class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors" title={$t.validation?.edit}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
{#if m.deleteConfirmId === task.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => m.handleDelete(task.id)} class="px-2 py-1 text-xs bg-destructive text-white rounded hover:bg-destructive transition-colors">{$t.validation?.confirm_delete}</button>
|
||||
<button onclick={() => { m.deleteConfirmId = null; }} class="px-2 py-1 text-xs bg-surface-muted text-text rounded hover:bg-surface-muted transition-colors">{$t.common?.cancel}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button onclick={() => { m.deleteConfirmId = task.id; }} class="p-2 text-text-subtle hover:text-destructive rounded-lg hover:bg-destructive-light transition-colors" title={$t.validation?.delete}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-text">{getEnvLabel(task)}</span>
|
||||
</td>
|
||||
|
||||
<!-- Schedule -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if task.schedule}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-light text-primary">
|
||||
{formatSchedule(task.schedule_description || task.schedule)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-text-subtle">{$t.validation?.manual || 'Manual'}</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Last Run -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if task.last_run_at || task.last_run_summary}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if task.last_run_at}
|
||||
<span class="text-xs text-text-muted">{formatTimestamp(task.last_run_at)}</span>
|
||||
{/if}
|
||||
{#if task.last_run_summary}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {getSummaryBadgeClass(task.last_run_summary)}">
|
||||
{formatLastRunSummary(task.last_run_summary)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-sm text-text-subtle">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Status Toggle -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onclick={() => handleToggleStatus(task)}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-ring focus:ring-offset-2"
|
||||
class:bg-primary={task.is_active !== false}
|
||||
class:bg-surface-muted={task.is_active === false}
|
||||
role="switch"
|
||||
aria-checked={task.is_active !== false}
|
||||
aria-label={$t.validation?.active || 'Active'}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-surface-card transition-transform"
|
||||
class:translate-x-6={task.is_active !== false}
|
||||
class:translate-x-1={task.is_active === false}
|
||||
></span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<!-- Run Now -->
|
||||
<button
|
||||
onclick={() => handleRunNow(task.id)}
|
||||
disabled={runningTasks[task.id]}
|
||||
class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={$t.validation?.run_now || 'Run Now'}
|
||||
>
|
||||
{#if runningTasks[task.id]}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Edit -->
|
||||
<button
|
||||
onclick={() => navToEdit(task.id)}
|
||||
class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors"
|
||||
title={$t.validation?.edit || 'Edit'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete -->
|
||||
{#if deleteConfirmId === task.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(task.id)}
|
||||
class="px-2 py-1 text-xs bg-destructive text-white rounded hover:bg-destructive transition-colors"
|
||||
>
|
||||
{$t.validation?.confirm_delete || 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { deleteConfirmId = null; }}
|
||||
class="px-2 py-1 text-xs bg-surface-muted text-text rounded hover:bg-surface-muted transition-colors"
|
||||
>
|
||||
{$t.common?.cancel || 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { deleteConfirmId = task.id; }}
|
||||
class="p-2 text-text-subtle hover:text-destructive rounded-lg hover:bg-destructive-light transition-colors"
|
||||
title={$t.validation?.delete || 'Delete'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-text-muted">
|
||||
{($t.validation?.showing || 'Showing {count} of {total}')
|
||||
.replace('{count}', String(tasks.length))
|
||||
.replace('{total}', String(total))}
|
||||
</p>
|
||||
<nav class="flex items-center gap-1" aria-label="Pagination">
|
||||
<button
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors"
|
||||
>
|
||||
{$t.validation?.previous || 'Previous'}
|
||||
</button>
|
||||
{#each Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
// Show window around current page
|
||||
if (totalPages <= 7) return i + 1;
|
||||
const half = 3;
|
||||
const start = Math.max(1, currentPage - half);
|
||||
const end = Math.min(totalPages, start + 6);
|
||||
const adjustedStart = Math.max(1, end - 6);
|
||||
return adjustedStart + i;
|
||||
}) as p (p)}
|
||||
<button
|
||||
onclick={() => goToPage(p)}
|
||||
class={pageBtnClass(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors"
|
||||
>
|
||||
{$t.validation?.next || 'Next'}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Pagination -->
|
||||
{#if m.totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-text-muted">{($t.validation?.showing || 'Showing {count} of {total}').replace('{count}', String(m.tasks.length)).replace('{total}', String(m.total))}</p>
|
||||
<nav class="flex items-center gap-1" aria-label="Pagination">
|
||||
<button onclick={() => m.goToPage(m.currentPage - 1)} disabled={m.currentPage <= 1} class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors">{$t.validation?.previous}</button>
|
||||
{#each Array.from({ length: Math.min(m.totalPages, 7) }, (_, i) => { if (m.totalPages <= 7) return i + 1; const half = 3; const start = Math.max(1, m.currentPage - half); const end = Math.min(m.totalPages, start + 6); const adj = Math.max(1, end - 6); return adj + i; }) as p (p)}
|
||||
<button onclick={() => m.goToPage(p)} class={pageBtnClass(p)}>{p}</button>
|
||||
{/each}
|
||||
<button onclick={() => m.goToPage(m.currentPage + 1)} disabled={m.currentPage >= m.totalPages} class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors">{$t.validation?.next}</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion ValidationTasksPage -->
|
||||
|
||||
Reference in New Issue
Block a user