Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import {
|
||||
type AccountFiltersState,
|
||||
buildDefaultAccountFilters,
|
||||
hasActiveAccountFilters,
|
||||
} from '@/components/account-filters'
|
||||
import { billingModeLabel } from '@/lib/format'
|
||||
import type { Provider } from '@/types/entities'
|
||||
|
||||
interface AccountFiltersToolbarProps {
|
||||
filters: AccountFiltersState
|
||||
onChange: (next: AccountFiltersState) => void
|
||||
providers: Provider[]
|
||||
}
|
||||
|
||||
export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) {
|
||||
const active = hasActiveAccountFilters(filters)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[12rem] flex-1 sm:max-w-xs">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Поиск по названию или логину"
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
triggerClassName="w-full sm:w-48"
|
||||
placeholder="Все хостеры"
|
||||
value={filters.providerIds[0] ?? null}
|
||||
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
|
||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
triggerClassName="w-full sm:w-40"
|
||||
placeholder="Любой биллинг"
|
||||
value={filters.billingMode || null}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...filters,
|
||||
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'monthly', label: billingModeLabel('monthly') },
|
||||
{ value: 'daily', label: billingModeLabel('daily') },
|
||||
]}
|
||||
/>
|
||||
{active ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(buildDefaultAccountFilters())}
|
||||
>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={filters.syncableOnly}
|
||||
onCheckedChange={(v) => onChange({ ...filters, syncableOnly: v === true })}
|
||||
/>
|
||||
<span>Готовы к синку</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={filters.issuesOnly}
|
||||
onCheckedChange={(v) => onChange({ ...filters, issuesOnly: v === true })}
|
||||
/>
|
||||
<span>С проблемами</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={filters.lowBalanceOnly}
|
||||
onCheckedChange={(v) => onChange({ ...filters, lowBalanceOnly: v === true })}
|
||||
/>
|
||||
<span>Низкий баланс</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||
import {
|
||||
getAccountHealthFlags,
|
||||
isAccountSyncable,
|
||||
type AccountHealthContext,
|
||||
} from '@/lib/account-health'
|
||||
|
||||
export interface AccountFiltersState {
|
||||
search: string
|
||||
providerIds: string[]
|
||||
billingMode: '' | 'daily' | 'monthly'
|
||||
syncableOnly: boolean
|
||||
issuesOnly: boolean
|
||||
lowBalanceOnly: boolean
|
||||
}
|
||||
|
||||
export function buildDefaultAccountFilters(): AccountFiltersState {
|
||||
return {
|
||||
search: '',
|
||||
providerIds: [],
|
||||
billingMode: '',
|
||||
syncableOnly: false,
|
||||
issuesOnly: false,
|
||||
lowBalanceOnly: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasActiveAccountFilters(filters: AccountFiltersState): boolean {
|
||||
return Boolean(
|
||||
filters.search.trim() ||
|
||||
filters.providerIds.length ||
|
||||
filters.billingMode ||
|
||||
filters.syncableOnly ||
|
||||
filters.issuesOnly ||
|
||||
filters.lowBalanceOnly,
|
||||
)
|
||||
}
|
||||
|
||||
export function applyAccountFilters(
|
||||
accounts: ProviderAccount[],
|
||||
filters: AccountFiltersState,
|
||||
providers: Provider[],
|
||||
healthCtx: AccountHealthContext,
|
||||
): ProviderAccount[] {
|
||||
const q = filters.search.trim().toLowerCase()
|
||||
return accounts.filter((account) => {
|
||||
if (q) {
|
||||
const hay = `${account.name} ${account.apiLogin ?? ''}`.toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
if (filters.providerIds.length && !filters.providerIds.includes(account.providerId)) return false
|
||||
if (filters.billingMode && (account.billingMode ?? 'monthly') !== filters.billingMode) return false
|
||||
if (filters.syncableOnly && !isAccountSyncable(account, providers)) return false
|
||||
const flags = getAccountHealthFlags(account, healthCtx)
|
||||
if (filters.issuesOnly && flags.length === 0) return false
|
||||
if (filters.lowBalanceOnly && !flags.includes('low-balance')) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { PlugIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -17,8 +18,8 @@ import { api, ApiError } from '@/lib/api-client'
|
||||
const EMPTY: ProviderAccountFormValues = {
|
||||
providerId: '',
|
||||
name: '',
|
||||
login: '',
|
||||
apiCredentials: '',
|
||||
apiLogin: '',
|
||||
apiPassword: '',
|
||||
billingMode: 'monthly',
|
||||
balanceAlertBelow: '',
|
||||
notes: '',
|
||||
@@ -91,8 +92,10 @@ export function ProviderAccountEditSheet({
|
||||
const providerId = watch('providerId')
|
||||
const provider = providers.find((p) => p.id === providerId)
|
||||
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
|
||||
const creds = watch('apiCredentials')?.trim() ?? ''
|
||||
const canTest = Boolean(apiBaseUrl && creds)
|
||||
const apiLogin = watch('apiLogin')?.trim() ?? ''
|
||||
const apiPassword = watch('apiPassword')?.trim() ?? ''
|
||||
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
||||
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -108,15 +111,15 @@ export function ProviderAccountEditSheet({
|
||||
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
|
||||
</FormField>
|
||||
<FormField label="Логин" htmlFor="acc-login">
|
||||
<Input id="acc-login" {...register('login')} />
|
||||
<FormField label="Логин API" htmlFor="acc-login">
|
||||
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
|
||||
htmlFor="acc-creds"
|
||||
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
|
||||
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
|
||||
htmlFor="acc-password"
|
||||
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
|
||||
>
|
||||
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
|
||||
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
|
||||
</FormField>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
@@ -125,7 +128,7 @@ export function ProviderAccountEditSheet({
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
loading={testMut.isPending}
|
||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
|
||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
|
||||
>
|
||||
<PlugIcon data-icon="inline-start" />
|
||||
Проверить подключение
|
||||
|
||||
Reference in New Issue
Block a user