diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index d259ada..716e158 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -29,12 +29,11 @@ apps/web/src/components/ ← shared + domain + layout empty-state.tsx query-state.tsx confirm-dialog.tsx - data-table-card.tsx + data-grid-card.tsx section-cards.tsx status-badge.tsx form-sheet.tsx ← Sheet + RHF FormProvider form-field.tsx ← Field + Controller + aria-invalid - table-card.tsx ← Card + Table wrapper loading-button.tsx ← Button + Spinner + label swap section-cards-skeleton.tsx table-skeleton.tsx @@ -49,14 +48,14 @@ apps/web/src/components/ ← shared + domain + layout | Page wrapper | `PageShell` | — | | Page title | `PageHeader` | — | | Stat metrics | `SectionCards` | `Card` | -| Data list | `DataTableCard` | `Table`, `InputGroup` | +| Data list | `DataGridCard` | `Table`, `InputGroup` | | Empty | `EmptyState` | `Empty` | | Loading / Error | `QueryState` | `Skeleton`, `Alert` | | Status | `StatusBadge` | `Badge` | | Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | | Form field | `FormField` | `Field`, `Input`, `Select` | | Submit button | `LoadingButton` | `Button`, `Spinner` | -| Table wrapper | `TableCard` | `Table`, `Card` | +| Table wrapper | `DataGridCard` | `Table`, `Card` | | Delete confirm | `ConfirmDialog` | `AlertDialog` | | List row | — | `Item variant="outline" size="sm"` | | Nav | `AppSidebar` | `Sidebar` | diff --git a/apps/web/src/components/analytics-page.tsx b/apps/web/src/components/analytics-page.tsx new file mode 100644 index 0000000..a65ef7c --- /dev/null +++ b/apps/web/src/components/analytics-page.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react' +import { ServerIcon } from 'lucide-react' +import { PageShell } from './page-shell' +import { PageHeader } from './page-header' +import { QueryState } from './query-state' +import { EmptyState } from './empty-state' +import { SectionCardsSkeleton } from './skeletons' + +interface AnalyticsPageProps { + title: string + description?: string + actions?: ReactNode + data: T | undefined + isLoading: boolean + isError: boolean + error?: unknown + onRetry?: () => void + /** Показать empty, если нет данных для аналитики (например, 0 VPS). */ + analyticsEmpty?: boolean + emptyTitle?: string + emptyDescription?: string + emptyAction?: ReactNode + skeleton?: ReactNode + children: (data: T) => ReactNode +} + +export function AnalyticsPage({ + title, + description, + actions, + data, + isLoading, + isError, + error, + onRetry, + analyticsEmpty, + emptyTitle = 'Нет данных для аналитики', + emptyDescription = 'Добавьте VPS или дождитесь синхронизации с BILLmanager', + emptyAction, + skeleton, + children, +}: AnalyticsPageProps) { + return ( + + + } + > + {(snap) => + analyticsEmpty ? ( + } + title={emptyTitle} + description={emptyDescription} + action={emptyAction} + /> + ) : ( + children(snap) + ) + } + + + ) +} diff --git a/apps/web/src/components/crud-list-page.tsx b/apps/web/src/components/crud-list-page.tsx new file mode 100644 index 0000000..8b3c1f5 --- /dev/null +++ b/apps/web/src/components/crud-list-page.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' +import { PageShell } from './page-shell' +import { PageHeader } from './page-header' +import { QueryState } from './query-state' +import { TableSkeleton } from './skeletons' + +interface CrudListPageProps { + title: string + description?: string + actions?: ReactNode + data: T | undefined + isLoading: boolean + isError: boolean + error?: unknown + onRetry?: () => void + empty?: boolean + emptyTitle?: string + emptyDescription?: string + emptyAction?: ReactNode + skeleton?: ReactNode + sheet?: ReactNode + children: (data: T) => ReactNode +} + +export function CrudListPage({ + title, + description, + actions, + data, + isLoading, + isError, + error, + onRetry, + empty, + emptyTitle, + emptyDescription, + emptyAction, + skeleton, + sheet, + children, +}: CrudListPageProps) { + return ( + + + } + empty={empty} + emptyTitle={emptyTitle} + emptyDescription={emptyDescription} + emptyAction={emptyAction} + > + {children} + + {sheet} + + ) +} diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index add10e0..3d699b6 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -195,7 +195,7 @@ export function DataGridCard({ return ( -
+
{title ? {title} : null} {description ?

{description}

: null}
diff --git a/apps/web/src/components/data-table-card.tsx b/apps/web/src/components/data-table-card.tsx deleted file mode 100644 index 8987823..0000000 --- a/apps/web/src/components/data-table-card.tsx +++ /dev/null @@ -1,2 +0,0 @@ -/** @deprecated Используйте DataGridCard. Тип колонок — data-grid-types. */ -export type { DataTableColumn } from './data-grid-types' diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx new file mode 100644 index 0000000..dfcbae0 --- /dev/null +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -0,0 +1,119 @@ +import { FormSheetRhf } from '@/components/form-sheet-rhf' +import { FormField } from '@/components/form-field' +import { Input } from '@cfdm/ui/components/input' +import { Textarea } from '@cfdm/ui/components/textarea' +import { SelectField } from '@/components/select-field' +import type { ZodType } from 'zod' +import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas' +import type { BillingMode, Provider } from '@/types/entities' +import { billingModeLabel } from '@/lib/format' + +const EMPTY: ProviderAccountFormValues = { + providerId: '', + name: '', + login: '', + apiCredentials: '', + billingMode: 'monthly', + balanceAlertBelow: '', + notes: '', +} + +interface ProviderAccountEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + defaultValues: ProviderAccountFormValues + providers: Provider[] + onSubmit: (values: ProviderAccountFormValues) => void + submitting?: boolean +} + +export function providerAccountFormDefaults( + edit?: Partial | null, + fallbackProviderId = '', +): ProviderAccountFormValues { + if (!edit) { + return { ...EMPTY, providerId: fallbackProviderId } + } + return { + ...EMPTY, + ...edit, + balanceAlertBelow: edit.balanceAlertBelow ?? '', + } +} + +export function ProviderAccountEditSheet({ + open, + onOpenChange, + defaultValues, + providers, + onSubmit, + submitting, +}: ProviderAccountEditSheetProps) { + const isEdit = Boolean(defaultValues.id) + + return ( + } + defaultValues={defaultValues} + onSubmit={onSubmit} + submitting={submitting} + > + {(form) => { + const { register, formState: { errors }, watch, setValue } = form + return ( + <> + + setValue('providerId', v ?? '', { shouldValidate: true })} + options={providers.map((p) => ({ value: p.id, label: p.name }))} + /> + + + + + + + + + + + + setValue('billingMode', (v ?? 'monthly') as BillingMode)} + options={[ + { value: 'monthly', label: billingModeLabel('monthly') }, + { value: 'daily', label: billingModeLabel('daily') }, + ]} + /> + + + + + +