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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 19:21:04 +07:00
co-authored by Cursor
parent 0bcea39868
commit 447d499a8d
19 changed files with 1561 additions and 100 deletions
+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>
</>
)
}}
@@ -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())}
/>
)
}