feat(projects): переработать проекты и отчётность по проектам
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Добавлены карточка проекта, KPI и фильтры на /projects, отчёты с разрезом по проектам, cascade rename и защита удаления на API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user