feat(settings): переработать страницу на ReUI Frame
Docker / build (push) Failing after 21s

Разбить монолитную Card-форму на вкладки Общие/Синхронизация/Уведомления/Интеграции с SettingRow и SettingsCard по эталонам settings-16/3/2.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-19 04:54:42 +07:00
co-authored by Cursor
parent e079a61811
commit 74af90270f
12 changed files with 1458 additions and 674 deletions
@@ -3,13 +3,12 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { SettingRow } from '@/components/setting-row'
import { LoadingButton } from '@/components/loading-button'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import type { Settings } from '@/types/entities'
const formSchema = z.object({
@@ -26,7 +25,7 @@ function generateToken(): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
interface CfdmIntegrationCardProps {
interface CfdmIntegrationFormProps {
settings?: Settings
onSave: (values: {
cfdmApiUrl?: string
@@ -36,7 +35,12 @@ interface CfdmIntegrationCardProps {
isSaving?: boolean
}
export function CfdmIntegrationCard({ settings, onSave, isSaving }: CfdmIntegrationCardProps) {
/** CFDM integration form — Frame/SettingRow, no Card. Preview https://reui.io/preview/base/settings-3 */
export function CfdmIntegrationForm({
settings,
onSave,
isSaving,
}: CfdmIntegrationFormProps) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
values: {
@@ -56,79 +60,104 @@ export function CfdmIntegrationCard({ settings, onSave, isSaving }: CfdmIntegrat
}
return (
<Card>
<CardHeader>
<CardTitle>CF Domain Manager</CardTitle>
<CardDescription>
Приём синхронизации доменов и сервисов из CFDM. Скопируйте токен в настройки CFDM.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit(handleSubmit)(e)}>
<FieldGroup>
<FormField label="URL API CFDM" htmlFor="cfdm-api-url">
<Input
id="cfdm-api-url"
placeholder="http://192.168.100.67:6363 (для failover vps_down)"
{...form.register('cfdmApiUrl')}
<form
className="flex flex-col gap-0"
onSubmit={(e) => void form.handleSubmit(handleSubmit)(e)}
>
<FieldGroup className="gap-0">
<SettingRow
title="Принимать синхронизацию"
description="Разрешить CFDM пушить домены и сервисы"
>
<Controller
control={form.control}
name="integrationEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Принимать синхронизацию"
/>
</FormField>
<FormField label="Integration token" htmlFor="integration-token">
<div className="flex gap-2">
<Input
id="integration-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.integrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Сгенерируйте или вставьте токен'
}
{...form.register('integrationToken')}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const token = generateToken()
form.setValue('integrationToken', token, { shouldDirty: true })
void navigator.clipboard.writeText(token)
toast.success('Токен сгенерирован и скопирован')
}}
>
Сгенерировать
</Button>
</div>
</FormField>
<Controller
control={form.control}
name="integrationEnabled"
render={({ field }) => (
<FormField label="Принимать синхронизацию" htmlFor="integration-enabled">
<SelectField
triggerId="integration-enabled"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormField>
)}
)}
/>
</SettingRow>
<SettingRow
title="URL API CFDM"
description="Для failover vps_down"
labelFor="cfdm-api-url"
stacked
>
<Input
id="cfdm-api-url"
className="w-full"
placeholder="http://192.168.100.67:6363"
{...form.register('cfdmApiUrl')}
/>
</SettingRow>
<SettingRow
title="Integration token"
description={
settings?.integrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Сгенерируйте или вставьте токен'
}
labelFor="integration-token"
stacked
last={!settings?.integrationLastSyncAt}
>
<div className="flex w-full flex-col gap-2 sm:flex-row">
<Input
id="integration-token"
type="password"
autoComplete="new-password"
className="min-w-0 flex-1"
placeholder={
settings?.integrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Сгенерируйте или вставьте токен'
}
{...form.register('integrationToken')}
/>
{settings?.integrationLastSyncAt ? (
<p className="text-sm text-muted-foreground">
Последний sync: {new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}
</p>
) : null}
</FieldGroup>
<LoadingButton type="submit" className="w-fit" loading={isSaving} disabled={!form.formState.isDirty}>
Сохранить интеграцию
</LoadingButton>
</form>
</CardContent>
</Card>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => {
const token = generateToken()
form.setValue('integrationToken', token, { shouldDirty: true })
void navigator.clipboard.writeText(token)
toast.success('Токен сгенерирован и скопирован')
}}
>
Сгенерировать
</Button>
</div>
</SettingRow>
{settings?.integrationLastSyncAt ? (
<SettingRow
title="Последний sync"
description={new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}
last
>
<span className="text-muted-foreground text-sm tabular-nums">
{new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}
</span>
</SettingRow>
) : null}
</FieldGroup>
<div className="flex justify-end border-t px-5 py-3">
<LoadingButton
type="submit"
loading={isSaving}
disabled={!form.formState.isDirty}
>
Сохранить интеграцию
</LoadingButton>
</div>
</form>
)
}
/** @deprecated Use CfdmIntegrationForm */
export const CfdmIntegrationCard = CfdmIntegrationForm
@@ -13,4 +13,5 @@ export {
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { SettingsCard } from './settings-card'
export { TopologyCanvas } from './topology-canvas'
@@ -0,0 +1,55 @@
import { type ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
headerClassName?: string
}
/** Settings section Frame — preview https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
headerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className={cn('gap-0 border-b px-5 py-3', headerClassName)}>
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -1,6 +1,11 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PlugIcon, SettingsIcon } from 'lucide-react'
import {
BellIcon,
PlugIcon,
RefreshCwIcon,
SettingsIcon,
} from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
@@ -24,6 +29,18 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
exact: true,
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
{
id: 'sync',
to: '/settings/sync',
label: 'Синхронизация',
icon: <RefreshCwIcon className="size-4" aria-hidden="true" />,
},
{
id: 'notifications',
to: '/settings/notifications',
label: 'Уведомления',
icon: <BellIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
+85
View File
@@ -0,0 +1,85 @@
import { type ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@cfdm/ui/components/field'
export interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
className?: string
titleAddon?: ReactNode
}
/** Compact settings row — preview https://reui.io/preview/base/settings-3 */
export function SettingRow({
title,
description,
children,
last,
compact,
stacked,
labelFor,
contentClassName,
className,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn('gap-4 px-5 py-4', className)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)}
>
<div
className={cn(
'flex w-full justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,97 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields'
import type { SettingsFormValues } from '@/lib/schemas'
import type { Settings } from '@/types/entities'
export function settingsToFormValues(s: Settings): SettingsFormValues {
return {
id: s.id,
baseCurrency: s.baseCurrency ?? 'RUB',
ratesUrl: s.ratesUrl ?? '',
autoConvert: s.autoConvert !== false,
syncEnabled: s.syncEnabled !== false,
syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
telegramChatId: s.telegramChatId ?? '',
telegramBotToken: '',
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
notifyVpsDownEnabled: s.notifyVpsDownEnabled !== false,
notifyIntervalMinutes: s.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes: s.uptimeCheckIntervalMinutes ?? 5,
webhookUrl: s.webhookUrl ?? '',
webhookEnabled: s.webhookEnabled === true,
customFields: parseCustomFieldDefs(s.customFields),
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
showQuickActions: s.showQuickActions !== false,
}
}
export function buildSettingsSavePayload(
r: Partial<SettingsFormValues>,
): Partial<SettingsFormValues> {
const { telegramBotToken, ...rest } = r
const token = telegramBotToken?.trim() ?? ''
return token ? { ...rest, telegramBotToken: token } : rest
}
export function useSettingsSnapshot() {
const query = useQuery(snapshotQueryOptions())
const current = query.data?.settings?.[0] as Settings | undefined
return { ...query, current }
}
export function useSettingsSave(options?: { successMessage?: string }) {
const queryClient = useQueryClient()
const { current } = useSettingsSnapshot()
const successMessage = options?.successMessage ?? 'Настройки сохранены'
return useMutation({
mutationFn: (patch: Partial<SettingsFormValues>) => {
const payload = buildSettingsSavePayload(patch)
if (current?.id) return api.update<Settings>('settings', current.id, payload)
return api.create<Settings>('settings', {
id: 'settings-main',
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
...payload,
} as Settings)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
toast.success(successMessage)
},
onError: (e: unknown) =>
toast.error(e instanceof ApiError ? e.message : 'Не удалось сохранить'),
})
}
export function useSettingsPatch(options?: { successMessage?: string }) {
const queryClient = useQueryClient()
const { current } = useSettingsSnapshot()
const successMessage = options?.successMessage ?? 'Настройки сохранены'
return useMutation({
mutationFn: (patch: Partial<Settings>) => {
if (!current?.id) {
return api.create<Settings>('settings', {
id: 'settings-main',
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
...patch,
} as Settings)
}
return api.update<Settings>('settings', current.id, patch)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
toast.success(successMessage)
},
onError: (e: unknown) =>
toast.error(e instanceof ApiError ? e.message : 'Не удалось сохранить'),
})
}
+43
View File
@@ -30,6 +30,8 @@ import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
import { Route as AuthSettingsSyncRouteImport } from './routes/_auth/settings/sync'
import { Route as AuthSettingsNotificationsRouteImport } from './routes/_auth/settings/notifications'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
@@ -137,6 +139,17 @@ const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
path: '/$vpsId',
getParentRoute: () => AuthVpsRoute,
} as any)
const AuthSettingsSyncRoute = AuthSettingsSyncRouteImport.update({
id: '/sync',
path: '/sync',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsNotificationsRoute =
AuthSettingsNotificationsRouteImport.update({
id: '/notifications',
path: '/notifications',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsIntegrationsRoute =
AuthSettingsIntegrationsRouteImport.update({
id: '/integrations',
@@ -170,6 +183,8 @@ export interface FileRoutesByFullPath {
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/settings/notifications': typeof AuthSettingsNotificationsRoute
'/settings/sync': typeof AuthSettingsSyncRoute
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/settings/': typeof AuthSettingsIndexRoute
}
@@ -193,6 +208,8 @@ export interface FileRoutesByTo {
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/settings/notifications': typeof AuthSettingsNotificationsRoute
'/settings/sync': typeof AuthSettingsSyncRoute
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/settings': typeof AuthSettingsIndexRoute
}
@@ -219,6 +236,8 @@ export interface FileRoutesById {
'/auth/callback': typeof AuthCallbackRoute
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute
'/_auth/settings/sync': typeof AuthSettingsSyncRoute
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
@@ -245,6 +264,8 @@ export interface FileRouteTypes {
| '/auth/callback'
| '/projects/$projectId'
| '/settings/integrations'
| '/settings/notifications'
| '/settings/sync'
| '/vps/$vpsId'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
@@ -268,6 +289,8 @@ export interface FileRouteTypes {
| '/auth/callback'
| '/projects/$projectId'
| '/settings/integrations'
| '/settings/notifications'
| '/settings/sync'
| '/vps/$vpsId'
| '/settings'
id:
@@ -293,6 +316,8 @@ export interface FileRouteTypes {
| '/auth/callback'
| '/_auth/projects/$projectId'
| '/_auth/settings/integrations'
| '/_auth/settings/notifications'
| '/_auth/settings/sync'
| '/_auth/vps/$vpsId'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
@@ -452,6 +477,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
parentRoute: typeof AuthVpsRoute
}
'/_auth/settings/sync': {
id: '/_auth/settings/sync'
path: '/sync'
fullPath: '/settings/sync'
preLoaderRoute: typeof AuthSettingsSyncRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/notifications': {
id: '/_auth/settings/notifications'
path: '/notifications'
fullPath: '/settings/notifications'
preLoaderRoute: typeof AuthSettingsNotificationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/integrations': {
id: '/_auth/settings/integrations'
path: '/integrations'
@@ -471,11 +510,15 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren {
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
AuthSettingsNotificationsRoute: typeof AuthSettingsNotificationsRoute
AuthSettingsSyncRoute: typeof AuthSettingsSyncRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
AuthSettingsNotificationsRoute: AuthSettingsNotificationsRoute,
AuthSettingsSyncRoute: AuthSettingsSyncRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
+250 -556
View File
@@ -1,196 +1,68 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { DownloadIcon, UploadIcon } from 'lucide-react'
import { useMemo, useCallback } from 'react'
import { useCallback } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { FormField } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { SettingRow } from '@/components/setting-row'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import {
settingsToFormValues,
useSettingsPatch,
useSettingsSave,
useSettingsSnapshot,
} from '@/components/settings/use-settings-section'
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
import type { NotificationLogRow, Settings } from '@/types/entities'
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Switch } from '@cfdm/ui/components/switch'
export const Route = createFileRoute('/_auth/settings/')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsPage,
component: SettingsGeneralPage,
})
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
const NOTIFICATION_STATUS_MAP: Record<string, string> = {
sent: 'ok',
failed: 'error',
}
const NOTIFICATION_STATUS_LABELS: Record<string, string> = {
sent: 'Отправлено',
failed: 'Ошибка',
}
function notificationStatusLabel(status: string): string {
return NOTIFICATION_STATUS_LABELS[status] ?? status
}
function settingsToFormValues(s: Settings): SettingsFormValues {
return {
id: s.id,
baseCurrency: s.baseCurrency ?? 'RUB',
ratesUrl: s.ratesUrl ?? '',
autoConvert: s.autoConvert !== false,
syncEnabled: s.syncEnabled !== false,
syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
telegramChatId: s.telegramChatId ?? '',
telegramBotToken: '',
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
notifyVpsDownEnabled: s.notifyVpsDownEnabled !== false,
notifyIntervalMinutes: s.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes: s.uptimeCheckIntervalMinutes ?? 5,
webhookUrl: s.webhookUrl ?? '',
webhookEnabled: s.webhookEnabled === true,
customFields: parseCustomFieldDefs(s.customFields),
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
showQuickActions: s.showQuickActions !== false,
}
}
function buildSettingsSavePayload(r: SettingsFormValues): SettingsFormValues {
const { telegramBotToken, ...rest } = r
const token = telegramBotToken?.trim() ?? ''
return token ? { ...rest, telegramBotToken: token } : (rest as SettingsFormValues)
}
function buildTelegramTestPayload(values: SettingsFormValues) {
const token = values.telegramBotToken?.trim() ?? ''
const payload: {
telegramChatId?: string
telegramMessageThreadId?: string
telegramBotToken?: string
} = {
telegramChatId: values.telegramChatId?.trim() || undefined,
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
}
if (token) payload.telegramBotToken = token
return payload
}
function BoolSelect({
id,
label,
value,
onChange,
}: {
id: string
label: string
value: boolean
onChange: (v: boolean) => void
}) {
function SettingsSkeleton() {
return (
<FormField label={label} htmlFor={id}>
<SelectField
triggerId={id}
triggerClassName="w-32"
value={value ? 'on' : 'off'}
onValueChange={(v) => onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormField>
<div className="flex flex-col gap-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
)
}
function SettingsPage() {
function SettingsGeneralPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const current = snapshot?.settings?.[0]
const { data: snapshot, current, isLoading, isError, error, refetch } =
useSettingsSnapshot()
const patchMut = useSettingsPatch({ successMessage: 'Настройки интерфейса сохранены' })
const saveMut = useSettingsSave()
const formValues = current ? settingsToFormValues(current) : undefined
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
values: current ? settingsToFormValues(current) : undefined,
})
const upsertMut = useMutation({
mutationFn: (patch: SettingsFormValues) => {
const payload = buildSettingsSavePayload(patch)
if (current?.id) return api.update<Settings>('settings', current.id, payload)
return api.create<Settings>('settings', {
id: 'settings-main',
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
...payload,
} as Settings)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
void refetchLog()
toast.success('Настройки сохранены')
form.reset(form.getValues())
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const telegramTestMut = useMutation({
mutationFn: () => api.sendTelegramTest(buildTelegramTestPayload(form.getValues())),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка Telegram', { duration: 10_000 })
return
}
toast.success('Тестовое сообщение отправлено')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки', { duration: 10_000 }),
})
const webhookTestMut = useMutation({
mutationFn: () => api.sendWebhookTest(),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка webhook')
return
}
toast.success('Тестовый webhook отправлен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
const { data: notificationLog = [], refetch: refetchLog } = useQuery({
queryKey: ['notifications', 'log'],
queryFn: () => api.fetchNotificationLog(30),
values: formValues,
})
const importJsonMut = useMutation({
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
toast.success('Импорт JSON выполнен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
@@ -199,7 +71,7 @@ function SettingsPage() {
const importDbMut = useMutation({
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
toast.success('Импорт SQLite выполнен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
@@ -212,8 +84,7 @@ function SettingsPage() {
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const text = await file.text()
importJsonMut.mutate(text)
importJsonMut.mutate(await file.text())
}
input.click()
}, [importJsonMut])
@@ -225,409 +96,232 @@ function SettingsPage() {
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const buffer = await file.arrayBuffer()
importDbMut.mutate(buffer)
importDbMut.mutate(await file.arrayBuffer())
}
input.click()
}, [importDbMut])
const notificationRows = useMemo(
() => notificationLog as NotificationLogRow[],
[notificationLog],
)
const backupActions = (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={async () => {
try {
const blob = await api.downloadBackupJson()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vps-tracker-backup-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
toast.success('JSON выгружен')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
}
}}
>
<DownloadIcon data-icon="inline-start" />
JSON
</Button>
<Button
variant="outline"
onClick={async () => {
try {
const blob = await api.downloadBackupDatabase()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vps-tracker-${new Date().toISOString().slice(0, 10)}.db`
a.click()
URL.revokeObjectURL(url)
toast.success('База выгружена')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
}
}}
>
<DownloadIcon data-icon="inline-start" />
SQLite
</Button>
<ConfirmDialog
title="Импортировать JSON?"
description="Текущие данные будут перезаписаны содержимым файла резервной копии."
confirmLabel="Выбрать файл"
destructive
onConfirm={pickJsonFile}
trigger={
<LoadingButton
type="button"
variant="outline"
loading={importJsonMut.isPending}
>
<UploadIcon data-icon="inline-start" />
Импорт JSON
</LoadingButton>
}
/>
<ConfirmDialog
title="Импортировать SQLite?"
description="Текущая база данных будет полностью заменена загруженным файлом .db."
confirmLabel="Выбрать файл"
destructive
onConfirm={pickDbFile}
trigger={
<LoadingButton
type="button"
variant="outline"
loading={importDbMut.isPending}
>
<UploadIcon data-icon="inline-start" />
Импорт SQLite
</LoadingButton>
}
/>
</div>
)
const showQuickActions = current?.showQuickActions !== false
return (
<>
<div className="flex flex-wrap gap-2">{backupActions}</div>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
>
{() => (
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SettingsSkeleton />}
>
{() => (
<div className="flex flex-col gap-4">
{/* preview https://reui.io/preview/base/settings-2 */}
<SettingsCard title="Интерфейс" description="Блоки на панели управления">
<FieldGroup className="gap-0">
<SettingRow
title="Быстрые действия"
description="KPI-like плитки быстрых переходов под метриками на главной."
last
>
<Switch
checked={showQuickActions}
disabled={isLoading || patchMut.isPending}
onCheckedChange={(checked) =>
patchMut.mutate({ showQuickActions: checked })
}
aria-label="Показывать быстрые действия"
/>
</SettingRow>
</FieldGroup>
</SettingsCard>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => upsertMut.mutate(values))(e)}
onSubmit={(e) =>
void form.handleSubmit((values) => {
saveMut.mutate(
{
baseCurrency: values.baseCurrency,
ratesUrl: values.ratesUrl,
autoConvert: values.autoConvert,
customFields: values.customFields,
},
{ onSuccess: () => form.reset(values) },
)
})(e)
}
>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Интерфейс</CardTitle>
<CardDescription>Блоки на дашборде</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Быстрые действия" htmlFor="set-qa">
<Controller
control={form.control}
name="showQuickActions"
render={({ field }) => (
<div className="flex items-center gap-3">
<Switch
id="set-qa"
checked={field.value !== false}
onCheckedChange={field.onChange}
/>
<span className="text-muted-foreground text-sm">
Показывать на дашборде
</span>
</div>
)}
/>
</FormField>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Валюта и курсы</CardTitle>
<CardDescription>Отображение сумм и источник курсов</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Базовая валюта" htmlFor="set-cur" error={form.formState.errors.baseCurrency?.message}>
<Controller
control={form.control}
name="baseCurrency"
render={({ field }) => (
<SelectField
triggerId="set-cur"
value={field.value}
onValueChange={(v) => field.onChange(v ?? 'RUB')}
options={CURRENCIES.map((c) => ({ value: c, label: c }))}
/>
)}
/>
</FormField>
<FormField label="URL курсов (JSON)" htmlFor="set-rates" error={form.formState.errors.ratesUrl?.message}>
<Input
id="set-rates"
placeholder="https://www.cbr-xml-daily.ru/latest.js"
{...form.register('ratesUrl')}
/>
</FormField>
<Controller
control={form.control}
name="autoConvert"
render={({ field }) => (
<BoolSelect
id="set-auto"
label="Автоконвертация"
value={field.value}
onChange={field.onChange}
/>
)}
/>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Telegram</CardTitle>
<CardDescription>Уведомления о здоровье инвентаря</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Chat ID" htmlFor="set-tg-chat">
<Input id="set-tg-chat" placeholder="-1001234567890" {...form.register('telegramChatId')} />
</FormField>
<FormField label="Bot token" htmlFor="set-tg-token">
<Input
id="set-tg-token"
type="password"
autoComplete="new-password"
placeholder={
current?.telegramBotTokenSet ? 'Токен установлен — введите новый для замены' : '123456:ABC-DEF...'
}
{...form.register('telegramBotToken')}
/>
</FormField>
<FormField label="Thread ID (топик)" htmlFor="set-tg-thread">
<Input id="set-tg-thread" placeholder="Необязательно" {...form.register('telegramMessageThreadId')} />
</FormField>
<LoadingButton
type="button"
variant="outline"
onClick={() => telegramTestMut.mutate()}
loading={telegramTestMut.isPending}
>
Тест
</LoadingButton>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Синхронизация</CardTitle>
<CardDescription>Автосинк BILLmanager и интервалы</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Controller
control={form.control}
name="syncEnabled"
render={({ field }) => (
<BoolSelect id="set-sync" label="Автосинк" value={field.value ?? true} onChange={field.onChange} />
)}
/>
<FormField label="Интервал синка (мин)" htmlFor="set-sync-int">
<Input id="set-sync-int" type="number" min={15} {...form.register('syncIntervalMinutes')} />
</FormField>
<FormField label="Интервал тарифов (мин)" htmlFor="set-tariff-int">
<Input id="set-tariff-int" type="number" min={60} {...form.register('syncTariffsIntervalMinutes')} />
</FormField>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Уведомления</CardTitle>
<CardDescription>События, интервалы и каналы доставки</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Интервал проверки оплаты (мин)" htmlFor="set-notify-int">
<Input id="set-notify-int" type="number" min={15} {...form.register('notifyIntervalMinutes')} />
</FormField>
<FormField label="Интервал uptime-проверки (мин)" htmlFor="set-uptime-int">
<Input
id="set-uptime-int"
type="number"
min={1}
{...form.register('uptimeCheckIntervalMinutes')}
/>
</FormField>
<Controller
control={form.control}
name="notifyLowBalanceEnabled"
render={({ field }) => (
<BoolSelect id="set-notify-bal" label="Низкий баланс" value={field.value ?? true} onChange={field.onChange} />
)}
/>
<Controller
control={form.control}
name="notifySyncDigestEnabled"
render={({ field }) => (
<BoolSelect id="set-notify-sync" label="Дайджест синка" value={field.value ?? true} onChange={field.onChange} />
)}
/>
<Controller
control={form.control}
name="notifyPaymentExpiryEnabled"
render={({ field }) => (
<BoolSelect id="set-notify-pay" label="Истечение оплаты" value={field.value ?? true} onChange={field.onChange} />
)}
/>
<Controller
control={form.control}
name="notifyNewTariffsEnabled"
render={({ field }) => (
<BoolSelect id="set-notify-tar" label="Новые тарифы" value={field.value ?? true} onChange={field.onChange} />
)}
/>
<Controller
control={form.control}
name="notifyVpsDownEnabled"
render={({ field }) => (
<BoolSelect id="set-notify-down" label="VPS недоступен" value={field.value ?? true} onChange={field.onChange} />
)}
/>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Webhook</CardTitle>
<CardDescription>POST JSON при тех же событиях, что и Telegram</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Controller
control={form.control}
name="webhookEnabled"
render={({ field }) => (
<BoolSelect id="set-webhook" label="Webhook" value={field.value ?? false} onChange={field.onChange} />
)}
/>
<FormField label="Webhook URL" htmlFor="set-webhook-url" error={form.formState.errors.webhookUrl?.message}>
<Input id="set-webhook-url" placeholder="https://hooks.example.com/..." {...form.register('webhookUrl')} />
</FormField>
<LoadingButton
type="button"
variant="outline"
onClick={() => webhookTestMut.mutate()}
loading={webhookTestMut.isPending}
>
Тест webhook
</LoadingButton>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Журнал уведомлений</CardTitle>
<CardDescription>Последние попытки доставки (Telegram и webhook)</CardDescription>
</CardHeader>
<CardContent>
{notificationRows.length === 0 ? (
<EmptyState title="Записей пока нет" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Время</TableHead>
<TableHead>Событие</TableHead>
<TableHead>Канал</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{notificationRows.map((row) => {
const errorText =
row.status === 'failed' && row.payload?.error != null
? String(row.payload.error)
: ''
return (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">
{new Date(row.createdAt).toLocaleString('ru-RU')}
</TableCell>
<TableCell>{row.event}</TableCell>
<TableCell>{row.channel}</TableCell>
<TableCell>
<StatusBadge
status={NOTIFICATION_STATUS_MAP[row.status] ?? row.status}
label={notificationStatusLabel(row.status)}
/>
</TableCell>
<TableCell className="max-w-xs break-words text-xs text-destructive">
{errorText || '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Кастомные поля VPS</CardTitle>
<CardDescription>
Поля отображаются в таблице VPS и в форме редактирования сервера
</CardDescription>
</CardHeader>
<CardContent>
<CustomFieldsEditor
control={form.control}
setValue={form.setValue}
errors={form.formState.errors.customFields}
/>
</CardContent>
</Card>
</div>
<LoadingButton
type="submit"
className="w-fit"
loading={upsertMut.isPending}
disabled={!form.formState.isDirty}
{/* preview https://reui.io/preview/base/settings-3 */}
<SettingsCard
title="Валюта и курсы"
description="Отображение сумм и источник курсов"
>
Сохранить настройки
</LoadingButton>
<FieldGroup className="gap-0">
<SettingRow
title="Базовая валюта"
description="Валюта для отображения сумм"
labelFor="set-cur"
compact
>
<Controller
control={form.control}
name="baseCurrency"
render={({ field }) => (
<SelectField
triggerId="set-cur"
triggerClassName="w-32"
value={field.value}
onValueChange={(v) => field.onChange(v ?? 'RUB')}
options={CURRENCIES.map((c) => ({ value: c, label: c }))}
/>
)}
/>
</SettingRow>
<SettingRow
title="URL курсов"
description="JSON-источник курсов валют"
labelFor="set-rates"
stacked
>
<Input
id="set-rates"
className="w-full"
placeholder="https://www.cbr-xml-daily.ru/latest.js"
aria-invalid={!!form.formState.errors.ratesUrl}
{...form.register('ratesUrl')}
/>
</SettingRow>
<SettingRow
title="Автоконвертация"
description="Пересчитывать суммы в базовую валюту"
last
>
<Controller
control={form.control}
name="autoConvert"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Автоконвертация"
/>
)}
/>
</SettingRow>
</FieldGroup>
</SettingsCard>
<SettingsCard
title="Кастомные поля VPS"
description="Поля отображаются в таблице VPS и в форме редактирования сервера"
footer={
<LoadingButton
type="submit"
loading={saveMut.isPending}
disabled={!form.formState.isDirty}
>
Сохранить
</LoadingButton>
}
>
<div className="px-5 py-4">
<CustomFieldsEditor
control={form.control}
setValue={form.setValue}
errors={form.formState.errors.customFields}
/>
</div>
</SettingsCard>
</form>
)}
</QueryState>
</>
<SettingsCard
title="Резервные копии"
description="Экспорт и импорт данных приложения"
>
<div className="flex flex-wrap gap-2 px-5 py-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const blob = await api.downloadBackupJson()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vps-tracker-backup-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
toast.success('JSON выгружен')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
}
}}
>
<DownloadIcon data-icon="inline-start" />
JSON
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const blob = await api.downloadBackupDatabase()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vps-tracker-${new Date().toISOString().slice(0, 10)}.db`
a.click()
URL.revokeObjectURL(url)
toast.success('База выгружена')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
}
}}
>
<DownloadIcon data-icon="inline-start" />
SQLite
</Button>
<ConfirmDialog
title="Импортировать JSON?"
description="Текущие данные будут перезаписаны содержимым файла резервной копии."
confirmLabel="Выбрать файл"
destructive
onConfirm={pickJsonFile}
trigger={
<LoadingButton
type="button"
variant="outline"
size="sm"
loading={importJsonMut.isPending}
>
<UploadIcon data-icon="inline-start" />
Импорт JSON
</LoadingButton>
}
/>
<ConfirmDialog
title="Импортировать SQLite?"
description="Текущая база данных будет полностью заменена загруженным файлом .db."
confirmLabel="Выбрать файл"
destructive
onConfirm={pickDbFile}
trigger={
<LoadingButton
type="button"
variant="outline"
size="sm"
loading={importDbMut.isPending}
>
<UploadIcon data-icon="inline-start" />
Импорт SQLite
</LoadingButton>
}
/>
</div>
</SettingsCard>
</div>
)}
</QueryState>
)
}
@@ -1,17 +1,23 @@
import { useState, type ReactNode } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ExternalLinkIcon } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
ChevronDownIcon,
ExternalLinkIcon,
GlobeIcon,
LayoutGridIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api } from '@/lib/api-client'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card'
import type { Settings } from '@/types/entities'
import { CfdmIntegrationForm } from '@/components/integrations/cfdm-integration-card'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { Button } from '@cfdm/ui/components/button'
import { useSettingsSnapshot } from '@/components/settings/use-settings-section'
import type { Settings } from '@/types/entities'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
@@ -19,15 +25,111 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { cn } from '@cfdm/ui/lib/utils'
export const Route = createFileRoute('/_auth/settings/integrations')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsIntegrationsPage,
})
type IntegrationStatus = 'connected' | 'available' | 'warning'
const STATUS_META: Record<
IntegrationStatus,
{ label: string; variant: 'success' | 'secondary' | 'warning' }
> = {
connected: { label: 'Подключено', variant: 'success' },
available: { label: 'Доступно', variant: 'secondary' },
warning: { label: 'Требует настройки', variant: 'warning' },
}
/** Integration row — preview https://reui.io/preview/base/settings-16 */
function IntegrationRow({
id,
name,
description,
logo,
status,
open,
onOpenChange,
children,
}: {
id: string
name: string
description: string
logo: ReactNode
status: IntegrationStatus
open: boolean
onOpenChange: (open: boolean) => void
children: ReactNode
}) {
const meta = STATUS_META[status]
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<Item className="items-center gap-3 border-0 px-3.5 py-3 sm:px-4">
<ItemMedia variant="icon">{logo}</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">{name}</span>
<Badge variant={meta.variant}>{meta.label}</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-1">{description}</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
<CollapsibleTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
aria-controls={`${id}-panel`}
aria-expanded={open}
/>
}
>
Настроить
<ChevronDownIcon
aria-hidden="true"
data-icon="inline-end"
className={cn('transition-transform', open && 'rotate-180')}
/>
</CollapsibleTrigger>
</ItemActions>
</Item>
<CollapsibleContent id={`${id}-panel`}>
<div className="border-border/60 border-t px-0 py-0">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
function SettingsIntegrationsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const current = snapshot?.settings?.[0] as Settings | undefined
const [openAppSwitcher, setOpenAppSwitcher] = useState(true)
const [openCfdm, setOpenCfdm] = useState(true)
const { data: snapshot, current, isLoading, isError, error, refetch } =
useSettingsSnapshot()
const { config: appSwitcher } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
@@ -41,6 +143,17 @@ function SettingsIntegrationsPage() {
onError: () => toast.error('Не удалось сохранить'),
})
const appSwitcherStatus: IntegrationStatus =
appSwitcher.apps.length > 0 ? 'connected' : 'available'
const cfdmStatus: IntegrationStatus = !current
? 'available'
: current.integrationEnabled && current.integrationTokenSet
? 'connected'
: current.cfdmApiUrl || current.integrationTokenSet
? 'warning'
: 'available'
return (
<QueryState
data={snapshot}
@@ -48,41 +161,107 @@ function SettingsIntegrationsPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={2} />}
skeleton={
<div className="flex flex-col gap-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
}
>
{() => (
<div className="flex flex-col gap-4">
<Frame>
<FrameHeader>
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
{appSwitcher.apps.length} приложений · меню «{appSwitcher.menuLabel}». Ссылки
настраиваются на auth-portal.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3">
<div className="flex w-full flex-col gap-5">
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Подключённые приложения</h2>
<p className="text-muted-foreground text-sm">
Локальный редактор отключён. Source of truth портал.
Переключение между сервисами в sidebar
</p>
{isAuthEnabled() ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
render={<a href={portalAppsUrl} />}
>
Открыть на портале
<ExternalLinkIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : null}
</FramePanel>
</Frame>
<CfdmIntegrationCard
settings={current}
isSaving={saveMut.isPending}
onSave={(patch) => saveMut.mutate(patch)}
/>
</div>
<Frame>
<FrameHeader className="sr-only">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Ссылки приложений настраиваются на auth-portal
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<IntegrationRow
id="app-switcher"
name="App Switcher"
description={`${appSwitcher.apps.length} приложений · меню «${appSwitcher.menuLabel}»`}
logo={<LayoutGridIcon aria-hidden="true" />}
status={appSwitcherStatus}
open={openAppSwitcher}
onOpenChange={setOpenAppSwitcher}
>
<div className="flex flex-col gap-3 px-3.5 py-4 sm:px-4">
<p className="text-muted-foreground text-sm">
Список и URL сервисов хранятся на auth-portal. Локальный редактор
отключён.
</p>
{isAuthEnabled() ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
render={<a href={portalAppsUrl} />}
>
Открыть на портале
<ExternalLinkIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : (
<p className="text-muted-foreground text-xs">
Включите auth-portal, чтобы редактировать ссылки.
</p>
)}
</div>
</IntegrationRow>
</ItemGroup>
</FramePanel>
</Frame>
</section>
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Внешние интеграции</h2>
<p className="text-muted-foreground text-sm">
Синхронизация доменов и сервисов из CF Domain Manager
</p>
</div>
<Frame>
<FrameHeader className="sr-only">
<FrameTitle>CF Domain Manager</FrameTitle>
<FrameDescription>URL API и integration token</FrameDescription>
</FrameHeader>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<IntegrationRow
id="cfdm"
name="CF Domain Manager"
description={
current?.cfdmApiUrl
? current.cfdmApiUrl
: 'Приём синхронизации доменов и сервисов'
}
logo={<GlobeIcon aria-hidden="true" />}
status={cfdmStatus}
open={openCfdm}
onOpenChange={setOpenCfdm}
>
<CfdmIntegrationForm
settings={current}
isSaving={saveMut.isPending}
onSave={(values) => saveMut.mutate(values)}
/>
</IntegrationRow>
</ItemGroup>
</FramePanel>
</Frame>
</section>
</div>
)}
</QueryState>
@@ -0,0 +1,429 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { useMemo } from 'react'
import { z } from 'zod'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { StatusBadge } from '@/components/status-badge'
import { LoadingButton } from '@/components/loading-button'
import { SettingRow } from '@/components/setting-row'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import {
settingsToFormValues,
useSettingsSave,
useSettingsSnapshot,
} from '@/components/settings/use-settings-section'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Switch } from '@cfdm/ui/components/switch'
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
import type { NotificationLogRow } from '@/types/entities'
export const Route = createFileRoute('/_auth/settings/notifications')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsNotificationsPage,
})
const notifySchema = z.object({
telegramChatId: z.string().optional().default(''),
telegramBotToken: z.string().optional().default(''),
telegramMessageThreadId: z.string().optional().default(''),
notifyPaymentExpiryEnabled: z.boolean().default(true),
notifyNewTariffsEnabled: z.boolean().default(true),
notifyLowBalanceEnabled: z.boolean().default(true),
notifySyncDigestEnabled: z.boolean().default(true),
notifyVpsDownEnabled: z.boolean().default(true),
notifyIntervalMinutes: z.coerce.number().min(15).default(60),
uptimeCheckIntervalMinutes: z.coerce.number().min(1).default(5),
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
webhookEnabled: z.boolean().default(false),
})
type NotifyFormValues = z.infer<typeof notifySchema>
const NOTIFICATION_STATUS_MAP: Record<string, string> = {
sent: 'ok',
failed: 'error',
}
const NOTIFICATION_STATUS_LABELS: Record<string, string> = {
sent: 'Отправлено',
failed: 'Ошибка',
}
function SettingsNotificationsPage() {
const { data: snapshot, current, isLoading, isError, error, refetch } =
useSettingsSnapshot()
const saveMut = useSettingsSave({ successMessage: 'Настройки уведомлений сохранены' })
const formValues = current ? settingsToFormValues(current) : undefined
const form = useForm<NotifyFormValues>({
resolver: zodResolver(notifySchema),
values: formValues
? {
telegramChatId: formValues.telegramChatId ?? '',
telegramBotToken: '',
telegramMessageThreadId: formValues.telegramMessageThreadId ?? '',
notifyPaymentExpiryEnabled: formValues.notifyPaymentExpiryEnabled ?? true,
notifyNewTariffsEnabled: formValues.notifyNewTariffsEnabled ?? true,
notifyLowBalanceEnabled: formValues.notifyLowBalanceEnabled ?? true,
notifySyncDigestEnabled: formValues.notifySyncDigestEnabled ?? true,
notifyVpsDownEnabled: formValues.notifyVpsDownEnabled ?? true,
notifyIntervalMinutes: formValues.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes: formValues.uptimeCheckIntervalMinutes ?? 5,
webhookUrl: formValues.webhookUrl ?? '',
webhookEnabled: formValues.webhookEnabled ?? false,
}
: undefined,
})
const webhookEnabled = form.watch('webhookEnabled')
const telegramTestMut = useMutation({
mutationFn: () => {
const values = form.getValues()
const token = values.telegramBotToken?.trim() ?? ''
const payload: {
telegramChatId?: string
telegramMessageThreadId?: string
telegramBotToken?: string
} = {
telegramChatId: values.telegramChatId?.trim() || undefined,
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
}
if (token) payload.telegramBotToken = token
return api.sendTelegramTest(payload)
},
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка Telegram', { duration: 10_000 })
return
}
toast.success('Тестовое сообщение отправлено')
},
onError: (e: unknown) =>
toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки', { duration: 10_000 }),
})
const webhookTestMut = useMutation({
mutationFn: () => api.sendWebhookTest(),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка webhook')
return
}
toast.success('Тестовый webhook отправлен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
const { data: notificationLog = [] } = useQuery({
queryKey: ['notifications', 'log'],
queryFn: () => api.fetchNotificationLog(30),
})
const notificationRows = useMemo(
() => notificationLog as NotificationLogRow[],
[notificationLog],
)
return (
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
}
>
{() => (
<form
className="flex flex-col gap-4"
onSubmit={(e) =>
void form.handleSubmit((values) => {
saveMut.mutate(values, {
onSuccess: () =>
form.reset({ ...values, telegramBotToken: '' }),
})
})(e)
}
>
{/* preview https://reui.io/preview/base/settings-3 */}
<SettingsCard
title="Telegram"
description="Уведомления о здоровье инвентаря"
footer={
<LoadingButton
type="button"
variant="outline"
onClick={() => telegramTestMut.mutate()}
loading={telegramTestMut.isPending}
>
Тест
</LoadingButton>
}
>
<FieldGroup className="gap-0">
<SettingRow title="Chat ID" labelFor="set-tg-chat" compact>
<Input
id="set-tg-chat"
className="w-full max-w-xs"
placeholder="-1001234567890"
{...form.register('telegramChatId')}
/>
</SettingRow>
<SettingRow title="Bot token" labelFor="set-tg-token" stacked>
<Input
id="set-tg-token"
type="password"
autoComplete="new-password"
className="w-full"
placeholder={
current?.telegramBotTokenSet
? 'Токен установлен — введите новый для замены'
: '123456:ABC-DEF...'
}
{...form.register('telegramBotToken')}
/>
</SettingRow>
<SettingRow
title="Thread ID"
description="Топик форума (необязательно)"
labelFor="set-tg-thread"
compact
last
>
<Input
id="set-tg-thread"
className="w-full max-w-xs"
placeholder="Необязательно"
{...form.register('telegramMessageThreadId')}
/>
</SettingRow>
</FieldGroup>
</SettingsCard>
<SettingsCard title="Webhook" description="POST JSON при тех же событиях, что и Telegram">
<FieldGroup className="gap-0">
<SettingRow
title="Webhook"
description="Включить доставку событий на URL"
last={!webhookEnabled}
>
<Controller
control={form.control}
name="webhookEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Webhook"
/>
)}
/>
</SettingRow>
{webhookEnabled ? (
<SettingRow title="Webhook URL" labelFor="set-webhook-url" stacked last>
<div className="flex w-full flex-col gap-2">
<Input
id="set-webhook-url"
className="w-full"
placeholder="https://hooks.example.com/..."
aria-invalid={!!form.formState.errors.webhookUrl}
{...form.register('webhookUrl')}
/>
<LoadingButton
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => webhookTestMut.mutate()}
loading={webhookTestMut.isPending}
>
Тест webhook
</LoadingButton>
</div>
</SettingRow>
) : null}
</FieldGroup>
</SettingsCard>
{/* preview https://reui.io/preview/base/settings-2 */}
<SettingsCard
title="События и интервалы"
description="Какие уведомления отправлять и как часто проверять"
footer={
<LoadingButton
type="submit"
loading={saveMut.isPending}
disabled={!form.formState.isDirty}
>
Сохранить
</LoadingButton>
}
>
<FieldGroup className="gap-0">
<SettingRow
title="Интервал проверки оплаты"
description="Минимум 15 минут"
labelFor="set-notify-int"
compact
>
<Input
id="set-notify-int"
type="number"
min={15}
className="w-28"
{...form.register('notifyIntervalMinutes')}
/>
</SettingRow>
<SettingRow
title="Интервал uptime"
description="Минимум 1 минута"
labelFor="set-uptime-int"
compact
>
<Input
id="set-uptime-int"
type="number"
min={1}
className="w-28"
{...form.register('uptimeCheckIntervalMinutes')}
/>
</SettingRow>
<SettingRow title="Низкий баланс" description="Алерт при падении баланса аккаунта">
<Controller
control={form.control}
name="notifyLowBalanceEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Низкий баланс"
/>
)}
/>
</SettingRow>
<SettingRow title="Дайджест синка" description="Итог автосинка BILLmanager">
<Controller
control={form.control}
name="notifySyncDigestEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Дайджест синка"
/>
)}
/>
</SettingRow>
<SettingRow title="Истечение оплаты" description="VPS с приближающимся paid until">
<Controller
control={form.control}
name="notifyPaymentExpiryEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Истечение оплаты"
/>
)}
/>
</SettingRow>
<SettingRow title="Новые тарифы" description="Появление тарифов в прайс-листе">
<Controller
control={form.control}
name="notifyNewTariffsEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Новые тарифы"
/>
)}
/>
</SettingRow>
<SettingRow
title="VPS недоступен"
description="Uptime-проверка и failover"
last
>
<Controller
control={form.control}
name="notifyVpsDownEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="VPS недоступен"
/>
)}
/>
</SettingRow>
</FieldGroup>
</SettingsCard>
<SettingsCard
title="Журнал уведомлений"
description="Последние попытки доставки (Telegram и webhook)"
>
{notificationRows.length === 0 ? (
<div className="px-5 py-6">
<EmptyState title="Записей пока нет" />
</div>
) : (
<ItemGroup className="gap-0 px-2 py-1">
{notificationRows.map((row) => {
const errorText =
row.status === 'failed' && row.payload?.error != null
? String(row.payload.error)
: ''
return (
<Item key={row.id} className="border-0 px-3 py-2.5">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">{row.event}</span>
<StatusBadge
status={NOTIFICATION_STATUS_MAP[row.status] ?? row.status}
label={NOTIFICATION_STATUS_LABELS[row.status] ?? row.status}
/>
</ItemTitle>
<ItemDescription className="flex flex-wrap gap-x-3 gap-y-0.5">
<span>{new Date(row.createdAt).toLocaleString('ru-RU')}</span>
<span>{row.channel}</span>
{errorText ? (
<span className="text-destructive">{errorText}</span>
) : null}
</ItemDescription>
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</SettingsCard>
</form>
)}
</QueryState>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { createFileRoute } from '@tanstack/react-router'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import { SettingRow } from '@/components/setting-row'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import {
settingsToFormValues,
useSettingsSave,
useSettingsSnapshot,
} from '@/components/settings/use-settings-section'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Switch } from '@cfdm/ui/components/switch'
export const Route = createFileRoute('/_auth/settings/sync')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsSyncPage,
})
const syncSchema = z.object({
syncEnabled: z.boolean().default(true),
syncIntervalMinutes: z.coerce.number().min(15).default(60),
syncTariffsIntervalMinutes: z.coerce.number().min(60).default(1440),
})
type SyncFormValues = z.infer<typeof syncSchema>
function SettingsSyncPage() {
const { data: snapshot, current, isLoading, isError, error, refetch } =
useSettingsSnapshot()
const saveMut = useSettingsSave({ successMessage: 'Настройки синхронизации сохранены' })
const formValues = current ? settingsToFormValues(current) : undefined
const form = useForm<SyncFormValues>({
resolver: zodResolver(syncSchema),
values: formValues
? {
syncEnabled: formValues.syncEnabled ?? true,
syncIntervalMinutes: formValues.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: formValues.syncTariffsIntervalMinutes ?? 1440,
}
: undefined,
})
return (
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<Skeleton className="h-56 w-full rounded-xl" />}
>
{() => (
<form
onSubmit={(e) =>
void form.handleSubmit((values) => {
saveMut.mutate(values, { onSuccess: () => form.reset(values) })
})(e)
}
>
{/* preview https://reui.io/preview/base/settings-3 */}
<SettingsCard
title="Синхронизация"
description="Автосинк BILLmanager и интервалы"
footer={
<LoadingButton
type="submit"
loading={saveMut.isPending}
disabled={!form.formState.isDirty}
>
Сохранить
</LoadingButton>
}
>
<FieldGroup className="gap-0">
<SettingRow
title="Автосинк"
description="Периодическая синхронизация VPS и платежей из BILLmanager"
>
<Controller
control={form.control}
name="syncEnabled"
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
aria-label="Автосинк"
/>
)}
/>
</SettingRow>
<SettingRow
title="Интервал синка"
description="Минимум 15 минут"
labelFor="set-sync-int"
compact
>
<Input
id="set-sync-int"
type="number"
min={15}
className="w-28"
{...form.register('syncIntervalMinutes')}
/>
</SettingRow>
<SettingRow
title="Интервал тарифов"
description="Минимум 60 минут"
labelFor="set-tariff-int"
compact
last
>
<Input
id="set-tariff-int"
type="number"
min={60}
className="w-28"
{...form.register('syncTariffsIntervalMinutes')}
/>
</SettingRow>
</FieldGroup>
</SettingsCard>
</form>
)}
</QueryState>
)
}
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }