feat(projects): переработать проекты и отчётность по проектам
Docker / build (push) Failing after 20s

Добавлены карточка проекта, KPI и фильтры на /projects, отчёты с разрезом по проектам, cascade rename и защита удаления на API.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-06-29 19:21:04 +07:00
1 parent 0bcea39868
commit 447d499a8d
19 files changed
+1561 -100

No files matched your search

+82 -3
View File
@@ -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 (
<Card className={className}>
<CardHeader>
<CardTitle>Расходы по хостерам (мес)</CardTitle>
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
<CardTitle>{title}</CardTitle>
<CardDescription>{description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
@@ -228,3 +233,77 @@ export function MonthlyTrendChart({
export function ChartsGrid({ children }: { children: ReactNode }) {
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 { 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<ProjectFormValues> | 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({
)}
/>
</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>
</>
)
}}