web(ui): AppDataTable pattern and modules pilot

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-05-19 11:59:34 +07:00
co-authored by Cursor
parent 77a2cfd426
commit b992301c99
+83 -127
View File
@@ -22,25 +22,11 @@
SelectTrigger, SelectTrigger,
} from '$lib/components/ui/select/index.js'; } from '$lib/components/ui/select/index.js';
import { Switch } from '$lib/components/ui/switch/index.js'; import { Switch } from '$lib/components/ui/switch/index.js';
import { import { Checkbox } from '$lib/ui/core/checkbox/index.js';
AlertDialog, import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
AlertDialogAction, import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
AlertDialogCancel, import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
AlertDialogContent, import { notify, notifyApiError } from '$lib/ui/app/toast.js';
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '$lib/components/ui/alert-dialog/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
@@ -55,7 +41,6 @@
let saving = $state(false); let saving = $state(false);
let selectedModuleIds = $state(new Set<string>()); let selectedModuleIds = $state(new Set<string>());
let deletingBulkModules = $state(false); let deletingBulkModules = $state(false);
let bulkDeleteModulesDialog = $state(false);
const selectedModulesCount = $derived(selectedModuleIds.size); const selectedModulesCount = $derived(selectedModuleIds.size);
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length); const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
@@ -75,6 +60,17 @@
{ value: 'IP_RANGES', label: 'IP Ranges' } { value: 'IP_RANGES', label: 'IP Ranges' }
] as const; ] as const;
const moduleColumns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
{ id: 'type', label: 'Тип', sortable: true, sortValue: (m: ModuleRow) => m.type },
{ id: 'priority', label: 'Приоритет', sortable: true, sortValue: (m: ModuleRow) => m.priority ?? 0 },
{ id: 'interval', label: 'Интервал' },
{ id: 'refreshed', label: 'Последнее обновление', sortable: true, sortValue: (m: ModuleRow) => m.last_refreshed_at ?? '' },
{ id: 'status', label: 'Статус' },
{ id: 'actions', label: '', class: 'w-16' }
] as const;
async function load() { async function load() {
loading = true; loading = true;
try { try {
@@ -83,7 +79,7 @@
const validIds = new Set(rows.map((item) => item.id)); const validIds = new Set(rows.map((item) => item.id));
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id))); selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : String(e)); notifyApiError(e);
} finally { } finally {
loading = false; loading = false;
} }
@@ -93,18 +89,18 @@
async function create() { async function create() {
if (!form.name.trim()) { if (!form.name.trim()) {
toast.error('Укажите название модуля'); notify.error('Укажите название модуля');
return; return;
} }
saving = true; saving = true;
try { try {
await apiMutate('/v1/modules', 'POST', form); await apiMutate('/v1/modules', 'POST', form);
toast.success('Модуль создан'); notify.success('Модуль создан');
dialogOpen = false; dialogOpen = false;
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 }; form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
await load(); await load();
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : String(e)); notifyApiError(e);
} finally { } finally {
saving = false; saving = false;
} }
@@ -154,6 +150,17 @@
selectedModuleIds = checked ? new Set(rows.map((item) => item.id)) : new Set<string>(); selectedModuleIds = checked ? new Set(rows.map((item) => item.id)) : new Set<string>();
} }
function requestBulkDelete() {
if (selectedModuleIds.size === 0) return;
void confirm({
title: 'Удалить выбранные модули?',
description: `Будет удалено: ${selectedModulesCount}. Это действие необратимо.`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDeleteModules
});
}
async function bulkDeleteModules() { async function bulkDeleteModules() {
if (selectedModuleIds.size === 0) return; if (selectedModuleIds.size === 0) return;
deletingBulkModules = true; deletingBulkModules = true;
@@ -164,13 +171,12 @@
await apiMutate(`/v1/modules/${id}`, 'DELETE', undefined, { idempotent: false }); await apiMutate(`/v1/modules/${id}`, 'DELETE', undefined, { idempotent: false });
deleted += 1; deleted += 1;
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : String(e)); notifyApiError(e);
} }
} }
if (deleted > 0) { if (deleted > 0) {
toast.success(`Удалено модулей: ${deleted}`); notify.success(`Удалено модулей: ${deleted}`);
} }
bulkDeleteModulesDialog = false;
await load(); await load();
} finally { } finally {
deletingBulkModules = false; deletingBulkModules = false;
@@ -178,21 +184,14 @@
} }
</script> </script>
<div class="space-y-6"> <div class="flex flex-col gap-6">
<div class="flex flex-wrap items-start justify-between gap-3"> <PageHeader
<div class="flex min-w-0 items-start gap-3"> title="Модули префиксов"
<div description="Управление модулями — AS, CDN, домены, IP-диапазоны."
class="bg-chart-1/15 text-chart-1 flex size-11 shrink-0 items-center justify-center rounded-xl" icon={Boxes}
aria-hidden="true" iconClass="bg-chart-1/15 text-chart-1"
> >
<Boxes class="size-6" /> {#snippet actions()}
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1>
<p class="text-muted-foreground mt-1 text-sm">Управление модулями — AS, CDN, домены, IP-диапазоны.</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onclick={load} disabled={loading}> <Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} /> <RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить Обновить
@@ -201,8 +200,8 @@
<Plus /> <Plus />
Новый модуль Новый модуль
</Button> </Button>
</div> {/snippet}
</div> </PageHeader>
<Card> <Card>
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3"> <CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
@@ -214,7 +213,7 @@
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={deletingBulkModules} disabled={deletingBulkModules}
onclick={() => (bulkDeleteModulesDialog = true)} onclick={requestBulkDelete}
> >
<Trash2 class="size-4" /> <Trash2 class="size-4" />
Удалить выбранные Удалить выбранные
@@ -222,72 +221,45 @@
</div> </div>
{/if} {/if}
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-4 pt-0">
<Table> <AppDataTable
<TableHeader> columns={[...moduleColumns]}
<TableRow> rows={rows}
<TableHead class="w-10"> rowKey={(m) => m.id}
<input {loading}
type="checkbox" emptyTitle="Нет модулей"
checked={allModulesSelected} emptyDescription="Создайте первый модуль."
indeterminate={someModulesSelected} >
aria-label="Выбрать все модули" {#snippet cell({ row: m, column })}
onchange={(event) => toggleAllModules((event.currentTarget as HTMLInputElement).checked)} {#if column.id === 'select'}
/> <Checkbox
</TableHead> checked={selectedModuleIds.has(m.id)}
<TableHead>Название</TableHead> aria-label={`Выбрать модуль ${m.name}`}
<TableHead>Тип</TableHead> onCheckedChange={() => toggleModuleSelection(m.id)}
<TableHead>Приоритет</TableHead> />
<TableHead>Интервал</TableHead> {:else if column.id === 'name'}
<TableHead>Последнее обновление</TableHead> <span class="font-medium">{m.name}</span>
<TableHead>Статус</TableHead> {:else if column.id === 'type'}
<TableHead class="w-16"></TableHead> <Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
</TableRow> {:else if column.id === 'priority'}
</TableHeader> <span class="text-muted-foreground">{m.priority}</span>
<TableBody> {:else if column.id === 'interval'}
{#each rows as m (m.id)} <span class="text-muted-foreground font-mono text-xs">{moduleIntervalLabel(m)}</span>
<TableRow> {:else if column.id === 'refreshed'}
<TableCell class="w-10"> <span class="text-muted-foreground text-sm whitespace-nowrap">{formatDateTime(m.last_refreshed_at)}</span>
<input {:else if column.id === 'status'}
type="checkbox" {#if m.enabled}
checked={selectedModuleIds.has(m.id)} <Badge variant="default" class="text-xs">вкл</Badge>
aria-label={`Выбрать модуль ${m.name}`} {:else}
onchange={() => toggleModuleSelection(m.id)} <Badge variant="secondary" class="text-xs">выкл</Badge>
/> {/if}
</TableCell> {:else if column.id === 'actions'}
<TableCell class="font-medium">{m.name}</TableCell> <Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<TableCell> <ExternalLink class="size-3.5" />
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge> </Button>
</TableCell> {/if}
<TableCell class="text-muted-foreground">{m.priority}</TableCell> {/snippet}
<TableCell class="text-muted-foreground font-mono text-xs"> </AppDataTable>
{moduleIntervalLabel(m)}
</TableCell>
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
{formatDateTime(m.last_refreshed_at)}
</TableCell>
<TableCell>
{#if m.enabled}
<Badge variant="default" class="text-xs">вкл</Badge>
{:else}
<Badge variant="secondary" class="text-xs">выкл</Badge>
{/if}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" />
</Button>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={8} class="text-muted-foreground text-center py-8">
{loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -353,19 +325,3 @@
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog bind:open={bulkDeleteModulesDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить выбранные модули?</AlertDialogTitle>
<AlertDialogDescription>
Будет удалено: {selectedModulesCount}. Это действие необратимо.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={bulkDeleteModules} disabled={deletingBulkModules}>
{deletingBulkModules ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>