test_task_crm/frontend/src/pages/deals/deals-page.tsx

631 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { zodResolver } from '@hookform/resolvers/zod'
import { type ColumnDef } from '@tanstack/react-table'
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
import { DealStageBadge, dealStageLabels } from '@/components/crm/deal-stage-badge'
import { DealStatusBadge, dealStatusLabels } from '@/components/crm/deal-status-badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { useToast } from '@/components/ui/use-toast'
import { useContactsQuery } from '@/features/contacts/hooks'
import { useCreateDealMutation, useDealsQuery, useUpdateDealMutation } from '@/features/deals/hooks'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { Contact, Deal, DealStage, DealStatus } from '@/types/crm'
const dealStatusList: DealStatus[] = ['new', 'in_progress', 'won', 'lost']
const dealStageList: DealStage[] = ['qualification', 'proposal', 'negotiation', 'closed']
const dealCreateSchema = z.object({
title: z.string().min(3, 'Минимум 3 символа'),
contactId: z.string().min(1, 'Введите ID контакта'),
amount: z.string().optional(),
currency: z.string().min(3).max(3).optional(),
ownerId: z.string().optional(),
})
type DealCreateFormValues = z.infer<typeof dealCreateSchema>
const dealUpdateSchema = z.object({
status: z.enum(dealStatusList),
stage: z.enum(dealStageList),
amount: z.string().optional(),
currency: z.string().min(3).max(3).optional(),
})
type DealUpdateFormValues = z.infer<typeof dealUpdateSchema>
const mapAmount = (value?: string | null) => {
if (!value) return 0
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
const DealsPage = () => {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | DealStatus>('all')
const [stageFilter, setStageFilter] = useState<'all' | DealStage>('all')
const [ownerFilter, setOwnerFilter] = useState('')
const [minAmount, setMinAmount] = useState('')
const [maxAmount, setMaxAmount] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [dealToEdit, setDealToEdit] = useState<Deal | null>(null)
const { toast } = useToast()
const filters = useMemo(() => {
const ownerNumber = ownerFilter ? Number(ownerFilter) : undefined
const min = minAmount ? Number(minAmount) : undefined
const max = maxAmount ? Number(maxAmount) : undefined
return {
status: statusFilter === 'all' ? undefined : [statusFilter],
stage: stageFilter === 'all' ? undefined : stageFilter,
ownerId: Number.isFinite(ownerNumber) ? ownerNumber : undefined,
minAmount: Number.isFinite(min) ? min : undefined,
maxAmount: Number.isFinite(max) ? max : undefined,
}
}, [statusFilter, stageFilter, ownerFilter, minAmount, maxAmount])
const { data: deals = [], isLoading } = useDealsQuery(filters)
const { data: contacts = [], isLoading: contactsLoading } = useContactsQuery({ pageSize: 100 })
const filteredDeals = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return deals
return deals.filter((deal) => deal.title.toLowerCase().includes(query))
}, [deals, search])
const createDeal = useCreateDealMutation()
const updateDeal = useUpdateDealMutation()
const ownerOptions = useMemo(
() =>
Array.from(
new Set(
[
...deals.map((deal) => deal.owner_id),
...contacts.map((contact) => contact.owner_id).filter((id): id is number => typeof id === 'number'),
],
),
)
.filter((id) => id !== undefined && id !== null)
.map((id) => ({ value: String(id), label: `Сотрудник #${id}` })),
[contacts, deals],
)
const stats = useMemo(() => {
const total = deals.length
const pipeline = deals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
const won = deals.filter((deal) => deal.status === 'won')
const wonAmount = won.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
const conversion = total ? Math.round((won.length / total) * 100) : 0
return { total, pipeline, wonAmount, conversion }
}, [deals])
const stageChartData = useMemo(
() =>
dealStageList.map((stage) => {
const stageDeals = deals.filter((deal) => deal.stage === stage)
return {
stage: dealStageLabels[stage],
count: stageDeals.length,
value: stageDeals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0),
}
}),
[deals],
)
const handleEditDeal = useCallback((deal: Deal) => setDealToEdit(deal), [])
const columns = useMemo<ColumnDef<Deal>[]>(
() => [
{
accessorKey: 'title',
header: 'Сделка',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.title}</p>
<p className="text-xs text-muted-foreground">Контакт #{row.original.contact_id}</p>
</div>
),
},
{
accessorKey: 'amount',
header: 'Сумма',
cell: ({ row }) => (
<div>
<p className="font-medium">{formatCurrency(row.original.amount, row.original.currency ?? 'USD')}</p>
<p className="text-xs text-muted-foreground">{row.original.currency ?? '—'}</p>
</div>
),
},
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => <DealStatusBadge status={row.original.status} />,
},
{
accessorKey: 'stage',
header: 'Этап',
cell: ({ row }) => <DealStageBadge stage={row.original.stage} />,
},
{
accessorKey: 'owner_id',
header: 'Владелец',
cell: ({ row }) => <span className="text-sm text-muted-foreground">Сотрудник #{row.original.owner_id}</span>,
},
{
accessorKey: 'updated_at',
header: 'Обновлена',
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.updated_at)}</span>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button variant="ghost" size="sm" onClick={() => handleEditDeal(row.original)}>
Обновить
</Button>
),
},
],
[handleEditDeal],
)
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-semibold text-foreground">Сделки</h1>
<p className="text-sm text-muted-foreground">Следите за воронкой продаж и обновляйте статусы в один клик.</p>
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard title="Всего сделок" value={stats.total} description="Количество в текущем списке" />
<StatCard title="Потенциал" value={formatCurrency(stats.pipeline)} description="Суммарный pipeline" />
<StatCard title="Выиграно" value={formatCurrency(stats.wonAmount)} description="Сумма выигранных сделок" />
<StatCard title="Конверсия" value={`${stats.conversion}%`} description="Выиграно от всех" />
</section>
<Card>
<CardHeader>
<CardTitle>Воронка по этапам</CardTitle>
<CardDescription>Количество и сумма сделок на каждом этапе.</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stageChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="stage" tick={{ fill: 'currentColor', fontSize: 12 }} />
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} />
<Tooltip content={<StageTooltip />} cursor={{ fill: 'transparent' }} />
<Bar dataKey="count" name="Сделки" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<DataTable
columns={columns}
data={filteredDeals}
isLoading={isLoading}
renderToolbar={
<DataTableToolbar
searchPlaceholder="Поиск по названию"
searchValue={search}
onSearchChange={setSearch}
actions={
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
+ Новая сделка
</Button>
}
>
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
<SelectTrigger className="w-[180px] bg-background">
<SelectValue placeholder="Статус" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все статусы</SelectItem>
{dealStatusList.map((status) => (
<SelectItem key={status} value={status}>
{dealStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stageFilter} onValueChange={(value) => setStageFilter(value as typeof stageFilter)}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Этап" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все этапы</SelectItem>
{dealStageList.map((stage) => (
<SelectItem key={stage} value={stage}>
{dealStageLabels[stage]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
placeholder="ID владельца"
value={ownerFilter}
onChange={(event) => setOwnerFilter(event.target.value)}
className="w-[140px]"
/>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="Мин сумма"
value={minAmount}
onChange={(event) => setMinAmount(event.target.value)}
className="w-[130px]"
/>
<Input
type="number"
placeholder="Макс сумма"
value={maxAmount}
onChange={(event) => setMaxAmount(event.target.value)}
className="w-[130px]"
/>
</div>
</DataTableToolbar>
}
/>
<DealCreateDrawer
open={createOpen}
onOpenChange={setCreateOpen}
isSubmitting={createDeal.isPending}
contacts={contacts}
contactsLoading={contactsLoading}
ownerOptions={ownerOptions}
onSubmit={async (values) => {
const payload = {
title: values.title,
contact_id: Number(values.contactId),
amount: values.amount ? Number(values.amount) : undefined,
currency: values.currency ? values.currency.toUpperCase() : undefined,
owner_id: values.ownerId && values.ownerId !== 'auto' ? Number(values.ownerId) : undefined,
}
try {
await createDeal.mutateAsync(payload)
toast({ title: 'Сделка создана', description: 'Запись появилась в воронке.' })
setCreateOpen(false)
} catch (error) {
toast({ title: 'Не удалось создать сделку', description: error instanceof Error ? error.message : 'Попробуйте снова', variant: 'destructive' })
}
}}
/>
<DealUpdateDrawer
deal={dealToEdit}
open={Boolean(dealToEdit)}
onOpenChange={(open) => {
if (!open) setDealToEdit(null)
}}
isSubmitting={updateDeal.isPending}
onSubmit={async (values) => {
if (!dealToEdit) return
const payload = {
status: values.status,
stage: values.stage,
amount: values.amount ? Number(values.amount) : undefined,
currency: values.currency ? values.currency.toUpperCase() : undefined,
}
try {
await updateDeal.mutateAsync({ dealId: dealToEdit.id, payload })
toast({ title: 'Сделка обновлена', description: 'Статус и этап сохранены.' })
setDealToEdit(null)
} catch (error) {
toast({ title: 'Ошибка обновления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
}
}}
/>
</div>
)
}
interface StatCardProps {
title: string
value: string | number
description: string
}
const StatCard = ({ title, value, description }: StatCardProps) => (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{value}</p>
</CardContent>
</Card>
)
const StageTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { stage: string; count: number; value: number } }> }) => {
if (!active || !payload?.length) return null
const [{ payload: data }] = payload
return (
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
<p className="font-semibold">{data.stage}</p>
<p>Сделок: {data.count}</p>
<p>Сумма: {formatCurrency(data.value)}</p>
</div>
)
}
interface DealCreateDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: DealCreateFormValues) => Promise<void>
isSubmitting: boolean
contacts: Contact[]
contactsLoading: boolean
ownerOptions: Array<{ value: string; label: string }>
}
const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting, contacts, contactsLoading, ownerOptions }: DealCreateDrawerProps) => {
const form = useForm<DealCreateFormValues>({
resolver: zodResolver(dealCreateSchema),
defaultValues: { title: '', contactId: '', amount: '', currency: 'USD', ownerId: 'auto' },
})
const handleSubmit = async (values: DealCreateFormValues) => {
await onSubmit(values)
form.reset({ title: '', contactId: '', amount: '', currency: 'USD', ownerId: 'auto' })
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-lg">
<SheetHeader>
<SheetTitle>Новая сделка</SheetTitle>
<SheetDescription>Заполните данные для создания сделки.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Название</FormLabel>
<FormControl>
<Input placeholder="Реставрация сайта" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactId"
render={({ field }) => (
<FormItem>
<FormLabel>Контакт</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={contactsLoading || !contacts.length}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={contactsLoading ? 'Загружаем контакты…' : 'Выберите контакт'} />
</SelectTrigger>
</FormControl>
<SelectContent>
{contacts.map((contact) => (
<SelectItem key={contact.id} value={String(contact.id)}>
{contact.name} · #{contact.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{contacts.length ? 'Контакт будет связан со сделкой.' : 'Сначала создайте контакт в разделе «Контакты».'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Сумма</FormLabel>
<FormControl>
<Input type="number" placeholder="10000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Валюта</FormLabel>
<FormControl>
<Input placeholder="USD" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ownerId"
render={({ field }) => (
<FormItem>
<FormLabel>Владелец (необязательно)</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={ownerOptions.length ? 'Выберите владельца' : 'Только автоназначение'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Назначить автоматически</SelectItem>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>При отсутствии выбора сделка будет закреплена за вами.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Создаём…' : 'Создать'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
interface DealUpdateDrawerProps {
deal: Deal | null
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: DealUpdateFormValues) => Promise<void>
isSubmitting: boolean
}
const DealUpdateDrawer = ({ deal, open, onOpenChange, onSubmit, isSubmitting }: DealUpdateDrawerProps) => {
const form = useForm<DealUpdateFormValues>({
resolver: zodResolver(dealUpdateSchema),
defaultValues: { status: 'new', stage: 'qualification', amount: '', currency: 'USD' },
})
useEffect(() => {
if (deal) {
form.reset({
status: deal.status,
stage: deal.stage,
amount: deal.amount ?? '',
currency: deal.currency ?? 'USD',
})
}
}, [deal, form])
if (!deal) return null
const handleSubmit = async (values: DealUpdateFormValues) => {
await onSubmit(values)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-lg">
<SheetHeader>
<SheetTitle>Обновление сделки</SheetTitle>
<SheetDescription>Измените статус, этап или сумму.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Статус</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите статус" />
</SelectTrigger>
</FormControl>
<SelectContent>
{dealStatusList.map((status) => (
<SelectItem key={status} value={status}>
{dealStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stage"
render={({ field }) => (
<FormItem>
<FormLabel>Этап</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите этап" />
</SelectTrigger>
</FormControl>
<SelectContent>
{dealStageList.map((stage) => (
<SelectItem key={stage} value={stage}>
{dealStageLabels[stage]}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Сумма</FormLabel>
<FormControl>
<Input type="number" placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Валюта</FormLabel>
<FormControl>
<Input placeholder="USD" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохраняем…' : 'Сохранить'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
export default DealsPage