Files
vps-tracker/apps/web/src/routes/_auth/payments.tsx
T
DenozordecandCursor 6fbd1a9113 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]>
2026-06-26 13:42:05 +07:00

194 lines
8.1 KiB
TypeScript

import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
import type { Payment, PaymentType } from '@/types/entities'
import { paymentTypeLabel, formatCurrency } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
export const Route = createFileRoute('/_auth/payments')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: PaymentsPage,
})
interface FormState {
id?: string
type: PaymentType
date: string
amount: number
currency: string
providerAccountId: string
note: string
}
const TODAY = new Date().toISOString().slice(0, 10)
const EMPTY: FormState = { type: 'provider_balance_topup', date: TODAY, amount: 0, currency: 'RUB', providerAccountId: '', note: '' }
function PaymentsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const saveMut = useMutation({
mutationFn: (r: FormState) => r.id
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
: api.create('payments', r as unknown as Payment),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Платёж сохранён')
setOpen(false)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const delMut = useMutation({
mutationFn: (id: string) => api.remove<Payment>('payments', id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Платёж удалён')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
const openEdit = (p: Payment) => {
setForm({
id: p.id, type: p.type, date: p.date, amount: p.amount, currency: p.currency,
providerAccountId: p.providerAccountId, note: p.note ?? '',
})
setOpen(true)
}
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const columns: DataTableColumn<Payment>[] = [
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
{
key: 'account',
header: 'Аккаунт',
cell: (p) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === p.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : '—'
},
},
{
key: 'type',
header: 'Тип',
cell: (p) => <span>{paymentTypeLabel(p.type)}</span>,
},
{
key: 'amount',
header: 'Сумма',
cell: (p) => <span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>,
},
{ key: 'note', header: 'Заметка', cell: (p) => <span className="text-muted-foreground">{p.note || '—'}</span> },
{
key: 'actions',
header: '',
className: 'w-24 text-right',
cell: (p) => (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
<PencilIcon />
</Button>
<ConfirmDialog
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
title="Удалить платёж?"
confirmLabel="Удалить"
destructive
onConfirm={() => delMut.mutate(p.id)}
/>
</div>
),
},
]
const sorted = [...(snapshot?.payments ?? [])].sort((a, b) => b.date.localeCompare(a.date))
return (
<PageShell>
<PageHeader
title="Платежи"
description="Пополнения балансов и прямые платежи за VPS"
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={snapshot?.payments.length === 0}
emptyTitle="Платежей нет"
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить платёж</Button>}
>
{() => <DataTableCard columns={columns} data={sorted} rowKey={(p) => p.id} />}
</QueryState>
<FormSheet
open={open}
onOpenChange={setOpen}
trigger={null}
title={form.id ? 'Редактировать платёж' : 'Новый платёж'}
onSubmit={() => saveMut.mutate(form)}
submitting={saveMut.isPending}
>
<FormField label="Тип" htmlFor="pay-type">
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: (v ?? 'provider_balance_topup') as PaymentType })}>
<SelectTrigger id="pay-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="provider_balance_topup">{paymentTypeLabel('provider_balance_topup')}</SelectItem>
<SelectItem value="direct_vps_payment">{paymentTypeLabel('direct_vps_payment')}</SelectItem>
<SelectItem value="daily_debit">{paymentTypeLabel('daily_debit')}</SelectItem>
<SelectItem value="monthly_debit">{paymentTypeLabel('monthly_debit')}</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="Аккаунт" htmlFor="pay-acc">
<Select value={form.providerAccountId} onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}>
<SelectTrigger id="pay-acc"><SelectValue placeholder="Выберите аккаунт" /></SelectTrigger>
<SelectContent>
{snapshot?.providerAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{accountSelectLabel(a, providerById)}</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Дата" htmlFor="pay-date">
<Input id="pay-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
</FormField>
<FormField label="Сумма" htmlFor="pay-amount">
<Input id="pay-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
</FormField>
<FormField label="Валюта" htmlFor="pay-cur">
<Input id="pay-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
</FormField>
</div>
<FormField label="Заметка" htmlFor="pay-note">
<Textarea id="pay-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</FormField>
</FormSheet>
</PageShell>
)
}