feat(projects): переработать проекты и отчётность по проектам
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Добавлены карточка проекта, KPI и фильтры на /projects, отчёты с разрезом по проектам, cascade rename и защита удаления на API. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
0bcea39868
commit
447d499a8d
19 files changed
+1561
-100
No files matched your search
@@ -0,0 +1,342 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BarChart3Icon,
|
||||
CpuIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
TrendingUpIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||
import type { ProjectFormValues } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@cfdm/ui/components/alert-dialog'
|
||||
import {
|
||||
formatCurrency,
|
||||
normalizeRatesPayload,
|
||||
vpsTariffRateAmount,
|
||||
} from '@/lib/format'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
import {
|
||||
findProject,
|
||||
latestPaymentDate,
|
||||
paymentsForVpsIds,
|
||||
projectVpsList,
|
||||
sumVpsMonthlyBurn,
|
||||
sumVpsResources,
|
||||
} from '@/lib/project-analytics'
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/projects/$projectId')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ProjectDetailPage,
|
||||
})
|
||||
|
||||
function formatDisplayDate(value: string | Date): string {
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
function ProjectDetailPage() {
|
||||
const { projectId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
|
||||
const project = snapshot ? findProject(snapshot, projectId) : undefined
|
||||
const projectVps = useMemo(
|
||||
() => (snapshot ? projectVpsList(snapshot, projectId) : []),
|
||||
[snapshot, projectId],
|
||||
)
|
||||
const activeVps = useMemo(
|
||||
() => projectVps.filter((v) => v.status === 'active'),
|
||||
[projectVps],
|
||||
)
|
||||
|
||||
const analyticsCtx = useMemo(
|
||||
() => ({
|
||||
providers: snapshot?.providers ?? [],
|
||||
settings: snapshot?.settings ?? [],
|
||||
ratesData,
|
||||
}),
|
||||
[snapshot, ratesData],
|
||||
)
|
||||
|
||||
const resources = useMemo(() => sumVpsResources(activeVps), [activeVps])
|
||||
const monthlyBurn = useMemo(
|
||||
() => sumVpsMonthlyBurn(activeVps, analyticsCtx),
|
||||
[activeVps, analyticsCtx],
|
||||
)
|
||||
|
||||
const lastPaymentDate = useMemo(() => {
|
||||
if (!snapshot) return null
|
||||
const ids = new Set(projectVps.map((v) => v.id))
|
||||
return latestPaymentDate(paymentsForVpsIds(snapshot.payments, ids))
|
||||
}, [snapshot, projectVps])
|
||||
|
||||
const baseCurrency = (settings?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: ProjectFormValues) => {
|
||||
const color = values.color?.trim() || null
|
||||
const notes = values.notes?.trim() || null
|
||||
return api.updateProject(values.id!, {
|
||||
name: values.name.trim(),
|
||||
color,
|
||||
notes,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Проект сохранён')
|
||||
setEditOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => api.deleteProject(projectId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Проект удалён')
|
||||
void navigate({ to: '/projects' })
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const columns: DataGridColumn<Vps>[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
header: 'IP',
|
||||
cell: (v) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
|
||||
>
|
||||
{v.ip || v.id}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
cell: (v) => <StatusBadge status={v.status} />,
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
header: 'Тариф',
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right tabular-nums',
|
||||
sortValue: (v) => vpsTariffRateAmount(v) ?? 0,
|
||||
cell: (v) => formatCurrency(vpsTariffRateAmount(v) ?? 0, v.currency),
|
||||
},
|
||||
{
|
||||
key: 'paidUntil',
|
||||
header: 'Оплачено до',
|
||||
cell: (v) => {
|
||||
if (!snapshot) return '—'
|
||||
const paid = getPaidUntilDate(v, {
|
||||
vps: snapshot.vps,
|
||||
providerAccounts: snapshot.providerAccounts,
|
||||
payments: snapshot.payments,
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
})
|
||||
return paid ? formatDisplayDate(paid) : '—'
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={project?.name ?? 'Проект'}
|
||||
description={
|
||||
project?.notes?.trim()
|
||||
? project.notes.length > 120
|
||||
? `${project.notes.slice(0, 120)}…`
|
||||
: project.notes
|
||||
: 'Карточка проекта'
|
||||
}
|
||||
actions={
|
||||
project ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" render={<Link to="/projects" />}>
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
К списку
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
render={
|
||||
<Link to="/reports" search={{ project: project.name }} />
|
||||
}
|
||||
>
|
||||
<BarChart3Icon data-icon="inline-start" />
|
||||
Отчёт
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
render={
|
||||
<Link to="/vps" search={{ project: project.name }} />
|
||||
}
|
||||
>
|
||||
<ServerIcon data-icon="inline-start" />
|
||||
VPS
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (projectVps.length > 0) {
|
||||
toast.error(`Нельзя удалить: к проекту привязано ${projectVps.length} VPS`)
|
||||
return
|
||||
}
|
||||
setDeleteOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
>
|
||||
{() =>
|
||||
!project ? (
|
||||
<EmptyState
|
||||
title="Проект не найден"
|
||||
description="Возможно, он был удалён"
|
||||
action={
|
||||
<Button variant="outline" render={<Link to="/projects" />}>
|
||||
К списку проектов
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Активных VPS',
|
||||
value: activeVps.length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint: `из ${projectVps.length}`,
|
||||
},
|
||||
{
|
||||
label: 'Расход/мес',
|
||||
value: formatCurrency(monthlyBurn, baseCurrency),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
hint: `в ${baseCurrency}`,
|
||||
},
|
||||
{
|
||||
label: 'vCPU / RAM / Disk',
|
||||
value: `${resources.vcpu} / ${resources.ramGb} / ${resources.diskGb}`,
|
||||
icon: <CpuIcon className="size-4" />,
|
||||
hint: 'активные VPS',
|
||||
},
|
||||
{
|
||||
label: 'Последний платёж',
|
||||
value: lastPaymentDate ? formatDisplayDate(lastPaymentDate) : '—',
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{project.notes?.trim() ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Заметки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<DataGridCard
|
||||
title="VPS проекта"
|
||||
description={`${projectVps.length} серверов`}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={projectVps}
|
||||
rowId={(v) => v.id}
|
||||
emptyTitle="VPS не назначены"
|
||||
emptyDescription="Назначьте проект при редактировании VPS"
|
||||
/>
|
||||
<ProjectEditSheet
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
defaultValues={projectFormDefaults({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color ?? '',
|
||||
notes: project.notes ?? '',
|
||||
})}
|
||||
onSubmit={(values) => saveMut.mutate(values)}
|
||||
submitting={saveMut.isPending}
|
||||
/>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить проект?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
«{project.name}» будет удалён без возможности восстановления.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => delMut.mutate()}
|
||||
disabled={delMut.isPending}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,40 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
|
||||
import {
|
||||
PlusIcon,
|
||||
FolderKanbanIcon,
|
||||
ServerIcon,
|
||||
TrendingUpIcon,
|
||||
BarChart3Icon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { CrudListPage } from '@/components/crud-list-page'
|
||||
import { RowActions } from '@/components/row-actions'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { ProjectFiltersToolbar } from '@/components/project-filters-toolbar'
|
||||
import {
|
||||
applyProjectFilters,
|
||||
buildDefaultProjectFilters,
|
||||
hasActiveProjectFilters,
|
||||
type ProjectFiltersState,
|
||||
} from '@/components/project-filters'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||
import type { ProjectFormValues } from '@/lib/schemas'
|
||||
|
||||
interface ProjectRow {
|
||||
id: string
|
||||
name: string
|
||||
color?: string | null
|
||||
vpsCount: number
|
||||
}
|
||||
import { formatCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||
import {
|
||||
buildProjectRows,
|
||||
projectsOverview,
|
||||
type ProjectRow,
|
||||
} from '@/lib/project-analytics'
|
||||
|
||||
export const Route = createFileRoute('/_auth/projects')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -31,16 +45,38 @@ export const Route = createFileRoute('/_auth/projects')({
|
||||
function ProjectsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<ProjectFiltersState>(buildDefaultProjectFilters())
|
||||
const [formDefaults, setFormDefaults] = useState<ProjectFormValues>(projectFormDefaults())
|
||||
|
||||
const analyticsCtx = useMemo(
|
||||
() => ({
|
||||
providers: snapshot?.providers ?? [],
|
||||
settings: snapshot?.settings ?? [],
|
||||
ratesData,
|
||||
}),
|
||||
[snapshot, ratesData],
|
||||
)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: ProjectFormValues) => {
|
||||
const color = values.color?.trim() || null
|
||||
const notes = values.notes?.trim() || null
|
||||
if (values.id) {
|
||||
return api.updateProject(values.id, { name: values.name.trim(), color })
|
||||
return api.updateProject(values.id, {
|
||||
name: values.name.trim(),
|
||||
color,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
return api.createProject(values.name.trim())
|
||||
return api.createProject({
|
||||
name: values.name.trim(),
|
||||
color,
|
||||
notes,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
@@ -59,23 +95,34 @@ function ProjectsPage() {
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const rows: ProjectRow[] = useMemo(
|
||||
() =>
|
||||
(snapshot?.serverProjects ?? []).map((p) => {
|
||||
const row = p as { id: string; name: string; color?: string | null }
|
||||
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
|
||||
return { id: row.id, name: row.name, color: row.color, vpsCount }
|
||||
}),
|
||||
[snapshot],
|
||||
const allRows = useMemo(
|
||||
() => (snapshot ? buildProjectRows(snapshot, analyticsCtx) : []),
|
||||
[snapshot, analyticsCtx],
|
||||
)
|
||||
|
||||
const rows = useMemo(() => applyProjectFilters(allRows, filters), [allRows, filters])
|
||||
|
||||
const overview = useMemo(
|
||||
() => (snapshot ? projectsOverview(snapshot, analyticsCtx) : null),
|
||||
[snapshot, analyticsCtx],
|
||||
)
|
||||
|
||||
const baseCurrency = (settings?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
|
||||
const openCreate = () => {
|
||||
setFormDefaults(projectFormDefaults())
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: ProjectRow) => {
|
||||
setFormDefaults(projectFormDefaults({ id: row.id, name: row.name, color: row.color ?? '' }))
|
||||
setFormDefaults(
|
||||
projectFormDefaults({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color ?? '',
|
||||
notes: row.notes ?? '',
|
||||
}),
|
||||
)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
@@ -92,7 +139,7 @@ function ProjectsPage() {
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
render={<Link to="/vps" search={{ project: row.name }} />}
|
||||
render={<Link to="/projects/$projectId" params={{ projectId: row.id }} />}
|
||||
>
|
||||
{row.name}
|
||||
</Button>
|
||||
@@ -104,11 +151,34 @@ function ProjectsPage() {
|
||||
header: 'VPS',
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right tabular-nums',
|
||||
sortValue: (row) => row.vpsCount,
|
||||
sortValue: (row) => row.vpsTotal,
|
||||
cell: (row) => (
|
||||
<Badge variant="secondary">{row.vpsCount}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{row.vpsActive}/{row.vpsTotal}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'burn',
|
||||
header: 'Расход/мес',
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right tabular-nums',
|
||||
sortValue: (row) => row.monthlyBurn,
|
||||
cell: (row) => formatCurrency(row.monthlyBurn, baseCurrency),
|
||||
},
|
||||
{
|
||||
key: 'resources',
|
||||
header: 'Ресурсы',
|
||||
className: 'text-muted-foreground text-sm tabular-nums',
|
||||
sortValue: (row) => row.vcpu,
|
||||
cell: (row) => `${row.vcpu} vCPU · ${row.ramGb} GB · ${row.diskGb} GB`,
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
header: 'Заметки',
|
||||
className: 'max-w-48 truncate text-muted-foreground',
|
||||
cell: (row) => row.notes?.trim() || '—',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
@@ -118,8 +188,8 @@ function ProjectsPage() {
|
||||
<RowActions
|
||||
onEdit={() => openEdit(row)}
|
||||
onDelete={() => {
|
||||
if (row.vpsCount > 0) {
|
||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsCount} VPS`)
|
||||
if (row.vpsTotal > 0) {
|
||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
|
||||
return
|
||||
}
|
||||
delMut.mutate(row.id)
|
||||
@@ -134,19 +204,25 @@ function ProjectsPage() {
|
||||
return (
|
||||
<CrudListPage
|
||||
title="Проекты"
|
||||
description="Группировка VPS по проектам"
|
||||
description="Группировка VPS, расходы и ресурсы по проектам"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" render={<Link to="/reports" />}>
|
||||
<BarChart3Icon data-icon="inline-start" />
|
||||
Отчёты
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
empty={rows.length === 0}
|
||||
empty={allRows.length === 0}
|
||||
emptyTitle="Проектов нет"
|
||||
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
||||
emptyAction={
|
||||
@@ -166,12 +242,64 @@ function ProjectsPage() {
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={rows}
|
||||
rowId={(r) => r.id}
|
||||
pinLastColumn
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{overview ? (
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Проектов',
|
||||
value: overview.projectCount,
|
||||
icon: <FolderKanbanIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: 'VPS в проектах',
|
||||
value: overview.vpsInProjects,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint:
|
||||
overview.vpsUnassigned > 0
|
||||
? `${overview.vpsUnassigned} без проекта`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Расход/мес',
|
||||
value: formatCurrency(overview.monthlyBurnInProjects, baseCurrency),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
hint: `в ${baseCurrency}`,
|
||||
},
|
||||
{
|
||||
label: 'Активных VPS',
|
||||
value: overview.activeInProjects,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint: 'в проектах',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<ProjectFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
shownCount={rows.length}
|
||||
totalCount={allRows.length}
|
||||
/>
|
||||
{rows.length === 0 && hasActiveProjectFilters(filters) ? (
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
description="Измените фильтры или сбросьте их"
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setFilters(buildDefaultProjectFilters())}>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={rows}
|
||||
rowId={(r) => r.id}
|
||||
pinLastColumn
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CrudListPage>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,100 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { DownloadIcon, TrendingUpIcon, CreditCardIcon, ServerIcon } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { AnalyticsPage } from '@/components/analytics-page'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { ChartsGrid, MonthlyExpenseChart, PaymentsPieChart, MonthlyTrendChart } from '@/components/domain/charts'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
ChartsGrid,
|
||||
MonthlyExpenseChart,
|
||||
PaymentsPieChart,
|
||||
MonthlyTrendChart,
|
||||
ProjectExpenseChart,
|
||||
} from '@/components/domain/charts'
|
||||
import {
|
||||
ReportsFiltersToolbar,
|
||||
buildDefaultReportsFilters,
|
||||
hasActiveReportsFilters,
|
||||
type ReportsFiltersState,
|
||||
} from '@/components/reports-filters-toolbar'
|
||||
import { exportVpsCsv } from '@/lib/export-csv'
|
||||
import { formatCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||
import {
|
||||
filterPaymentsByPeriod,
|
||||
filterPaymentsByProjectKeys,
|
||||
filterVpsByProjectKeys,
|
||||
paymentsInTrendWindow,
|
||||
projectKeysFromSearch,
|
||||
sumVpsMonthlyBurn,
|
||||
type ReportsPeriod,
|
||||
} from '@/lib/project-analytics'
|
||||
|
||||
import { convertVpsMonthlyBurnToBase, formatCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||
import { providerByIdMap } from '@/lib/billmanager'
|
||||
const reportsSearchSchema = z.object({
|
||||
project: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
period: z.enum(['3m', '6m', '12m', 'all']).optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_auth/reports')({
|
||||
validateSearch: (search) => reportsSearchSchema.parse(search),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ReportsPage,
|
||||
})
|
||||
|
||||
function ReportsPage() {
|
||||
const search = Route.useSearch()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
const [filters, setFilters] = useState<ReportsFiltersState>(buildDefaultReportsFilters())
|
||||
|
||||
const projects = snapshot?.serverProjects ?? []
|
||||
|
||||
useEffect(() => {
|
||||
const keys = projectKeysFromSearch(search.project, projects)
|
||||
setFilters({
|
||||
projectKeys: keys,
|
||||
period: (search.period as ReportsPeriod) ?? '12m',
|
||||
})
|
||||
}, [search.project, search.period, projects])
|
||||
|
||||
const filteredVps = useMemo(() => {
|
||||
if (!snapshot) return []
|
||||
return filterVpsByProjectKeys(snapshot.vps, filters.projectKeys, projects)
|
||||
}, [snapshot, filters.projectKeys, projects])
|
||||
|
||||
const filteredPayments = useMemo(() => {
|
||||
if (!snapshot) return []
|
||||
const byProject = filterPaymentsByProjectKeys(
|
||||
snapshot.payments,
|
||||
new Map(snapshot.vps.map((v) => [v.id, v])),
|
||||
filters.projectKeys,
|
||||
projects,
|
||||
)
|
||||
return filterPaymentsByPeriod(byProject, filters.period)
|
||||
}, [snapshot, filters.projectKeys, filters.period, projects])
|
||||
|
||||
const trendPayments = useMemo(
|
||||
() => paymentsInTrendWindow(filteredPayments, filters.period),
|
||||
[filteredPayments, filters.period],
|
||||
)
|
||||
|
||||
const exportCsv = () => {
|
||||
if (!snapshot) return
|
||||
const suffix =
|
||||
filters.projectKeys.length === 1
|
||||
? `-${projects.find((p) => p.id === filters.projectKeys[0])?.name ?? 'project'}`
|
||||
: filters.projectKeys.length > 1
|
||||
? '-filtered'
|
||||
: ''
|
||||
exportVpsCsv(
|
||||
snapshot.vps.map((v) => ({
|
||||
filteredVps.map((v) => ({
|
||||
ip: v.ip,
|
||||
project: v.project ?? '',
|
||||
status: v.status,
|
||||
@@ -37,16 +104,19 @@ function ReportsPage() {
|
||||
monthlyRate: v.monthlyRate ?? 0,
|
||||
currency: v.currency,
|
||||
})),
|
||||
'vps-report.csv',
|
||||
`vps-report${suffix}.csv`,
|
||||
)
|
||||
}
|
||||
|
||||
const filterActive = hasActiveReportsFilters(filters)
|
||||
const zeroResults = Boolean(snapshot && snapshot.vps.length > 0 && filteredVps.length === 0 && filterActive)
|
||||
|
||||
return (
|
||||
<AnalyticsPage
|
||||
title="Отчёты"
|
||||
description="Расходы, платежи и динамика"
|
||||
description="Расходы, платежи и динамика в разрезе проектов"
|
||||
actions={
|
||||
<Button variant="outline" onClick={exportCsv} disabled={!snapshot}>
|
||||
<Button variant="outline" onClick={exportCsv} disabled={!snapshot || filteredVps.length === 0}>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
Экспорт CSV
|
||||
</Button>
|
||||
@@ -64,47 +134,104 @@ function ReportsPage() {
|
||||
}
|
||||
>
|
||||
{(snap) => {
|
||||
const providerById = providerByIdMap(snap.providers)
|
||||
const baseCurrency = (snap.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const monthly = snap.vps.reduce(
|
||||
(acc, v) =>
|
||||
acc + convertVpsMonthlyBurnToBase(v, providerById.get(v.providerId), snap.settings, ratesData),
|
||||
0,
|
||||
const analyticsCtx = {
|
||||
providers: snap.providers,
|
||||
settings: snap.settings,
|
||||
ratesData,
|
||||
}
|
||||
const monthly = sumVpsMonthlyBurn(
|
||||
filteredVps.filter((v) => v.status === 'active'),
|
||||
analyticsCtx,
|
||||
)
|
||||
const expenseTitle =
|
||||
filters.projectKeys.length > 0
|
||||
? 'Расходы по хостерам (в рамках фильтра)'
|
||||
: 'Расходы по хостерам (мес)'
|
||||
|
||||
if (zeroResults) {
|
||||
return (
|
||||
<>
|
||||
<ReportsFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
projects={projects}
|
||||
shownVps={0}
|
||||
totalVps={snap.vps.length}
|
||||
/>
|
||||
<EmptyState
|
||||
title="Нет данных по фильтру"
|
||||
description="Выберите другие проекты или сбросьте фильтры"
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setFilters(buildDefaultReportsFilters())}>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportsFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
projects={projects}
|
||||
shownVps={filteredVps.length}
|
||||
totalVps={snap.vps.length}
|
||||
/>
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Расход/мес',
|
||||
value: formatCurrency(monthly, baseCurrency),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
hint: `в ${baseCurrency}`,
|
||||
hint:
|
||||
filters.projectKeys.length > 0
|
||||
? `в ${baseCurrency}, по фильтру`
|
||||
: `в ${baseCurrency}`,
|
||||
},
|
||||
{
|
||||
label: 'Платежей',
|
||||
value: snap.payments.length,
|
||||
value: filteredPayments.length,
|
||||
icon: <CreditCardIcon className="size-4" />,
|
||||
hint:
|
||||
filters.projectKeys.length > 0
|
||||
? 'только с привязкой к VPS проекта'
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Активных VPS',
|
||||
value: snap.vps.filter((v) => v.status === 'active').length,
|
||||
value: filteredVps.filter((v) => v.status === 'active').length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint: `из ${snap.vps.length}`,
|
||||
hint: `из ${filteredVps.length}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ChartsGrid>
|
||||
<ProjectExpenseChart
|
||||
vps={filteredVps}
|
||||
projects={projects}
|
||||
providers={snap.providers}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
<MonthlyExpenseChart
|
||||
vps={snap.vps}
|
||||
vps={filteredVps}
|
||||
providers={snap.providers}
|
||||
providerAccounts={snap.providerAccounts}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
title={expenseTitle}
|
||||
/>
|
||||
<PaymentsPieChart
|
||||
payments={filteredPayments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
|
||||
<MonthlyTrendChart
|
||||
payments={snap.payments}
|
||||
payments={trendPayments}
|
||||
settings={snap.settings}
|
||||
ratesData={ratesData}
|
||||
className="lg:col-span-2"
|
||||
|
||||
Reference in new issue
Block a user