Разбить монолитную Card-форму на вкладки Общие/Синхронизация/Уведомления/Интеграции с SettingRow и SettingsCard по эталонам settings-16/3/2. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 : 'Не удалось сохранить'),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user