Разбить монолитную Card-форму на вкладки Общие/Синхронизация/Уведомления/Интеграции с SettingRow и SettingsCard по эталонам settings-16/3/2. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -3,13 +3,12 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { toast } from 'sonner'
|
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 { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
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 { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import type { Settings } from '@/types/entities'
|
import type { Settings } from '@/types/entities'
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -26,7 +25,7 @@ function generateToken(): string {
|
|||||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CfdmIntegrationCardProps {
|
interface CfdmIntegrationFormProps {
|
||||||
settings?: Settings
|
settings?: Settings
|
||||||
onSave: (values: {
|
onSave: (values: {
|
||||||
cfdmApiUrl?: string
|
cfdmApiUrl?: string
|
||||||
@@ -36,7 +35,12 @@ interface CfdmIntegrationCardProps {
|
|||||||
isSaving?: boolean
|
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>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
values: {
|
values: {
|
||||||
@@ -56,79 +60,104 @@ export function CfdmIntegrationCard({ settings, onSave, isSaving }: CfdmIntegrat
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<form
|
||||||
<CardHeader>
|
className="flex flex-col gap-0"
|
||||||
<CardTitle>CF Domain Manager</CardTitle>
|
onSubmit={(e) => void form.handleSubmit(handleSubmit)(e)}
|
||||||
<CardDescription>
|
>
|
||||||
Приём синхронизации доменов и сервисов из CFDM. Скопируйте токен в настройки CFDM.
|
<FieldGroup className="gap-0">
|
||||||
</CardDescription>
|
<SettingRow
|
||||||
</CardHeader>
|
title="Принимать синхронизацию"
|
||||||
<CardContent>
|
description="Разрешить CFDM пушить домены и сервисы"
|
||||||
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit(handleSubmit)(e)}>
|
>
|
||||||
<FieldGroup>
|
<Controller
|
||||||
<FormField label="URL API CFDM" htmlFor="cfdm-api-url">
|
control={form.control}
|
||||||
<Input
|
name="integrationEnabled"
|
||||||
id="cfdm-api-url"
|
render={({ field }) => (
|
||||||
placeholder="http://192.168.100.67:6363 (для failover vps_down)"
|
<Switch
|
||||||
{...form.register('cfdmApiUrl')}
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
aria-label="Принимать синхронизацию"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
)}
|
||||||
<FormField label="Integration token" htmlFor="integration-token">
|
/>
|
||||||
<div className="flex gap-2">
|
</SettingRow>
|
||||||
<Input
|
<SettingRow
|
||||||
id="integration-token"
|
title="URL API CFDM"
|
||||||
type="password"
|
description="Для failover vps_down"
|
||||||
autoComplete="new-password"
|
labelFor="cfdm-api-url"
|
||||||
placeholder={
|
stacked
|
||||||
settings?.integrationTokenSet
|
>
|
||||||
? 'Токен установлен — введите новый для замены'
|
<Input
|
||||||
: 'Сгенерируйте или вставьте токен'
|
id="cfdm-api-url"
|
||||||
}
|
className="w-full"
|
||||||
{...form.register('integrationToken')}
|
placeholder="http://192.168.100.67:6363"
|
||||||
/>
|
{...form.register('cfdmApiUrl')}
|
||||||
<Button
|
/>
|
||||||
type="button"
|
</SettingRow>
|
||||||
variant="outline"
|
<SettingRow
|
||||||
onClick={() => {
|
title="Integration token"
|
||||||
const token = generateToken()
|
description={
|
||||||
form.setValue('integrationToken', token, { shouldDirty: true })
|
settings?.integrationTokenSet
|
||||||
void navigator.clipboard.writeText(token)
|
? 'Токен установлен — введите новый для замены'
|
||||||
toast.success('Токен сгенерирован и скопирован')
|
: 'Сгенерируйте или вставьте токен'
|
||||||
}}
|
}
|
||||||
>
|
labelFor="integration-token"
|
||||||
Сгенерировать
|
stacked
|
||||||
</Button>
|
last={!settings?.integrationLastSyncAt}
|
||||||
</div>
|
>
|
||||||
</FormField>
|
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||||
<Controller
|
<Input
|
||||||
control={form.control}
|
id="integration-token"
|
||||||
name="integrationEnabled"
|
type="password"
|
||||||
render={({ field }) => (
|
autoComplete="new-password"
|
||||||
<FormField label="Принимать синхронизацию" htmlFor="integration-enabled">
|
className="min-w-0 flex-1"
|
||||||
<SelectField
|
placeholder={
|
||||||
triggerId="integration-enabled"
|
settings?.integrationTokenSet
|
||||||
triggerClassName="w-32"
|
? 'Токен установлен — введите новый для замены'
|
||||||
value={field.value ? 'on' : 'off'}
|
: 'Сгенерируйте или вставьте токен'
|
||||||
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
|
}
|
||||||
options={[
|
{...form.register('integrationToken')}
|
||||||
{ value: 'on', label: 'Вкл' },
|
|
||||||
{ value: 'off', label: 'Выкл' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{settings?.integrationLastSyncAt ? (
|
<Button
|
||||||
<p className="text-sm text-muted-foreground">
|
type="button"
|
||||||
Последний sync: {new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}
|
variant="outline"
|
||||||
</p>
|
size="sm"
|
||||||
) : null}
|
className="shrink-0"
|
||||||
</FieldGroup>
|
onClick={() => {
|
||||||
<LoadingButton type="submit" className="w-fit" loading={isSaving} disabled={!form.formState.isDirty}>
|
const token = generateToken()
|
||||||
Сохранить интеграцию
|
form.setValue('integrationToken', token, { shouldDirty: true })
|
||||||
</LoadingButton>
|
void navigator.clipboard.writeText(token)
|
||||||
</form>
|
toast.success('Токен сгенерирован и скопирован')
|
||||||
</CardContent>
|
}}
|
||||||
</Card>
|
>
|
||||||
|
Сгенерировать
|
||||||
|
</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 { OpsDashboard } from './ops-dashboard'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
|
export { SettingsCard } from './settings-card'
|
||||||
export { TopologyCanvas } from './topology-canvas'
|
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 type { ReactNode } from 'react'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
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 { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -24,6 +29,18 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
|
|||||||
exact: true,
|
exact: true,
|
||||||
icon: <SettingsIcon className="size-4" aria-hidden="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',
|
id: 'integrations',
|
||||||
to: '/settings/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 AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
|
||||||
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
||||||
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
|
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 AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||||
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
|
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
|
||||||
|
|
||||||
@@ -137,6 +139,17 @@ const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
|
|||||||
path: '/$vpsId',
|
path: '/$vpsId',
|
||||||
getParentRoute: () => AuthVpsRoute,
|
getParentRoute: () => AuthVpsRoute,
|
||||||
} as any)
|
} 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 =
|
const AuthSettingsIntegrationsRoute =
|
||||||
AuthSettingsIntegrationsRouteImport.update({
|
AuthSettingsIntegrationsRouteImport.update({
|
||||||
id: '/integrations',
|
id: '/integrations',
|
||||||
@@ -170,6 +183,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
|
'/settings/notifications': typeof AuthSettingsNotificationsRoute
|
||||||
|
'/settings/sync': typeof AuthSettingsSyncRoute
|
||||||
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
'/settings/': typeof AuthSettingsIndexRoute
|
'/settings/': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -193,6 +208,8 @@ export interface FileRoutesByTo {
|
|||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
|
'/settings/notifications': typeof AuthSettingsNotificationsRoute
|
||||||
|
'/settings/sync': typeof AuthSettingsSyncRoute
|
||||||
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
'/settings': typeof AuthSettingsIndexRoute
|
'/settings': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -219,6 +236,8 @@ export interface FileRoutesById {
|
|||||||
'/auth/callback': typeof AuthCallbackRoute
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
|
'/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute
|
||||||
|
'/_auth/settings/sync': typeof AuthSettingsSyncRoute
|
||||||
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -245,6 +264,8 @@ export interface FileRouteTypes {
|
|||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/projects/$projectId'
|
| '/projects/$projectId'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
|
| '/settings/notifications'
|
||||||
|
| '/settings/sync'
|
||||||
| '/vps/$vpsId'
|
| '/vps/$vpsId'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -268,6 +289,8 @@ export interface FileRouteTypes {
|
|||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/projects/$projectId'
|
| '/projects/$projectId'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
|
| '/settings/notifications'
|
||||||
|
| '/settings/sync'
|
||||||
| '/vps/$vpsId'
|
| '/vps/$vpsId'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
id:
|
id:
|
||||||
@@ -293,6 +316,8 @@ export interface FileRouteTypes {
|
|||||||
| '/auth/callback'
|
| '/auth/callback'
|
||||||
| '/_auth/projects/$projectId'
|
| '/_auth/projects/$projectId'
|
||||||
| '/_auth/settings/integrations'
|
| '/_auth/settings/integrations'
|
||||||
|
| '/_auth/settings/notifications'
|
||||||
|
| '/_auth/settings/sync'
|
||||||
| '/_auth/vps/$vpsId'
|
| '/_auth/vps/$vpsId'
|
||||||
| '/_auth/settings/'
|
| '/_auth/settings/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -452,6 +477,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
||||||
parentRoute: typeof AuthVpsRoute
|
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': {
|
'/_auth/settings/integrations': {
|
||||||
id: '/_auth/settings/integrations'
|
id: '/_auth/settings/integrations'
|
||||||
path: '/integrations'
|
path: '/integrations'
|
||||||
@@ -471,11 +510,15 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthSettingsRouteRouteChildren {
|
interface AuthSettingsRouteRouteChildren {
|
||||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||||
|
AuthSettingsNotificationsRoute: typeof AuthSettingsNotificationsRoute
|
||||||
|
AuthSettingsSyncRoute: typeof AuthSettingsSyncRoute
|
||||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||||
|
AuthSettingsNotificationsRoute: AuthSettingsNotificationsRoute,
|
||||||
|
AuthSettingsSyncRoute: AuthSettingsSyncRoute,
|
||||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,196 +1,68 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
||||||
import { useMemo, useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
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 { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { FormField } from '@/components/form-field'
|
import { SettingRow } from '@/components/setting-row'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { SettingsCard } from '@/components/reui-kit/settings-card'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import {
|
||||||
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
settingsToFormValues,
|
||||||
|
useSettingsPatch,
|
||||||
|
useSettingsSave,
|
||||||
|
useSettingsSnapshot,
|
||||||
|
} from '@/components/settings/use-settings-section'
|
||||||
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
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/')({
|
export const Route = createFileRoute('/_auth/settings/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
component: SettingsPage,
|
component: SettingsGeneralPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
||||||
|
|
||||||
const NOTIFICATION_STATUS_MAP: Record<string, string> = {
|
function SettingsSkeleton() {
|
||||||
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
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<FormField label={label} htmlFor={id}>
|
<div className="flex flex-col gap-4">
|
||||||
<SelectField
|
<Skeleton className="h-28 w-full rounded-xl" />
|
||||||
triggerId={id}
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
triggerClassName="w-32"
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
value={value ? 'on' : 'off'}
|
</div>
|
||||||
onValueChange={(v) => onChange((v ?? 'on') === 'on')}
|
|
||||||
options={[
|
|
||||||
{ value: 'on', label: 'Вкл' },
|
|
||||||
{ value: 'off', label: 'Выкл' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsGeneralPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, current, isLoading, isError, error, refetch } =
|
||||||
const current = snapshot?.settings?.[0]
|
useSettingsSnapshot()
|
||||||
|
const patchMut = useSettingsPatch({ successMessage: 'Настройки интерфейса сохранены' })
|
||||||
|
const saveMut = useSettingsSave()
|
||||||
|
|
||||||
|
const formValues = current ? settingsToFormValues(current) : undefined
|
||||||
|
|
||||||
const form = useForm<SettingsFormValues>({
|
const form = useForm<SettingsFormValues>({
|
||||||
resolver: zodResolver(settingsSchema),
|
resolver: zodResolver(settingsSchema),
|
||||||
values: current ? settingsToFormValues(current) : undefined,
|
values: formValues,
|
||||||
})
|
|
||||||
|
|
||||||
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),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const importJsonMut = useMutation({
|
const importJsonMut = useMutation({
|
||||||
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
|
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
|
||||||
toast.success('Импорт JSON выполнен')
|
toast.success('Импорт JSON выполнен')
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
|
||||||
@@ -199,7 +71,7 @@ function SettingsPage() {
|
|||||||
const importDbMut = useMutation({
|
const importDbMut = useMutation({
|
||||||
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
|
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
|
||||||
toast.success('Импорт SQLite выполнен')
|
toast.success('Импорт SQLite выполнен')
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
|
||||||
@@ -212,8 +84,7 @@ function SettingsPage() {
|
|||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const text = await file.text()
|
importJsonMut.mutate(await file.text())
|
||||||
importJsonMut.mutate(text)
|
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
}, [importJsonMut])
|
}, [importJsonMut])
|
||||||
@@ -225,409 +96,232 @@ function SettingsPage() {
|
|||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const buffer = await file.arrayBuffer()
|
importDbMut.mutate(await file.arrayBuffer())
|
||||||
importDbMut.mutate(buffer)
|
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
}, [importDbMut])
|
}, [importDbMut])
|
||||||
|
|
||||||
const notificationRows = useMemo(
|
const showQuickActions = current?.showQuickActions !== false
|
||||||
() => 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>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<QueryState
|
||||||
<div className="flex flex-wrap gap-2">{backupActions}</div>
|
data={snapshot}
|
||||||
<QueryState
|
isLoading={isLoading}
|
||||||
data={snapshot}
|
isError={isError}
|
||||||
isLoading={isLoading}
|
error={error}
|
||||||
isError={isError}
|
onRetry={() => refetch()}
|
||||||
error={error}
|
skeleton={<SettingsSkeleton />}
|
||||||
onRetry={() => refetch()}
|
>
|
||||||
skeleton={<SectionCardsSkeleton count={3} />}
|
{() => (
|
||||||
>
|
<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
|
<form
|
||||||
className="flex flex-col gap-4"
|
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">
|
{/* preview https://reui.io/preview/base/settings-3 */}
|
||||||
<Card>
|
<SettingsCard
|
||||||
<CardHeader>
|
title="Валюта и курсы"
|
||||||
<CardTitle>Интерфейс</CardTitle>
|
description="Отображение сумм и источник курсов"
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
Сохранить настройки
|
<FieldGroup className="gap-0">
|
||||||
</LoadingButton>
|
<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>
|
</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 { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ExternalLinkIcon } from 'lucide-react'
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { CfdmIntegrationForm } from '@/components/integrations/cfdm-integration-card'
|
||||||
import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card'
|
|
||||||
import type { Settings } from '@/types/entities'
|
|
||||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
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 {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -19,15 +25,111 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} 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')({
|
export const Route = createFileRoute('/_auth/settings/integrations')({
|
||||||
|
loader: ({ context: { queryClient } }) =>
|
||||||
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
component: SettingsIntegrationsPage,
|
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() {
|
function SettingsIntegrationsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const [openAppSwitcher, setOpenAppSwitcher] = useState(true)
|
||||||
const current = snapshot?.settings?.[0] as Settings | undefined
|
const [openCfdm, setOpenCfdm] = useState(true)
|
||||||
|
const { data: snapshot, current, isLoading, isError, error, refetch } =
|
||||||
|
useSettingsSnapshot()
|
||||||
const { config: appSwitcher } = useAppSwitcherConfig()
|
const { config: appSwitcher } = useAppSwitcherConfig()
|
||||||
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
|
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
|
||||||
|
|
||||||
@@ -41,6 +143,17 @@ function SettingsIntegrationsPage() {
|
|||||||
onError: () => toast.error('Не удалось сохранить'),
|
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 (
|
return (
|
||||||
<QueryState
|
<QueryState
|
||||||
data={snapshot}
|
data={snapshot}
|
||||||
@@ -48,41 +161,107 @@ function SettingsIntegrationsPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
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">
|
<div className="flex w-full flex-col gap-5">
|
||||||
<Frame>
|
<section className="flex flex-col gap-3">
|
||||||
<FrameHeader>
|
<div className="flex flex-col gap-1">
|
||||||
<FrameTitle>App Switcher</FrameTitle>
|
<h2 className="text-sm font-semibold">Подключённые приложения</h2>
|
||||||
<FrameDescription>
|
|
||||||
{appSwitcher.apps.length} приложений · меню «{appSwitcher.menuLabel}». Ссылки
|
|
||||||
настраиваются на auth-portal.
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="flex flex-col gap-3">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Локальный редактор отключён. Source of truth — портал.
|
Переключение между сервисами в sidebar
|
||||||
</p>
|
</p>
|
||||||
{isAuthEnabled() ? (
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
<Frame>
|
||||||
variant="outline"
|
<FrameHeader className="sr-only">
|
||||||
size="sm"
|
<FrameTitle>App Switcher</FrameTitle>
|
||||||
className="w-fit"
|
<FrameDescription>
|
||||||
render={<a href={portalAppsUrl} />}
|
Ссылки приложений настраиваются на auth-portal
|
||||||
>
|
</FrameDescription>
|
||||||
Открыть на портале
|
</FrameHeader>
|
||||||
<ExternalLinkIcon data-icon="inline-end" aria-hidden="true" />
|
<FramePanel className="p-0!">
|
||||||
</Button>
|
<ItemGroup className="gap-0">
|
||||||
) : null}
|
<IntegrationRow
|
||||||
</FramePanel>
|
id="app-switcher"
|
||||||
</Frame>
|
name="App Switcher"
|
||||||
<CfdmIntegrationCard
|
description={`${appSwitcher.apps.length} приложений · меню «${appSwitcher.menuLabel}»`}
|
||||||
settings={current}
|
logo={<LayoutGridIcon aria-hidden="true" />}
|
||||||
isSaving={saveMut.isPending}
|
status={appSwitcherStatus}
|
||||||
onSave={(patch) => saveMut.mutate(patch)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</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