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 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 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(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[]>( () => [ { accessorKey: 'title', header: 'Сделка', cell: ({ row }) => (

{row.original.title}

Контакт #{row.original.contact_id}

), }, { accessorKey: 'amount', header: 'Сумма', cell: ({ row }) => (

{formatCurrency(row.original.amount, row.original.currency ?? 'USD')}

{row.original.currency ?? '—'}

), }, { accessorKey: 'status', header: 'Статус', cell: ({ row }) => , }, { accessorKey: 'stage', header: 'Этап', cell: ({ row }) => , }, { accessorKey: 'owner_id', header: 'Владелец', cell: ({ row }) => Сотрудник #{row.original.owner_id}, }, { accessorKey: 'updated_at', header: 'Обновлена', cell: ({ row }) => {formatDate(row.original.updated_at)}, }, { id: 'actions', header: '', cell: ({ row }) => ( ), }, ], [handleEditDeal], ) return (

Сделки

Следите за воронкой продаж и обновляйте статусы в один клик.

Воронка по этапам Количество и сумма сделок на каждом этапе. } cursor={{ fill: 'transparent' }} /> setCreateOpen(true)}> + Новая сделка } > setOwnerFilter(event.target.value)} className="w-[140px]" />
setMinAmount(event.target.value)} className="w-[130px]" /> setMaxAmount(event.target.value)} className="w-[130px]" />
} /> { 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' }) } }} /> { 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' }) } }} />
) } interface StatCardProps { title: string value: string | number description: string } const StatCard = ({ title, value, description }: StatCardProps) => ( {title} {description}

{value}

) 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 (

{data.stage}

Сделок: {data.count}

Сумма: {formatCurrency(data.value)}

) } interface DealCreateDrawerProps { open: boolean onOpenChange: (open: boolean) => void onSubmit: (values: DealCreateFormValues) => Promise 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({ 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 ( Новая сделка Заполните данные для создания сделки.
( Название )} /> ( Контакт {contacts.length ? 'Контакт будет связан со сделкой.' : 'Сначала создайте контакт в разделе «Контакты».'} )} /> ( Сумма )} /> ( Валюта )} /> ( Владелец (необязательно) При отсутствии выбора сделка будет закреплена за вами. )} />
) } interface DealUpdateDrawerProps { deal: Deal | null open: boolean onOpenChange: (open: boolean) => void onSubmit: (values: DealUpdateFormValues) => Promise isSubmitting: boolean } const DealUpdateDrawer = ({ deal, open, onOpenChange, onSubmit, isSubmitting }: DealUpdateDrawerProps) => { const form = useForm({ 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 ( Обновление сделки Измените статус, этап или сумму.
( Статус )} /> ( Этап )} /> ( Сумма )} /> ( Валюта )} />
) } export default DealsPage