refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle

Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom

Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)

Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo

Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-26 13:42:05 +07:00
co-authored by Cursor
parent 8408155be6
commit 6fbd1a9113
202 changed files with 16734 additions and 12145 deletions
@@ -0,0 +1,53 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@cfdm/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps {
trigger: ReactElement
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
}
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { TableCard } from './table-card'
import { EmptyState } from './empty-state'
export interface DataTableColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
className?: string
headerClassName?: string
}
interface DataTableCardProps<T> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
columns: DataTableColumn<T>[]
data: T[]
rowKey: (row: T, index: number) => string
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRowClick?: (row: T) => void
}
export function DataTableCard<T>({
title,
description,
actions,
columns,
data,
rowKey,
emptyTitle = 'Нет записей',
emptyDescription,
emptyAction,
onRowClick,
}: DataTableCardProps<T>) {
return (
<TableCard title={title} description={description} actions={actions}>
{data.length === 0 ? (
<div className="p-4">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key} className={col.headerClassName}>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow
key={rowKey(row, index)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{columns.map((col) => (
<TableCell key={col.key} className={col.className}>
{col.cell(row, index)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
)
}
+182
View File
@@ -0,0 +1,182 @@
import {
Bar,
BarChart,
CartesianGrid,
XAxis,
YAxis,
Cell,
Pie,
PieChart,
Tooltip as RechartsTooltip,
} from 'recharts'
import {
ChartContainer,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import type { ReactNode } from 'react'
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format'
import { providerByIdMap } from '@/lib/billmanager'
const EXPENSE_CONFIG: ChartConfig = {
expense: { label: 'Расход', color: 'var(--chart-1)' },
}
export function MonthlyExpenseChart({
vps,
providers,
settings,
ratesData,
className,
}: {
vps: Vps[]
providers: Provider[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const providerById = providerByIdMap(providers)
const monthlyByAccount = new Map<string, number>()
for (const v of vps) {
if (v.status !== 'active') continue
const provider = providerById.get(v.providerId)
const monthly = Number(v.monthlyRate || 0)
const daily = Number(v.dailyRate || 0)
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
const fromCurrency = toIsoCurrency(provider?.baseCurrency || v.currency || baseCurrency)
const converted = convertCurrency(burn, fromCurrency, baseCurrency, ratesData)
monthlyByAccount.set(v.providerAccountId, (monthlyByAccount.get(v.providerAccountId) ?? 0) + converted)
}
const data = Array.from(monthlyByAccount.entries())
.map(([accountId, value]) => ({
accountId,
name: providerById.get(accountId)?.name ?? accountId,
expense: Math.round(value),
}))
.sort((a, b) => b.expense - a.expense)
.slice(0, 10)
return (
<Card className={className}>
<CardHeader>
<CardTitle>Расходы по хостерам (мес)</CardTitle>
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={48} />
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
const PIE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
const PAYMENTS_CONFIG: ChartConfig = {
amount: { label: 'Платежи', color: 'var(--chart-2)' },
}
export function PaymentsPieChart({
payments,
settings,
ratesData,
className,
}: {
payments: Payment[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const byType = new Map<string, number>()
for (const p of payments) {
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
byType.set(p.type, (byType.get(p.type) ?? 0) + converted)
}
const data = Array.from(byType.entries()).map(([type, amount]) => ({ type, amount: Math.round(amount) }))
return (
<Card className={className}>
<CardHeader>
<CardTitle>Платежи по типам</CardTitle>
<CardDescription>Структура в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
<PieChart>
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
<Pie data={data} dataKey="amount" nameKey="type" innerRadius={50} outerRadius={90} strokeWidth={2}>
{data.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
</PieChart>
</ChartContainer>
</CardContent>
</Card>
)
}
export function MonthlyTrendChart({
payments,
settings,
ratesData,
className,
}: {
payments: Payment[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const byMonth = new Map<string, number>()
for (const p of payments) {
const key = monthKey(p.date)
if (!key) continue
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
byMonth.set(key, (byMonth.get(key) ?? 0) + converted)
}
const data = Array.from(byMonth.entries())
.map(([month, amount]) => ({ month, amount: Math.round(amount) }))
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-12)
const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } }
return (
<Card className={className}>
<CardHeader>
<CardTitle>Динамика платежей</CardTitle>
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={trendConfig} className="h-72 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={48} />
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
export function ChartsGrid({ children }: { children: ReactNode }) {
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
interface EmptyStateProps {
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
interface FormFieldProps {
label: string
htmlFor?: string
error?: string
invalid?: boolean
description?: ReactNode
children: ReactNode
}
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
return (
<Field data-invalid={invalid || Boolean(error)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
{error ? <FieldError>{error}</FieldError> : null}
</Field>
)
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import {
useForm,
type DefaultValues,
type FieldValues,
type SubmitHandler,
type UseFormReturn,
} from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { ZodType } from 'zod'
import { FormSheet } from './form-sheet'
interface FormSheetRhfProps<TField extends FieldValues> {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
schema: ZodType<TField>
defaultValues: DefaultValues<TField>
onSubmit: (values: TField) => void
submitting?: boolean
submitLabel?: string
children: (form: UseFormReturn<TField>) => ReactNode
}
export function FormSheetRhf<TField extends FieldValues>({
open,
onOpenChange,
title,
description,
schema,
defaultValues,
onSubmit,
submitting,
submitLabel,
children,
}: FormSheetRhfProps<TField>) {
const form = useForm<TField>({
resolver: zodResolver(schema) as never,
defaultValues: defaultValues as DefaultValues<TField>,
mode: 'onBlur',
})
const submit: SubmitHandler<TField> = (values) => onSubmit(values)
return (
<FormSheet
open={open}
onOpenChange={(o) => {
if (!o) form.reset()
onOpenChange(o)
}}
trigger={null}
title={title}
description={description}
submitLabel={submitLabel}
submitting={submitting}
onSubmit={() => void form.handleSubmit(submit)()}
>
{children(form)}
</FormSheet>
)
}
+65
View File
@@ -0,0 +1,65 @@
import type { ReactElement, ReactNode } from 'react'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@cfdm/ui/components/sheet'
import { LoadingButton } from './loading-button'
interface FormSheetProps {
trigger?: ReactElement | null
title: string
description?: string
open?: boolean
onOpenChange?: (open: boolean) => void
onSubmit?: () => void
submitLabel?: string
submitting?: boolean
submitDisabled?: boolean
children: ReactNode
}
export function FormSheet({
trigger,
title,
description,
open,
onOpenChange,
onSubmit,
submitLabel = 'Сохранить',
submitting,
submitDisabled,
children,
}: FormSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{trigger ? <SheetTrigger render={trigger} /> : null}
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<form
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
onSubmit={(e) => {
e.preventDefault()
onSubmit?.()
}}
>
{children}
{onSubmit ? (
<SheetFooter className="mt-auto pt-4">
<LoadingButton type="submit" loading={submitting} disabled={submitDisabled}>
{submitLabel}
</LoadingButton>
</SheetFooter>
) : null}
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,124 @@
import {
LayoutDashboard,
Server,
ServerCog,
Building2,
Wallet,
CreditCard,
Coins,
ChartColumnBig,
ChartBar,
Settings,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@cfdm/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ReactNode } from 'react'
interface NavItem {
to: string
label: string
icon: typeof LayoutDashboard
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
{ to: '/payments', label: 'Платежи', icon: CreditCard },
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
{ to: '/settings', label: 'Настройки', icon: Settings },
]
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map((i) => [i.to, i.label]),
)
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? NAV_ITEMS[0]
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Server className="size-4" />
</div>
<div className="flex flex-col text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold">VPS Tracker</span>
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Меню</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
)
}
@@ -0,0 +1,19 @@
import { Button } from '@cfdm/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{children}
</Button>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { EmptyState } from './empty-state'
interface QueryStateProps<T> {
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
empty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRetry?: () => void
skeleton?: ReactNode
children: (data: T) => ReactNode
}
export function QueryState<T>({
data,
isLoading,
isError,
error,
empty,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
)
}
if (empty || data == null) {
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
}
return <>{children(data)}</>
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
{items.map((item, idx) => (
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
<CardContent className="flex flex-col gap-1 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{item.label}</span>
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
</div>
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
</CardContent>
</Card>
))}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent } from '@cfdm/ui/components/card'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
}))}
/>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
<CardContent className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))}
</div>
))}
</div>
</CardContent>
</Card>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Badge } from '@cfdm/ui/components/badge'
import type { ComponentProps } from 'react'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'default',
ok: 'default',
paid: 'default',
paused: 'secondary',
archived: 'outline',
error: 'destructive',
running: 'secondary',
overdue: 'destructive',
stale: 'destructive',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
const variant = STATUS_VARIANT[status] ?? 'outline'
return <Badge variant={variant}>{label ?? status}</Badge>
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
interface TableCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}
export function TableCard({ title, description, actions, children, className, contentClassName }: TableCardProps) {
return (
<Card className={cn('gap-0', className)}>
{(title || actions) && (
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="space-y-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</CardHeader>
)}
<CardContent className={cn('p-0', contentClassName)}>{children}</CardContent>
</Card>
)
}