diff --git a/apps/api/src/routes/projects.test.ts b/apps/api/src/routes/projects.test.ts new file mode 100644 index 0000000..cc7c7d7 --- /dev/null +++ b/apps/api/src/routes/projects.test.ts @@ -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> + + 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() + }) +}) diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index ac1a448..d477441 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -21,11 +21,17 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => { }) 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) { 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) => { @@ -46,10 +52,21 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => { }) app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { - const ok = projectsRepository.delete(req.params.id) - if (!ok) { + const existing = projectsRepository.get(req.params.id) + if (!existing) { 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() }) } diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx index e8650dc..805b99c 100644 --- a/apps/web/src/components/domain/charts.tsx +++ b/apps/web/src/components/domain/charts.tsx @@ -20,7 +20,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm import type { ReactNode } 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 { canonicalPaymentType, convertCurrency, @@ -31,6 +31,7 @@ import { toIsoCurrency, } from '@/lib/format' import { providerByIdMap } from '@/lib/billmanager' +import { aggregateBurnByProject } from '@/lib/project-analytics' import { EmptyState } from '@/components/empty-state' function ChartEmpty({ message }: { message: string }) { @@ -48,6 +49,8 @@ export function MonthlyExpenseChart({ settings, ratesData, className, + title = 'Расходы по хостерам (мес)', + description, }: { vps: Vps[] providers: Provider[] @@ -55,6 +58,8 @@ export function MonthlyExpenseChart({ settings: Settings[] ratesData: RatesData | null className?: string + title?: string + description?: string }) { const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() const providerById = providerByIdMap(providers) @@ -79,8 +84,8 @@ export function MonthlyExpenseChart({ return ( - Расходы по хостерам (мес) - Топ-10 по monthly rate, в {baseCurrency} + {title} + {description ?? `Топ-10 по monthly rate, в ${baseCurrency}`} {data.length === 0 ? ( @@ -228,3 +233,77 @@ export function MonthlyTrendChart({ export function ChartsGrid({ children }: { children: ReactNode }) { return
{children}
} + +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 ( + + + Расходы по проектам (мес) + Активные VPS, в {baseCurrency} + + + {data.length === 0 ? ( + + ) : ( + + + + + + formatCurrency(Number(v), baseCurrency)} /> + } + /> + + {data.map((row) => ( + + ))} + + + + )} + + + ) +} diff --git a/apps/web/src/components/domain/project-edit-sheet.tsx b/apps/web/src/components/domain/project-edit-sheet.tsx index 87c6624..d5d0cf2 100644 --- a/apps/web/src/components/domain/project-edit-sheet.tsx +++ b/apps/web/src/components/domain/project-edit-sheet.tsx @@ -3,10 +3,11 @@ import { Controller } from 'react-hook-form' import { FormSheetRhf } from '@/components/form-sheet-rhf' import { FormField } from '@/components/form-field' import { Input } from '@cfdm/ui/components/input' +import { Textarea } from '@cfdm/ui/components/textarea' import { ColorPicker } from '@/components/reui/color-picker' import { projectSchema, type ProjectFormValues } from '@/lib/schemas' -const EMPTY: ProjectFormValues = { name: '', color: '' } +const EMPTY: ProjectFormValues = { name: '', color: '', notes: '' } interface ProjectEditSheetProps { open: boolean @@ -18,7 +19,7 @@ interface ProjectEditSheetProps { export function projectFormDefaults(edit?: Partial | null): ProjectFormValues { if (!edit) return { ...EMPTY } - return { ...EMPTY, ...edit, color: edit.color ?? '' } + return { ...EMPTY, ...edit, color: edit.color ?? '', notes: edit.notes ?? '' } } export function ProjectEditSheet({ @@ -73,6 +74,19 @@ export function ProjectEditSheet({ )} /> + +