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:
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { closeDb } from '@cfdm/db'
|
||||||
|
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
|
||||||
|
import { projectsRepository } from '@cfdm/db/repositories/projects'
|
||||||
|
import { getSqlite } from '@cfdm/db'
|
||||||
|
import { buildApp } from '../index.js'
|
||||||
|
|
||||||
|
describe('projects routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetTestDb()
|
||||||
|
seedTestProvider()
|
||||||
|
seedTestProviderAccount()
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
closeDb()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists projects', async () => {
|
||||||
|
projectsRepository.create({ name: 'Alpha', color: '#ff0000' })
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/projects' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = res.json() as { id: string; name: string; color?: string }[]
|
||||||
|
expect(body).toHaveLength(1)
|
||||||
|
expect(body[0]?.name).toBe('Alpha')
|
||||||
|
expect(body[0]?.color).toBe('#ff0000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates project with color and notes', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/projects',
|
||||||
|
payload: { name: 'Web', color: '#3b82f6', notes: 'Production sites' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
const body = res.json() as { id: string; name: string; color?: string; notes?: string }
|
||||||
|
expect(body.name).toBe('Web')
|
||||||
|
expect(body.color).toBe('#3b82f6')
|
||||||
|
expect(body.notes).toBe('Production sites')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renames project and cascades vps.project', async () => {
|
||||||
|
const project = projectsRepository.create({ name: 'OldName' })
|
||||||
|
getSqlite()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO vps (id, ip, providerId, providerAccountId, status, project, projectId)
|
||||||
|
VALUES ('vps-p1', '1.1.1.1', 'prov-1', 'acc-1', 'active', 'OldName', ?)`,
|
||||||
|
)
|
||||||
|
.run(project.id)
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/projects/${project.id}`,
|
||||||
|
payload: { name: 'NewName' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const vps = getSqlite().prepare(`SELECT project FROM vps WHERE id = 'vps-p1'`).get() as {
|
||||||
|
project: string
|
||||||
|
}
|
||||||
|
expect(vps.project).toBe('NewName')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 409 when deleting project with VPS', async () => {
|
||||||
|
const project = projectsRepository.create({ name: 'Bound' })
|
||||||
|
getSqlite()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO vps (id, ip, providerId, providerAccountId, status, project, projectId)
|
||||||
|
VALUES ('vps-p2', '2.2.2.2', 'prov-1', 'acc-1', 'active', 'Bound', ?)`,
|
||||||
|
)
|
||||||
|
.run(project.id)
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/projects/${project.id}`,
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
const body = res.json() as { error?: { code?: string; dependencies?: { vps?: number } } }
|
||||||
|
expect(body.error?.code).toBe('CONFLICT')
|
||||||
|
expect(body.error?.dependencies?.vps).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes project without dependencies', async () => {
|
||||||
|
const project = projectsRepository.create({ name: 'Free' })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/projects/${project.id}`,
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(204)
|
||||||
|
expect(projectsRepository.get(project.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,11 +21,17 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/projects', async (req, reply) => {
|
app.post('/api/projects', async (req, reply) => {
|
||||||
const name = normalizeProjectNameInput((req.body as { name?: unknown })?.name)
|
const body = req.body as { name?: unknown; color?: string | null; notes?: string | null }
|
||||||
|
const name = normalizeProjectNameInput(body.name)
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } })
|
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } })
|
||||||
}
|
}
|
||||||
return reply.code(201).send(resolveOrCreateProject(name))
|
const created = projectsRepository.createOrResolve({
|
||||||
|
name,
|
||||||
|
color: body.color,
|
||||||
|
notes: body.notes,
|
||||||
|
})
|
||||||
|
return reply.code(201).send(created)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
app.put<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
||||||
@@ -46,10 +52,21 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
||||||
const ok = projectsRepository.delete(req.params.id)
|
const existing = projectsRepository.get(req.params.id)
|
||||||
if (!ok) {
|
if (!existing) {
|
||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||||
}
|
}
|
||||||
|
const dependencies = projectsRepository.getDependencyCounts(req.params.id)
|
||||||
|
if (dependencies.vps > 0) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: {
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: `Нельзя удалить: к проекту привязано ${dependencies.vps} VPS`,
|
||||||
|
dependencies,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
projectsRepository.delete(req.params.id)
|
||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
|
import type { Vps, Provider, Payment, Settings, RatesData, ServerProject } from '@/types/entities'
|
||||||
import {
|
import {
|
||||||
canonicalPaymentType,
|
canonicalPaymentType,
|
||||||
convertCurrency,
|
convertCurrency,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
toIsoCurrency,
|
toIsoCurrency,
|
||||||
} from '@/lib/format'
|
} from '@/lib/format'
|
||||||
import { providerByIdMap } from '@/lib/billmanager'
|
import { providerByIdMap } from '@/lib/billmanager'
|
||||||
|
import { aggregateBurnByProject } from '@/lib/project-analytics'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
|
||||||
function ChartEmpty({ message }: { message: string }) {
|
function ChartEmpty({ message }: { message: string }) {
|
||||||
@@ -48,6 +49,8 @@ export function MonthlyExpenseChart({
|
|||||||
settings,
|
settings,
|
||||||
ratesData,
|
ratesData,
|
||||||
className,
|
className,
|
||||||
|
title = 'Расходы по хостерам (мес)',
|
||||||
|
description,
|
||||||
}: {
|
}: {
|
||||||
vps: Vps[]
|
vps: Vps[]
|
||||||
providers: Provider[]
|
providers: Provider[]
|
||||||
@@ -55,6 +58,8 @@ export function MonthlyExpenseChart({
|
|||||||
settings: Settings[]
|
settings: Settings[]
|
||||||
ratesData: RatesData | null
|
ratesData: RatesData | null
|
||||||
className?: string
|
className?: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
}) {
|
}) {
|
||||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||||
const providerById = providerByIdMap(providers)
|
const providerById = providerByIdMap(providers)
|
||||||
@@ -79,8 +84,8 @@ export function MonthlyExpenseChart({
|
|||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Расходы по хостерам (мес)</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
|
<CardDescription>{description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
@@ -228,3 +233,77 @@ export function MonthlyTrendChart({
|
|||||||
export function ChartsGrid({ children }: { children: ReactNode }) {
|
export function ChartsGrid({ children }: { children: ReactNode }) {
|
||||||
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
|
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ProjectExpenseChart({
|
||||||
|
vps,
|
||||||
|
projects,
|
||||||
|
providers,
|
||||||
|
settings,
|
||||||
|
ratesData,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
vps: Vps[]
|
||||||
|
projects: ServerProject[]
|
||||||
|
providers: Provider[]
|
||||||
|
settings: Settings[]
|
||||||
|
ratesData: RatesData | null
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
aggregateBurnByProject(vps, projects, {
|
||||||
|
providers,
|
||||||
|
settings,
|
||||||
|
ratesData,
|
||||||
|
}),
|
||||||
|
[vps, projects, providers, settings, ratesData],
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartConfig: ChartConfig = useMemo(() => {
|
||||||
|
const config: ChartConfig = { expense: { label: 'Расход', color: 'var(--chart-1)' } }
|
||||||
|
data.forEach((row, i) => {
|
||||||
|
config[row.key] = {
|
||||||
|
label: row.name,
|
||||||
|
color: row.color ?? `var(--chart-${(i % 5) + 1})`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return config
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Расходы по проектам (мес)</CardTitle>
|
||||||
|
<CardDescription>Активные VPS, в {baseCurrency}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<ChartEmpty message="Нет данных для графика" />
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} 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" radius={4}>
|
||||||
|
{data.map((row) => (
|
||||||
|
<Cell
|
||||||
|
key={row.key}
|
||||||
|
fill={row.color ?? chartConfig[row.key]?.color ?? 'var(--chart-1)'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Controller } from 'react-hook-form'
|
|||||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||||
import { ColorPicker } from '@/components/reui/color-picker'
|
import { ColorPicker } from '@/components/reui/color-picker'
|
||||||
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
|
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
|
||||||
|
|
||||||
const EMPTY: ProjectFormValues = { name: '', color: '' }
|
const EMPTY: ProjectFormValues = { name: '', color: '', notes: '' }
|
||||||
|
|
||||||
interface ProjectEditSheetProps {
|
interface ProjectEditSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -18,7 +19,7 @@ interface ProjectEditSheetProps {
|
|||||||
|
|
||||||
export function projectFormDefaults(edit?: Partial<ProjectFormValues> | null): ProjectFormValues {
|
export function projectFormDefaults(edit?: Partial<ProjectFormValues> | null): ProjectFormValues {
|
||||||
if (!edit) return { ...EMPTY }
|
if (!edit) return { ...EMPTY }
|
||||||
return { ...EMPTY, ...edit, color: edit.color ?? '' }
|
return { ...EMPTY, ...edit, color: edit.color ?? '', notes: edit.notes ?? '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectEditSheet({
|
export function ProjectEditSheet({
|
||||||
@@ -73,6 +74,19 @@ export function ProjectEditSheet({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label="Заметки"
|
||||||
|
htmlFor="project-notes"
|
||||||
|
error={errors.notes?.message}
|
||||||
|
invalid={!!errors.notes}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="project-notes"
|
||||||
|
rows={3}
|
||||||
|
aria-invalid={!!errors.notes}
|
||||||
|
{...register('notes')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ListFiltersBar,
|
||||||
|
FilterToggleChip,
|
||||||
|
type FilterChip,
|
||||||
|
} from '@/components/list-filters-bar'
|
||||||
|
import {
|
||||||
|
type ProjectFiltersState,
|
||||||
|
buildDefaultProjectFilters,
|
||||||
|
hasActiveProjectFilters,
|
||||||
|
} from '@/components/project-filters'
|
||||||
|
|
||||||
|
interface ProjectFiltersToolbarProps {
|
||||||
|
filters: ProjectFiltersState
|
||||||
|
onChange: (next: ProjectFiltersState) => void
|
||||||
|
shownCount: number
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectFiltersToolbar({
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
shownCount,
|
||||||
|
totalCount,
|
||||||
|
}: ProjectFiltersToolbarProps) {
|
||||||
|
const chips = useMemo((): FilterChip[] => {
|
||||||
|
const out: FilterChip[] = []
|
||||||
|
if (filters.search.trim()) {
|
||||||
|
out.push({
|
||||||
|
id: 'search',
|
||||||
|
label: `Поиск: ${filters.search.trim()}`,
|
||||||
|
onRemove: () => onChange({ ...filters, search: '' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (filters.withVpsOnly) {
|
||||||
|
out.push({
|
||||||
|
id: 'withVps',
|
||||||
|
label: 'Только с VPS',
|
||||||
|
onRemove: () => onChange({ ...filters, withVpsOnly: false }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [filters, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListFiltersBar
|
||||||
|
search={{
|
||||||
|
value: filters.search,
|
||||||
|
onChange: (search) => onChange({ ...filters, search }),
|
||||||
|
placeholder: 'Поиск по названию или заметкам',
|
||||||
|
}}
|
||||||
|
controls={
|
||||||
|
<FilterToggleChip
|
||||||
|
label="С VPS"
|
||||||
|
active={filters.withVpsOnly}
|
||||||
|
onClick={() => onChange({ ...filters, withVpsOnly: !filters.withVpsOnly })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
chips={chips}
|
||||||
|
shown={shownCount}
|
||||||
|
total={totalCount}
|
||||||
|
showReset={hasActiveProjectFilters(filters)}
|
||||||
|
onReset={() => onChange(buildDefaultProjectFilters())}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ProjectRow } from '@/lib/project-analytics'
|
||||||
|
|
||||||
|
export interface ProjectFiltersState {
|
||||||
|
search: string
|
||||||
|
withVpsOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultProjectFilters(): ProjectFiltersState {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
withVpsOnly: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveProjectFilters(filters: ProjectFiltersState): boolean {
|
||||||
|
return Boolean(filters.search.trim() || filters.withVpsOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProjectFilters(rows: ProjectRow[], filters: ProjectFiltersState): ProjectRow[] {
|
||||||
|
const q = filters.search.trim().toLowerCase()
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (filters.withVpsOnly && row.vpsTotal === 0) return false
|
||||||
|
if (!q) return true
|
||||||
|
return (
|
||||||
|
row.name.toLowerCase().includes(q) ||
|
||||||
|
(row.notes ?? '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { FolderKanbanIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||||
|
import { Label } from '@cfdm/ui/components/label'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import {
|
||||||
|
ListFiltersBar,
|
||||||
|
type FilterChip,
|
||||||
|
} from '@/components/list-filters-bar'
|
||||||
|
import { NO_PROJECT_KEY, type ReportsPeriod, periodLabel } from '@/lib/project-analytics'
|
||||||
|
import type { ServerProject } from '@/types/entities'
|
||||||
|
|
||||||
|
export interface ReportsFiltersState {
|
||||||
|
projectKeys: string[]
|
||||||
|
period: ReportsPeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultReportsFilters(): ReportsFiltersState {
|
||||||
|
return { projectKeys: [], period: '12m' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveReportsFilters(filters: ReportsFiltersState): boolean {
|
||||||
|
return filters.projectKeys.length > 0 || filters.period !== '12m'
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectLabel(key: string, projects: ServerProject[]): string {
|
||||||
|
if (key === NO_PROJECT_KEY) return 'Без проекта'
|
||||||
|
return projects.find((p) => p.id === key)?.name ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportsFiltersToolbarProps {
|
||||||
|
filters: ReportsFiltersState
|
||||||
|
onChange: (next: ReportsFiltersState) => void
|
||||||
|
projects: ServerProject[]
|
||||||
|
shownVps: number
|
||||||
|
totalVps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportsFiltersToolbar({
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
projects,
|
||||||
|
shownVps,
|
||||||
|
totalVps,
|
||||||
|
}: ReportsFiltersToolbarProps) {
|
||||||
|
const chips = useMemo((): FilterChip[] => {
|
||||||
|
const out: FilterChip[] = []
|
||||||
|
if (filters.projectKeys.length) {
|
||||||
|
out.push({
|
||||||
|
id: 'projects',
|
||||||
|
label: `Проекты: ${filters.projectKeys.map((k) => projectLabel(k, projects)).join(', ')}`,
|
||||||
|
onRemove: () => onChange({ ...filters, projectKeys: [] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (filters.period !== '12m') {
|
||||||
|
out.push({
|
||||||
|
id: 'period',
|
||||||
|
label: `Период: ${periodLabel(filters.period)}`,
|
||||||
|
onRemove: () => onChange({ ...filters, period: '12m' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [filters, onChange, projects])
|
||||||
|
|
||||||
|
const toggleProjectKey = (key: string) => {
|
||||||
|
const set = new Set(filters.projectKeys)
|
||||||
|
if (set.has(key)) set.delete(key)
|
||||||
|
else set.add(key)
|
||||||
|
onChange({ ...filters, projectKeys: [...set] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectButtonLabel =
|
||||||
|
filters.projectKeys.length === 0
|
||||||
|
? 'Все проекты'
|
||||||
|
: `Проекты (${filters.projectKeys.length})`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListFiltersBar
|
||||||
|
controls={
|
||||||
|
<>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto">
|
||||||
|
<FolderKanbanIcon data-icon="inline-start" />
|
||||||
|
{projectButtonLabel}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent className="w-72 p-3" align="start">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Проекты</p>
|
||||||
|
<div className="flex flex-col gap-2 max-h-56 overflow-y-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="reports-project-none"
|
||||||
|
checked={filters.projectKeys.includes(NO_PROJECT_KEY)}
|
||||||
|
onCheckedChange={() => toggleProjectKey(NO_PROJECT_KEY)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="reports-project-none" className="font-normal">
|
||||||
|
Без проекта
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`reports-project-${p.id}`}
|
||||||
|
checked={filters.projectKeys.includes(p.id)}
|
||||||
|
onCheckedChange={() => toggleProjectKey(p.id)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`reports-project-${p.id}`} className="flex items-center gap-2 font-normal">
|
||||||
|
{p.color ? (
|
||||||
|
<span
|
||||||
|
className="size-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: p.color }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{p.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<SelectField
|
||||||
|
triggerClassName="w-full sm:w-44"
|
||||||
|
placeholder="Период"
|
||||||
|
value={filters.period}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onChange({ ...filters, period: (v as ReportsPeriod) ?? '12m' })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: '3m', label: '3 месяца' },
|
||||||
|
{ value: '6m', label: '6 месяцев' },
|
||||||
|
{ value: '12m', label: '12 месяцев' },
|
||||||
|
{ value: 'all', label: 'Всё время' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
chips={chips}
|
||||||
|
shown={shownVps}
|
||||||
|
total={totalVps}
|
||||||
|
resultsSuffix="VPS"
|
||||||
|
showReset={hasActiveReportsFilters(filters)}
|
||||||
|
onReset={() => onChange(buildDefaultReportsFilters())}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -169,10 +169,10 @@ export const api = {
|
|||||||
|
|
||||||
fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'),
|
fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'),
|
||||||
|
|
||||||
createProject: (name: string) =>
|
createProject: (payload: { name: string; color?: string | null; notes?: string | null }) =>
|
||||||
fetchApi<{ id: string; name: string }>('/api/projects', {
|
fetchApi<import('@/types/entities').ServerProject>('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateProject: (id: string, patch: { name?: string; color?: string | null; notes?: string | null }) =>
|
updateProject: (id: string, patch: { name?: string; color?: string | null; notes?: string | null }) =>
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import type {
|
||||||
|
DataSnapshot,
|
||||||
|
Payment,
|
||||||
|
Provider,
|
||||||
|
RatesData,
|
||||||
|
ServerProject,
|
||||||
|
Settings,
|
||||||
|
Vps,
|
||||||
|
} from '@/types/entities'
|
||||||
|
import { convertVpsMonthlyBurnToBase, monthKey } from '@/lib/format'
|
||||||
|
import { providerByIdMap } from '@/lib/billmanager'
|
||||||
|
|
||||||
|
export const NO_PROJECT_KEY = '__none__'
|
||||||
|
|
||||||
|
export type ReportsPeriod = '3m' | '6m' | '12m' | 'all'
|
||||||
|
|
||||||
|
export interface ProjectRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
createdAt?: string
|
||||||
|
vpsTotal: number
|
||||||
|
vpsActive: number
|
||||||
|
monthlyBurn: number
|
||||||
|
vcpu: number
|
||||||
|
ramGb: number
|
||||||
|
diskGb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectAnalyticsContext {
|
||||||
|
providers: Provider[]
|
||||||
|
settings: Settings[]
|
||||||
|
ratesData: RatesData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vpsBelongsToProject(
|
||||||
|
vps: Pick<Vps, 'project' | 'projectId'>,
|
||||||
|
project: Pick<ServerProject, 'id' | 'name'>,
|
||||||
|
): boolean {
|
||||||
|
if (vps.projectId && vps.projectId === project.id) return true
|
||||||
|
const name = (vps.project ?? '').trim()
|
||||||
|
return name !== '' && name.toLowerCase() === project.name.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vpsHasNoProject(vps: Pick<Vps, 'project' | 'projectId'>): boolean {
|
||||||
|
return !(vps.projectId ?? '').trim() && !(vps.project ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectFilterKeys(
|
||||||
|
keys: string[],
|
||||||
|
projects: ServerProject[],
|
||||||
|
): { projectIds: string[]; includeNoProject: boolean } {
|
||||||
|
const projectIds: string[] = []
|
||||||
|
let includeNoProject = false
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key === NO_PROJECT_KEY) {
|
||||||
|
includeNoProject = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const byId = projects.find((p) => p.id === key)
|
||||||
|
if (byId) {
|
||||||
|
projectIds.push(byId.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const byName = projects.find((p) => p.name.toLowerCase() === key.toLowerCase())
|
||||||
|
if (byName) projectIds.push(byName.id)
|
||||||
|
}
|
||||||
|
return { projectIds: [...new Set(projectIds)], includeNoProject }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectKeysFromSearch(
|
||||||
|
project: string | string[] | undefined,
|
||||||
|
projects: ServerProject[],
|
||||||
|
): string[] {
|
||||||
|
if (!project) return []
|
||||||
|
const raw = Array.isArray(project) ? project : [project]
|
||||||
|
const keys: string[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
const trimmed = item.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
if (trimmed === NO_PROJECT_KEY || trimmed.toLowerCase() === 'без проекта') {
|
||||||
|
keys.push(NO_PROJECT_KEY)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const match = projects.find((p) => p.name.toLowerCase() === trimmed.toLowerCase())
|
||||||
|
keys.push(match?.id ?? trimmed)
|
||||||
|
}
|
||||||
|
return [...new Set(keys)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVpsByProjectKeys(
|
||||||
|
vpsList: Vps[],
|
||||||
|
keys: string[],
|
||||||
|
projects: ServerProject[],
|
||||||
|
): Vps[] {
|
||||||
|
if (!keys.length) return vpsList
|
||||||
|
const { projectIds, includeNoProject } = resolveProjectFilterKeys(keys, projects)
|
||||||
|
const selected = projectIds
|
||||||
|
.map((id) => projects.find((p) => p.id === id))
|
||||||
|
.filter((p): p is ServerProject => Boolean(p))
|
||||||
|
|
||||||
|
return vpsList.filter((v) => {
|
||||||
|
if (includeNoProject && vpsHasNoProject(v)) return true
|
||||||
|
return selected.some((p) => vpsBelongsToProject(v, p))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPaymentsByProjectKeys(
|
||||||
|
payments: Payment[],
|
||||||
|
vpsById: Map<string, Vps>,
|
||||||
|
keys: string[],
|
||||||
|
projects: ServerProject[],
|
||||||
|
): Payment[] {
|
||||||
|
if (!keys.length) return payments
|
||||||
|
const allowedVpsIds = new Set(
|
||||||
|
filterVpsByProjectKeys(Array.from(vpsById.values()), keys, projects).map((v) => v.id),
|
||||||
|
)
|
||||||
|
return payments.filter((p) => p.vpsId && allowedVpsIds.has(p.vpsId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPaymentsByPeriod(
|
||||||
|
payments: Payment[],
|
||||||
|
period: ReportsPeriod,
|
||||||
|
): Payment[] {
|
||||||
|
if (period === 'all') return payments
|
||||||
|
const months = period === '3m' ? 3 : period === '6m' ? 6 : 12
|
||||||
|
const cutoff = new Date()
|
||||||
|
cutoff.setMonth(cutoff.getMonth() - months)
|
||||||
|
cutoff.setHours(0, 0, 0, 0)
|
||||||
|
return payments.filter((p) => {
|
||||||
|
const date = new Date(p.date)
|
||||||
|
return !Number.isNaN(date.getTime()) && date >= cutoff
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumVpsMonthlyBurn(
|
||||||
|
vpsList: Vps[],
|
||||||
|
ctx: ProjectAnalyticsContext,
|
||||||
|
): number {
|
||||||
|
const providerById = providerByIdMap(ctx.providers)
|
||||||
|
return vpsList.reduce(
|
||||||
|
(acc, v) =>
|
||||||
|
acc + convertVpsMonthlyBurnToBase(v, providerById.get(v.providerId), ctx.settings, ctx.ratesData),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumVpsResources(vpsList: Vps[]): { vcpu: number; ramGb: number; diskGb: number } {
|
||||||
|
return vpsList.reduce(
|
||||||
|
(acc, v) => ({
|
||||||
|
vcpu: acc.vcpu + Number(v.vcpu || 0),
|
||||||
|
ramGb: acc.ramGb + Number(v.ramGb || 0),
|
||||||
|
diskGb: acc.diskGb + Number(v.diskGb || 0),
|
||||||
|
}),
|
||||||
|
{ vcpu: 0, ramGb: 0, diskGb: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectRows(
|
||||||
|
snapshot: DataSnapshot,
|
||||||
|
ctx: ProjectAnalyticsContext,
|
||||||
|
): ProjectRow[] {
|
||||||
|
const projects = (snapshot.serverProjects ?? []) as ServerProject[]
|
||||||
|
return projects.map((project) => {
|
||||||
|
const projectVps = snapshot.vps.filter((v) => vpsBelongsToProject(v, project))
|
||||||
|
const active = projectVps.filter((v) => v.status === 'active')
|
||||||
|
const resources = sumVpsResources(active)
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
color: project.color,
|
||||||
|
notes: project.notes,
|
||||||
|
createdAt: project.createdAt,
|
||||||
|
vpsTotal: projectVps.length,
|
||||||
|
vpsActive: active.length,
|
||||||
|
monthlyBurn: sumVpsMonthlyBurn(active, ctx),
|
||||||
|
...resources,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectVpsList(snapshot: DataSnapshot, projectId: string): Vps[] {
|
||||||
|
const project = (snapshot.serverProjects ?? []).find(
|
||||||
|
(p) => (p as ServerProject).id === projectId,
|
||||||
|
) as ServerProject | undefined
|
||||||
|
if (!project) return []
|
||||||
|
return snapshot.vps.filter((v) => vpsBelongsToProject(v, project))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProject(snapshot: DataSnapshot, projectId: string): ServerProject | undefined {
|
||||||
|
return (snapshot.serverProjects ?? []).find((p) => (p as ServerProject).id === projectId) as
|
||||||
|
| ServerProject
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateBurnByProject(
|
||||||
|
vpsList: Vps[],
|
||||||
|
projects: ServerProject[],
|
||||||
|
ctx: ProjectAnalyticsContext,
|
||||||
|
limit = 10,
|
||||||
|
): { key: string; name: string; expense: number; color?: string | null }[] {
|
||||||
|
const providerById = providerByIdMap(ctx.providers)
|
||||||
|
const byKey = new Map<string, { name: string; expense: number; color?: string | null }>()
|
||||||
|
|
||||||
|
for (const v of vpsList) {
|
||||||
|
if (v.status !== 'active') continue
|
||||||
|
const burn = convertVpsMonthlyBurnToBase(
|
||||||
|
v,
|
||||||
|
providerById.get(v.providerId),
|
||||||
|
ctx.settings,
|
||||||
|
ctx.ratesData,
|
||||||
|
)
|
||||||
|
if (burn <= 0) continue
|
||||||
|
|
||||||
|
const matched = projects.find((p) => vpsBelongsToProject(v, p))
|
||||||
|
const key = matched?.id ?? NO_PROJECT_KEY
|
||||||
|
const name = matched?.name ?? 'Без проекта'
|
||||||
|
const color = matched?.color
|
||||||
|
const entry = byKey.get(key) ?? { name, expense: 0, color }
|
||||||
|
entry.expense += burn
|
||||||
|
byKey.set(key, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKey.entries())
|
||||||
|
.map(([key, row]) => ({ key, ...row, expense: Math.round(row.expense) }))
|
||||||
|
.filter((row) => row.expense > 0)
|
||||||
|
.sort((a, b) => b.expense - a.expense)
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latestPaymentDate(payments: Payment[]): string | null {
|
||||||
|
let latest: string | null = null
|
||||||
|
for (const p of payments) {
|
||||||
|
if (!latest || p.date > latest) latest = p.date
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paymentsForVpsIds(payments: Payment[], vpsIds: Set<string>): Payment[] {
|
||||||
|
return payments.filter((p) => p.vpsId && vpsIds.has(p.vpsId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectsOverview(snapshot: DataSnapshot, ctx: ProjectAnalyticsContext) {
|
||||||
|
const projects = (snapshot.serverProjects ?? []) as ServerProject[]
|
||||||
|
const assigned = snapshot.vps.filter((v) => !vpsHasNoProject(v))
|
||||||
|
const unassigned = snapshot.vps.length - assigned.length
|
||||||
|
const activeInProjects = assigned.filter((v) => v.status === 'active')
|
||||||
|
return {
|
||||||
|
projectCount: projects.length,
|
||||||
|
vpsInProjects: assigned.length,
|
||||||
|
vpsUnassigned: unassigned,
|
||||||
|
activeInProjects: activeInProjects.length,
|
||||||
|
monthlyBurnInProjects: sumVpsMonthlyBurn(activeInProjects, ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vpsByIdMap(vpsList: Vps[]): Map<string, Vps> {
|
||||||
|
return new Map(vpsList.map((v) => [v.id, v]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodLabel(period: ReportsPeriod): string {
|
||||||
|
switch (period) {
|
||||||
|
case '3m':
|
||||||
|
return '3 месяца'
|
||||||
|
case '6m':
|
||||||
|
return '6 месяцев'
|
||||||
|
case '12m':
|
||||||
|
return '12 месяцев'
|
||||||
|
default:
|
||||||
|
return 'Всё время'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paymentsInTrendWindow(payments: Payment[], period: ReportsPeriod): Payment[] {
|
||||||
|
const filtered = filterPaymentsByPeriod(payments, period)
|
||||||
|
if (period === 'all') {
|
||||||
|
const byMonth = new Map<string, number>()
|
||||||
|
for (const p of filtered) {
|
||||||
|
const key = monthKey(p.date)
|
||||||
|
if (key) byMonth.set(key, (byMonth.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const months = Array.from(byMonth.keys()).sort()
|
||||||
|
const last12 = months.slice(-12)
|
||||||
|
if (!last12.length) return filtered
|
||||||
|
const minMonth = last12[0]!
|
||||||
|
return filtered.filter((p) => {
|
||||||
|
const key = monthKey(p.date)
|
||||||
|
return key >= minMonth
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
@@ -114,13 +114,10 @@ export const settingsSchema = z.object({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectSchema = z.object({
|
export {
|
||||||
id: z.string().optional(),
|
projectFormSchema as projectSchema,
|
||||||
name: z.string().min(1, 'Укажите название проекта').max(120),
|
type ProjectFormValues,
|
||||||
color: z.string().optional().default(''),
|
} from '@cfdm/shared/contracts/project'
|
||||||
})
|
|
||||||
|
|
||||||
export type ProjectFormValues = z.infer<typeof projectSchema>
|
|
||||||
|
|
||||||
export type ProviderFormValues = z.infer<typeof providerSchema>
|
export type ProviderFormValues = z.infer<typeof providerSchema>
|
||||||
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
|||||||
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
||||||
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
||||||
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
|
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
|
||||||
|
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
@@ -111,6 +112,11 @@ const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
|
|||||||
path: '/$vpsId',
|
path: '/$vpsId',
|
||||||
getParentRoute: () => AuthVpsRoute,
|
getParentRoute: () => AuthVpsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
|
||||||
|
id: '/$projectId',
|
||||||
|
path: '/$projectId',
|
||||||
|
getParentRoute: () => AuthProjectsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -119,7 +125,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/balance': typeof AuthBalanceRoute
|
'/balance': typeof AuthBalanceRoute
|
||||||
'/dashboard': typeof AuthDashboardRoute
|
'/dashboard': typeof AuthDashboardRoute
|
||||||
'/payments': typeof AuthPaymentsRoute
|
'/payments': typeof AuthPaymentsRoute
|
||||||
'/projects': typeof AuthProjectsRoute
|
'/projects': typeof AuthProjectsRouteWithChildren
|
||||||
'/providers': typeof AuthProvidersRoute
|
'/providers': typeof AuthProvidersRoute
|
||||||
'/renewals': typeof AuthRenewalsRoute
|
'/renewals': typeof AuthRenewalsRoute
|
||||||
'/reports': typeof AuthReportsRoute
|
'/reports': typeof AuthReportsRoute
|
||||||
@@ -128,6 +134,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
'/vps': typeof AuthVpsRouteWithChildren
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -137,7 +144,7 @@ export interface FileRoutesByTo {
|
|||||||
'/balance': typeof AuthBalanceRoute
|
'/balance': typeof AuthBalanceRoute
|
||||||
'/dashboard': typeof AuthDashboardRoute
|
'/dashboard': typeof AuthDashboardRoute
|
||||||
'/payments': typeof AuthPaymentsRoute
|
'/payments': typeof AuthPaymentsRoute
|
||||||
'/projects': typeof AuthProjectsRoute
|
'/projects': typeof AuthProjectsRouteWithChildren
|
||||||
'/providers': typeof AuthProvidersRoute
|
'/providers': typeof AuthProvidersRoute
|
||||||
'/renewals': typeof AuthRenewalsRoute
|
'/renewals': typeof AuthRenewalsRoute
|
||||||
'/reports': typeof AuthReportsRoute
|
'/reports': typeof AuthReportsRoute
|
||||||
@@ -146,6 +153,7 @@ export interface FileRoutesByTo {
|
|||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
'/vps': typeof AuthVpsRouteWithChildren
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
@@ -157,7 +165,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/balance': typeof AuthBalanceRoute
|
'/_auth/balance': typeof AuthBalanceRoute
|
||||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||||
'/_auth/payments': typeof AuthPaymentsRoute
|
'/_auth/payments': typeof AuthPaymentsRoute
|
||||||
'/_auth/projects': typeof AuthProjectsRoute
|
'/_auth/projects': typeof AuthProjectsRouteWithChildren
|
||||||
'/_auth/providers': typeof AuthProvidersRoute
|
'/_auth/providers': typeof AuthProvidersRoute
|
||||||
'/_auth/renewals': typeof AuthRenewalsRoute
|
'/_auth/renewals': typeof AuthRenewalsRoute
|
||||||
'/_auth/reports': typeof AuthReportsRoute
|
'/_auth/reports': typeof AuthReportsRoute
|
||||||
@@ -166,6 +174,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||||
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
||||||
|
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
@@ -186,6 +195,7 @@ export interface FileRouteTypes {
|
|||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
|
| '/projects/$projectId'
|
||||||
| '/vps/$vpsId'
|
| '/vps/$vpsId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -204,6 +214,7 @@ export interface FileRouteTypes {
|
|||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
|
| '/projects/$projectId'
|
||||||
| '/vps/$vpsId'
|
| '/vps/$vpsId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
@@ -223,6 +234,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/sync-journal'
|
| '/_auth/sync-journal'
|
||||||
| '/_auth/tariffs'
|
| '/_auth/tariffs'
|
||||||
| '/_auth/vps'
|
| '/_auth/vps'
|
||||||
|
| '/_auth/projects/$projectId'
|
||||||
| '/_auth/vps/$vpsId'
|
| '/_auth/vps/$vpsId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
@@ -352,9 +364,28 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
||||||
parentRoute: typeof AuthVpsRoute
|
parentRoute: typeof AuthVpsRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/projects/$projectId': {
|
||||||
|
id: '/_auth/projects/$projectId'
|
||||||
|
path: '/$projectId'
|
||||||
|
fullPath: '/projects/$projectId'
|
||||||
|
preLoaderRoute: typeof AuthProjectsProjectIdRouteImport
|
||||||
|
parentRoute: typeof AuthProjectsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthProjectsRouteChildren {
|
||||||
|
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthProjectsRouteChildren: AuthProjectsRouteChildren = {
|
||||||
|
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthProjectsRouteWithChildren = AuthProjectsRoute._addFileChildren(
|
||||||
|
AuthProjectsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface AuthVpsRouteChildren {
|
interface AuthVpsRouteChildren {
|
||||||
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
|
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
@@ -372,7 +403,7 @@ interface AuthRouteChildren {
|
|||||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||||
AuthProjectsRoute: typeof AuthProjectsRoute
|
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
|
||||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||||
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
||||||
AuthReportsRoute: typeof AuthReportsRoute
|
AuthReportsRoute: typeof AuthReportsRoute
|
||||||
@@ -389,7 +420,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||||||
AuthBalanceRoute: AuthBalanceRoute,
|
AuthBalanceRoute: AuthBalanceRoute,
|
||||||
AuthDashboardRoute: AuthDashboardRoute,
|
AuthDashboardRoute: AuthDashboardRoute,
|
||||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||||
AuthProjectsRoute: AuthProjectsRoute,
|
AuthProjectsRoute: AuthProjectsRouteWithChildren,
|
||||||
AuthProvidersRoute: AuthProvidersRoute,
|
AuthProvidersRoute: AuthProvidersRoute,
|
||||||
AuthRenewalsRoute: AuthRenewalsRoute,
|
AuthRenewalsRoute: AuthRenewalsRoute,
|
||||||
AuthReportsRoute: AuthReportsRoute,
|
AuthReportsRoute: AuthReportsRoute,
|
||||||
|
|||||||
@@ -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 { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
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 { toast } from 'sonner'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { RowActions } from '@/components/row-actions'
|
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 { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||||
import type { ProjectFormValues } from '@/lib/schemas'
|
import type { ProjectFormValues } from '@/lib/schemas'
|
||||||
|
import { formatCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||||
interface ProjectRow {
|
import {
|
||||||
id: string
|
buildProjectRows,
|
||||||
name: string
|
projectsOverview,
|
||||||
color?: string | null
|
type ProjectRow,
|
||||||
vpsCount: number
|
} from '@/lib/project-analytics'
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/projects')({
|
export const Route = createFileRoute('/_auth/projects')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -31,16 +45,38 @@ export const Route = createFileRoute('/_auth/projects')({
|
|||||||
function ProjectsPage() {
|
function ProjectsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
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 [open, setOpen] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<ProjectFiltersState>(buildDefaultProjectFilters())
|
||||||
const [formDefaults, setFormDefaults] = useState<ProjectFormValues>(projectFormDefaults())
|
const [formDefaults, setFormDefaults] = useState<ProjectFormValues>(projectFormDefaults())
|
||||||
|
|
||||||
|
const analyticsCtx = useMemo(
|
||||||
|
() => ({
|
||||||
|
providers: snapshot?.providers ?? [],
|
||||||
|
settings: snapshot?.settings ?? [],
|
||||||
|
ratesData,
|
||||||
|
}),
|
||||||
|
[snapshot, ratesData],
|
||||||
|
)
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (values: ProjectFormValues) => {
|
mutationFn: (values: ProjectFormValues) => {
|
||||||
const color = values.color?.trim() || null
|
const color = values.color?.trim() || null
|
||||||
|
const notes = values.notes?.trim() || null
|
||||||
if (values.id) {
|
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: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
@@ -59,23 +95,34 @@ function ProjectsPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows: ProjectRow[] = useMemo(
|
const allRows = useMemo(
|
||||||
() =>
|
() => (snapshot ? buildProjectRows(snapshot, analyticsCtx) : []),
|
||||||
(snapshot?.serverProjects ?? []).map((p) => {
|
[snapshot, analyticsCtx],
|
||||||
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 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 = () => {
|
const openCreate = () => {
|
||||||
setFormDefaults(projectFormDefaults())
|
setFormDefaults(projectFormDefaults())
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (row: ProjectRow) => {
|
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)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +139,7 @@ function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 font-medium"
|
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}
|
{row.name}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -104,11 +151,34 @@ function ProjectsPage() {
|
|||||||
header: 'VPS',
|
header: 'VPS',
|
||||||
headerClassName: 'text-right',
|
headerClassName: 'text-right',
|
||||||
className: 'text-right tabular-nums',
|
className: 'text-right tabular-nums',
|
||||||
sortValue: (row) => row.vpsCount,
|
sortValue: (row) => row.vpsTotal,
|
||||||
cell: (row) => (
|
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',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -118,8 +188,8 @@ function ProjectsPage() {
|
|||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => openEdit(row)}
|
onEdit={() => openEdit(row)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
if (row.vpsCount > 0) {
|
if (row.vpsTotal > 0) {
|
||||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsCount} VPS`)
|
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delMut.mutate(row.id)
|
delMut.mutate(row.id)
|
||||||
@@ -134,19 +204,25 @@ function ProjectsPage() {
|
|||||||
return (
|
return (
|
||||||
<CrudListPage
|
<CrudListPage
|
||||||
title="Проекты"
|
title="Проекты"
|
||||||
description="Группировка VPS по проектам"
|
description="Группировка VPS, расходы и ресурсы по проектам"
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={openCreate}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<PlusIcon data-icon="inline-start" />
|
<Button variant="outline" render={<Link to="/reports" />}>
|
||||||
Добавить
|
<BarChart3Icon data-icon="inline-start" />
|
||||||
</Button>
|
Отчёты
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
data={snapshot}
|
data={snapshot}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
empty={rows.length === 0}
|
empty={allRows.length === 0}
|
||||||
emptyTitle="Проектов нет"
|
emptyTitle="Проектов нет"
|
||||||
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
||||||
emptyAction={
|
emptyAction={
|
||||||
@@ -166,12 +242,64 @@ function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<DataGridCard
|
<div className="flex flex-col gap-4">
|
||||||
columns={columnDefFromDataGrid(columns)}
|
{overview ? (
|
||||||
data={rows}
|
<SectionCards
|
||||||
rowId={(r) => r.id}
|
items={[
|
||||||
pinLastColumn
|
{
|
||||||
/>
|
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>
|
</CrudListPage>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,33 +1,100 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { DownloadIcon, TrendingUpIcon, CreditCardIcon, ServerIcon } from 'lucide-react'
|
import { DownloadIcon, TrendingUpIcon, CreditCardIcon, ServerIcon } from 'lucide-react'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { AnalyticsPage } from '@/components/analytics-page'
|
import { AnalyticsPage } from '@/components/analytics-page'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
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 { 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'
|
const reportsSearchSchema = z.object({
|
||||||
import { providerByIdMap } from '@/lib/billmanager'
|
project: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
period: z.enum(['3m', '6m', '12m', 'all']).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/reports')({
|
export const Route = createFileRoute('/_auth/reports')({
|
||||||
|
validateSearch: (search) => reportsSearchSchema.parse(search),
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
component: ReportsPage,
|
component: ReportsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ReportsPage() {
|
function ReportsPage() {
|
||||||
|
const search = Route.useSearch()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const settings = snapshot?.settings?.[0]
|
const settings = snapshot?.settings?.[0]
|
||||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
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 = () => {
|
const exportCsv = () => {
|
||||||
if (!snapshot) return
|
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(
|
exportVpsCsv(
|
||||||
snapshot.vps.map((v) => ({
|
filteredVps.map((v) => ({
|
||||||
ip: v.ip,
|
ip: v.ip,
|
||||||
project: v.project ?? '',
|
project: v.project ?? '',
|
||||||
status: v.status,
|
status: v.status,
|
||||||
@@ -37,16 +104,19 @@ function ReportsPage() {
|
|||||||
monthlyRate: v.monthlyRate ?? 0,
|
monthlyRate: v.monthlyRate ?? 0,
|
||||||
currency: v.currency,
|
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 (
|
return (
|
||||||
<AnalyticsPage
|
<AnalyticsPage
|
||||||
title="Отчёты"
|
title="Отчёты"
|
||||||
description="Расходы, платежи и динамика"
|
description="Расходы, платежи и динамика в разрезе проектов"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" onClick={exportCsv} disabled={!snapshot}>
|
<Button variant="outline" onClick={exportCsv} disabled={!snapshot || filteredVps.length === 0}>
|
||||||
<DownloadIcon data-icon="inline-start" />
|
<DownloadIcon data-icon="inline-start" />
|
||||||
Экспорт CSV
|
Экспорт CSV
|
||||||
</Button>
|
</Button>
|
||||||
@@ -64,47 +134,104 @@ function ReportsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(snap) => {
|
{(snap) => {
|
||||||
const providerById = providerByIdMap(snap.providers)
|
|
||||||
const baseCurrency = (snap.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
const baseCurrency = (snap.settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||||
const monthly = snap.vps.reduce(
|
const analyticsCtx = {
|
||||||
(acc, v) =>
|
providers: snap.providers,
|
||||||
acc + convertVpsMonthlyBurnToBase(v, providerById.get(v.providerId), snap.settings, ratesData),
|
settings: snap.settings,
|
||||||
0,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ReportsFiltersToolbar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
projects={projects}
|
||||||
|
shownVps={filteredVps.length}
|
||||||
|
totalVps={snap.vps.length}
|
||||||
|
/>
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: 'Расход/мес',
|
label: 'Расход/мес',
|
||||||
value: formatCurrency(monthly, baseCurrency),
|
value: formatCurrency(monthly, baseCurrency),
|
||||||
icon: <TrendingUpIcon className="size-4" />,
|
icon: <TrendingUpIcon className="size-4" />,
|
||||||
hint: `в ${baseCurrency}`,
|
hint:
|
||||||
|
filters.projectKeys.length > 0
|
||||||
|
? `в ${baseCurrency}, по фильтру`
|
||||||
|
: `в ${baseCurrency}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Платежей',
|
label: 'Платежей',
|
||||||
value: snap.payments.length,
|
value: filteredPayments.length,
|
||||||
icon: <CreditCardIcon className="size-4" />,
|
icon: <CreditCardIcon className="size-4" />,
|
||||||
|
hint:
|
||||||
|
filters.projectKeys.length > 0
|
||||||
|
? 'только с привязкой к VPS проекта'
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Активных VPS',
|
label: 'Активных VPS',
|
||||||
value: snap.vps.filter((v) => v.status === 'active').length,
|
value: filteredVps.filter((v) => v.status === 'active').length,
|
||||||
icon: <ServerIcon className="size-4" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
hint: `из ${snap.vps.length}`,
|
hint: `из ${filteredVps.length}`,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<ChartsGrid>
|
<ChartsGrid>
|
||||||
|
<ProjectExpenseChart
|
||||||
|
vps={filteredVps}
|
||||||
|
projects={projects}
|
||||||
|
providers={snap.providers}
|
||||||
|
settings={snap.settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
<MonthlyExpenseChart
|
<MonthlyExpenseChart
|
||||||
vps={snap.vps}
|
vps={filteredVps}
|
||||||
providers={snap.providers}
|
providers={snap.providers}
|
||||||
providerAccounts={snap.providerAccounts}
|
providerAccounts={snap.providerAccounts}
|
||||||
settings={snap.settings}
|
settings={snap.settings}
|
||||||
ratesData={ratesData}
|
ratesData={ratesData}
|
||||||
|
title={expenseTitle}
|
||||||
|
/>
|
||||||
|
<PaymentsPieChart
|
||||||
|
payments={filteredPayments}
|
||||||
|
settings={snap.settings}
|
||||||
|
ratesData={ratesData}
|
||||||
/>
|
/>
|
||||||
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
|
|
||||||
<MonthlyTrendChart
|
<MonthlyTrendChart
|
||||||
payments={snap.payments}
|
payments={trendPayments}
|
||||||
settings={snap.settings}
|
settings={snap.settings}
|
||||||
ratesData={ratesData}
|
ratesData={ratesData}
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface Vps {
|
|||||||
purpose?: string
|
purpose?: string
|
||||||
environment?: 'prod' | 'dev' | 'staging'
|
environment?: 'prod' | 'dev' | 'staging'
|
||||||
project?: string
|
project?: string
|
||||||
|
projectId?: string
|
||||||
monitoringEnabled?: boolean
|
monitoringEnabled?: boolean
|
||||||
backupEnabled?: boolean
|
backupEnabled?: boolean
|
||||||
status: VpsStatus
|
status: VpsStatus
|
||||||
@@ -189,6 +190,15 @@ export interface NotificationLogRow {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerProject {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color?: string | null
|
||||||
|
sortOrder?: number
|
||||||
|
notes?: string | null
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataSnapshot {
|
export interface DataSnapshot {
|
||||||
vps: Vps[]
|
vps: Vps[]
|
||||||
providers: Provider[]
|
providers: Provider[]
|
||||||
@@ -198,6 +208,6 @@ export interface DataSnapshot {
|
|||||||
settings: Settings[]
|
settings: Settings[]
|
||||||
activeTariffs: ActiveTariff[]
|
activeTariffs: ActiveTariff[]
|
||||||
tariffSyncOptions?: unknown[]
|
tariffSyncOptions?: unknown[]
|
||||||
serverProjects?: unknown[]
|
serverProjects?: ServerProject[]
|
||||||
syncLog: SyncLogRow[]
|
syncLog: SyncLogRow[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export function getProjectNameById(id: string): string {
|
|||||||
return row?.name ?? ''
|
return row?.name ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countVpsByProjectId(projectId: string): number {
|
||||||
|
const row = getDb()
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(schema.vps)
|
||||||
|
.where(eq(schema.vps.projectId, projectId))
|
||||||
|
.get()
|
||||||
|
return Number(row?.count ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
export const projectsRepository = {
|
export const projectsRepository = {
|
||||||
list(): (typeof schema.serverProjects.$inferSelect)[] {
|
list(): (typeof schema.serverProjects.$inferSelect)[] {
|
||||||
return getDb()
|
return getDb()
|
||||||
@@ -78,6 +87,16 @@ export const projectsRepository = {
|
|||||||
.orderBy(asc(schema.serverProjects.name))
|
.orderBy(asc(schema.serverProjects.name))
|
||||||
.all()
|
.all()
|
||||||
},
|
},
|
||||||
|
get(id: string): (typeof schema.serverProjects.$inferSelect) | undefined {
|
||||||
|
return getDb()
|
||||||
|
.select()
|
||||||
|
.from(schema.serverProjects)
|
||||||
|
.where(eq(schema.serverProjects.id, id))
|
||||||
|
.get()
|
||||||
|
},
|
||||||
|
getDependencyCounts(id: string): { vps: number } {
|
||||||
|
return { vps: countVpsByProjectId(id) }
|
||||||
|
},
|
||||||
create(input: { name: string; color?: string | null; notes?: string | null }) {
|
create(input: { name: string; color?: string | null; notes?: string | null }) {
|
||||||
const id = `proj-${randomUUID()}`
|
const id = `proj-${randomUUID()}`
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
@@ -92,28 +111,47 @@ export const projectsRepository = {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
return this.list().find((p) => p.id === id)!
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
createOrResolve(input: { name: string; color?: string | null; notes?: string | null }) {
|
||||||
|
const existing = findProjectByNameCaseInsensitive(input.name)
|
||||||
|
if (existing) {
|
||||||
|
const hasMeta = input.color !== undefined || input.notes !== undefined
|
||||||
|
if (!hasMeta) return existing
|
||||||
|
return (
|
||||||
|
this.update(existing.id, {
|
||||||
|
...(input.color !== undefined ? { color: input.color } : {}),
|
||||||
|
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||||
|
}) ?? existing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.create(input)
|
||||||
},
|
},
|
||||||
update(
|
update(
|
||||||
id: string,
|
id: string,
|
||||||
input: Partial<{ name: string; color: string | null; notes: string | null }>,
|
input: Partial<{ name: string; color: string | null; notes: string | null }>,
|
||||||
) {
|
) {
|
||||||
const existing = getDb()
|
const existing = this.get(id)
|
||||||
.select()
|
|
||||||
.from(schema.serverProjects)
|
|
||||||
.where(eq(schema.serverProjects.id, id))
|
|
||||||
.get()
|
|
||||||
if (!existing) return undefined
|
if (!existing) return undefined
|
||||||
getDb()
|
const nextName = input.name ?? existing.name
|
||||||
.update(schema.serverProjects)
|
const db = getDb()
|
||||||
.set({
|
db.transaction(() => {
|
||||||
name: input.name ?? existing.name,
|
db.update(schema.serverProjects)
|
||||||
color: input.color ?? existing.color,
|
.set({
|
||||||
notes: input.notes ?? existing.notes,
|
name: nextName,
|
||||||
})
|
color: input.color !== undefined ? input.color : existing.color,
|
||||||
.where(eq(schema.serverProjects.id, id))
|
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||||
.run()
|
})
|
||||||
return getDb().select().from(schema.serverProjects).where(eq(schema.serverProjects.id, id)).get()
|
.where(eq(schema.serverProjects.id, id))
|
||||||
|
.run()
|
||||||
|
if (nextName !== existing.name) {
|
||||||
|
db.update(schema.vps)
|
||||||
|
.set({ project: nextName })
|
||||||
|
.where(eq(schema.vps.projectId, id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this.get(id)
|
||||||
},
|
},
|
||||||
delete(id: string): boolean {
|
delete(id: string): boolean {
|
||||||
const r = getDb().delete(schema.serverProjects).where(eq(schema.serverProjects.id, id)).run()
|
const r = getDb().delete(schema.serverProjects).where(eq(schema.serverProjects.id, id)).run()
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ const TABLE_MIGRATIONS: string[] = [
|
|||||||
lastSentAt TEXT,
|
lastSentAt TEXT,
|
||||||
lastStatus TEXT
|
lastStatus TEXT
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS server_projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
createdAt TEXT
|
||||||
|
)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
let migrated = false
|
let migrated = false
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ CREATE TABLE IF NOT EXISTS notification_state (
|
|||||||
lastSentAt TEXT,
|
lastSentAt TEXT,
|
||||||
lastStatus TEXT
|
lastStatus TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
createdAt TEXT
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
export function resetTestDb(): void {
|
export function resetTestDb(): void {
|
||||||
@@ -198,3 +207,10 @@ export function seedTestProvider(id = 'prov-1'): void {
|
|||||||
)
|
)
|
||||||
.run(id)
|
.run(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function seedTestProviderAccount(id = 'acc-1', providerId = 'prov-1'): void {
|
||||||
|
const sqlite = getSqlite()
|
||||||
|
sqlite
|
||||||
|
.prepare(`INSERT INTO provider_accounts (id, providerId, name) VALUES (?, ?, 'Test Account')`)
|
||||||
|
.run(id, providerId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const projectSchema = z.object({
|
export const serverProjectSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название проекта').max(120),
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string().nullable().optional(),
|
||||||
|
sortOrder: z.number().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ProjectFormValues = z.infer<typeof projectSchema>
|
export type ServerProject = z.infer<typeof serverProjectSchema>
|
||||||
|
|
||||||
|
export const projectFormSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
name: z.string().min(1, 'Укажите название проекта').max(120),
|
||||||
|
color: z.string().optional().default(''),
|
||||||
|
notes: z.string().optional().default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ProjectFormValues = z.infer<typeof projectFormSchema>
|
||||||
|
|
||||||
|
/** @deprecated use projectFormSchema */
|
||||||
|
export const projectSchema = projectFormSchema.pick({ name: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user