Files
cloudflare-domain-manager/apps/web/src/routes/_auth/domains/index.tsx
T
DenozordecandCursor 704624cbe7
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m4s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m45s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
feat(ui): единый PageHeader h1 на всех разделах
Заголовок раздела над KPI/контентом; FrameTitle text-sm больше не заменяет page-level h1.

Co-authored-by: Cursor <[email protected]>
2026-07-17 14:29:44 +07:00

293 lines
9.8 KiB
TypeScript

import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
import {
bulkUpdateDomains,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
serviceBindingKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { DomainKpiCards } from '@/components/domain-kpi-cards'
import { ResourcePage } from '@/components/reui-kit'
import {
DOMAIN_TABS,
createDefaultDomainFilters,
domainFilterFieldValue,
domainTabFilter,
useDomainColumns,
useDomainFilterFields,
} from '@/components/columns/domains-columns'
import { DomainsBulkToolbar } from '@/components/domains/domains-bulk-toolbar'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
import type { DomainListItem } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/domains/')({
loader: ({ context: { queryClient } }) =>
Promise.all([
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
]),
component: DomainsPage,
})
function DomainsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [importGroupId, setImportGroupId] = useState('')
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const {
data: domains,
isLoading,
isError,
error,
refetch,
} = useQuery(domainsListQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const groupItems = useMemo(
() => [
{ label: 'Без группы', value: 'none' },
...(groups?.map((g) => ({ label: g.name, value: String(g.id) })) ?? []),
],
[groups],
)
const filterFields = useDomainFilterFields(groupItems)
const form = useForm<CreateDomainInput>({
resolver: zodResolver(createDomainSchema),
defaultValues: { zone_name: '', group_id: '' },
})
const createMutation = useMutation({
mutationFn: (body: { zone_name: string; group_id?: number }) =>
api.post('/api/v1/domains', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
form.reset({ zone_name: '', group_id: importGroupId })
setSheetOpen(false)
toast.success('Домен импортирован')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
setDeleteTarget(null)
toast.success('Зона удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить зону')
},
})
const bulkMutation = useMutation({
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success(`Обновлено: ${data.updated}`)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить')
},
})
const { columns } = useDomainColumns({
onRequestDelete: setDeleteTarget,
isDeleting: deleteMutation.isPending,
enableSelection: true,
})
const handleCreate = (values: CreateDomainInput) => {
createMutation.mutate({
zone_name: values.zone_name.trim(),
group_id: importGroupId ? Number(importGroupId) : undefined,
})
}
const handleImportGroupChange = (value: string | null) => {
const next = value === 'none' || !value ? '' : value
setImportGroupId(next)
form.setValue('group_id', next)
}
const primaryAction = (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
)
return (
<PageShell>
<PageHeader
title="Домены"
description="Импортированные зоны Cloudflare"
actions={primaryAction}
/>
<DomainKpiCards
domains={domains}
activeTab={activeTab}
isLoading={isLoading && !domains}
onSelectTab={handleSelectTab}
/>
<ResourcePage
title="Домены"
description="Импортированные зоны Cloudflare"
hideHeader
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={domainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultDomainFilters())}
getFilterFieldValue={domainFilterFieldValue}
columns={columns}
data={domains ?? []}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar
count={selectedIds.length}
isPending={bulkMutation.isPending}
groupItems={groupItems}
onAssignGroup={(groupId) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), group_id: groupId },
{ onSuccess: () => clearSelection() },
)
}}
onSetEnvironment={(environment) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), environment },
{ onSuccess: () => clearSelection() },
)
}}
onAddTag={(tag) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), tags_add: [tag] },
{ onSuccess: () => clearSelection() },
)
}}
onClear={clearSelection}
/>
)}
emptyState={{
title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare',
action: (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
),
}}
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title="Импорт домена"
description="Добавить зону из аккаунта Cloudflare в менеджер"
form={form}
onSubmit={handleCreate}
footer={
<LoadingButton
type="submit"
className="w-full"
isLoading={createMutation.isPending}
loadingLabel="Импорт…"
>
Импортировать
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple
label="Имя зоны"
htmlFor="zone_name"
error={form.formState.errors.zone_name}
>
<Input
id="zone_name"
placeholder="example.com"
{...form.register('zone_name')}
aria-invalid={!!form.formState.errors.zone_name}
/>
</FormFieldSimple>
<FormFieldSimple label="Группа" htmlFor="import_group_id">
<Select
items={groupItems}
value={importGroupId || 'none'}
onValueChange={handleImportGroupChange}
>
<SelectTrigger id="import_group_id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
</FieldGroup>
</FormSheet>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить зону?"
description={
deleteTarget
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
: ''
}
onConfirm={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id)
}}
disabled={deleteMutation.isPending}
/>
</PageShell>
)
}