feat: add deals, tasks, and organizations pages with CRUD functionality

- Implemented DealsPage with deal creation, updating, and filtering features.
- Added OrganizationsPage to manage and switch between organizations.
- Created TasksPage for task management, including task creation and filtering.
- Updated router to include new pages for navigation.
This commit is contained in:
Artem Kashaev
2025-12-01 13:46:56 +05:00
parent 4fe3d0480e
commit 8718df9686
21 changed files with 1917 additions and 1 deletions
@@ -0,0 +1,206 @@
import { useMemo, useState } from 'react'
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'
import { dealStageLabels } from '@/components/crm/deal-stage-badge'
import { dealStatusLabels } from '@/components/crm/deal-status-badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useDealFunnelQuery, useDealSummaryQuery } from '@/features/analytics/hooks'
import { formatCurrency } from '@/lib/utils'
import type { DealFunnelResponse, DealStatus } from '@/types/crm'
const dayOptions = [7, 14, 30, 60, 90, 120]
const statusColors: Record<DealStatus, string> = {
new: '#fbbf24',
in_progress: '#38bdf8',
won: '#22c55e',
lost: '#f87171',
}
const parseDecimal = (value?: string | number | null) => {
if (value === null || value === undefined) return 0
const num = typeof value === 'string' ? Number(value) : value
return Number.isFinite(num) ? num : 0
}
const AnalyticsPage = () => {
const [days, setDays] = useState(30)
const summaryQuery = useDealSummaryQuery(days)
const funnelQuery = useDealFunnelQuery()
const summary = summaryQuery.data
const funnel = funnelQuery.data
const statusChartData = useMemo(
() =>
summary?.by_status.map((item) => ({
status: dealStatusLabels[item.status],
count: item.count,
amount: parseDecimal(item.amount_sum),
rawStatus: item.status,
})) ?? [],
[summary],
)
const funnelChartData = useMemo(() => buildFunnelChartData(funnel), [funnel])
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Аналитика сделок</h1>
<p className="text-sm text-muted-foreground">Сводка по статусам и этапам с конверсией и динамикой.</p>
</div>
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Период" />
</SelectTrigger>
<SelectContent>
{dayOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
Последние {option} дней
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary ? (
<>
<SummaryCard title="Сделок в работе" primary={summary.total_deals} secondary={`${summary.new_deals.count} за ${summary.new_deals.days} дн.`} />
<SummaryCard title="Выиграно" primary={formatCurrency(summary.won.amount_sum)} secondary={`Средний чек ${formatCurrency(summary.won.average_amount)}`} />
<SummaryCard title="По статусам" primary={summary.by_status.reduce((acc, item) => acc + item.count, 0)} secondary="Всего записей" />
<SummaryCard title="Активность" primary={`${summary.new_deals.count}`} secondary={`Новых за ${summary.new_deals.days} дн.`} />
</>
) : (
<SummarySkeleton />
)}
</section>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Статусы сделок</CardTitle>
<CardDescription>Количество сделок и суммы по каждому статусу.</CardDescription>
</CardHeader>
<CardContent className="h-[320px]">
{summaryQuery.isLoading ? (
<Skeleton className="h-full w-full rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={statusChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="status" tick={{ fill: 'currentColor', fontSize: 12 }} />
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} allowDecimals={false} />
<Tooltip content={<StatusTooltip />} cursor={{ fill: 'transparent' }} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} name="Сделки" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Воронка продаж</CardTitle>
<CardDescription>Распределение сделок по этапам и статусам.</CardDescription>
</CardHeader>
<CardContent className="h-[320px]">
{funnelQuery.isLoading ? (
<Skeleton className="h-full w-full rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={funnelChartData} stackOffset="expand">
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="stage" tick={{ fill: 'currentColor', fontSize: 12 }} />
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} tickFormatter={(value) => `${Math.round(value * 100)}%`} />
<Tooltip content={<FunnelTooltip />} cursor={{ fill: 'transparent' }} />
<Legend />
{(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => (
<Bar key={status} dataKey={status} stackId="status" fill={statusColors[status]} name={dealStatusLabels[status]} />
))}
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</div>
)
}
const SummaryCard = ({ title, primary, secondary }: { title: string; primary: string | number; secondary: string }) => (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{secondary}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{primary}</p>
</CardContent>
</Card>
)
const SummarySkeleton = () => (
<>
{[...Array(4)].map((_, index) => (
<Card key={index}>
<CardHeader>
<Skeleton className="h-3 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-32" />
</CardContent>
</Card>
))}
</>
)
const StatusTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { status: string; count: number; amount: 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.status}</p>
<p>Сделок: {data.count}</p>
<p>Сумма: {formatCurrency(data.amount)}</p>
</div>
)
}
const FunnelTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: Record<string, 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>
{(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => (
<p key={status}>
{dealStatusLabels[status]}: {Math.round((data[status] || 0) * 100)}%
</p>
))}
</div>
)
}
const buildFunnelChartData = (funnel?: DealFunnelResponse) => {
if (!funnel) return []
return funnel.stages.map((stage) => {
const total = stage.total || 1
return {
stage: dealStageLabels[stage.stage],
...(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).reduce(
(acc, status) => ({
...acc,
[status]: (stage.by_status[status] ?? 0) / total,
}),
{},
),
}
})
}
export default AnalyticsPage
@@ -0,0 +1,311 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { type ColumnDef } from '@tanstack/react-table'
import { Pencil, Plus, Trash2 } from 'lucide-react'
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 { Button } from '@/components/ui/button'
import { Form, FormControl, 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, useCreateContactMutation, useDeleteContactMutation, useUpdateContactMutation } from '@/features/contacts/hooks'
import { useDebounce } from '@/hooks/use-debounce'
import { formatDate } from '@/lib/utils'
import type { Contact } from '@/types/crm'
const contactFormSchema = z.object({
name: z.string().min(2, 'Имя не короче двух символов'),
email: z.string().email('Некорректный email').or(z.literal('')).optional(),
phone: z.string().min(6, 'Телефон слишком короткий').or(z.literal('')).optional(),
ownerId: z.string().optional(),
})
interface ContactFormValues extends z.infer<typeof contactFormSchema> {}
const defaultValues: ContactFormValues = {
name: '',
email: '',
phone: '',
ownerId: '',
}
const ContactsPage = () => {
const [search, setSearch] = useState('')
const [ownerFilter, setOwnerFilter] = useState('all')
const [drawerOpen, setDrawerOpen] = useState(false)
const [editingContact, setEditingContact] = useState<Contact | null>(null)
const debouncedSearch = useDebounce(search, 400)
const ownerIdFilter = ownerFilter === 'all' ? undefined : Number(ownerFilter)
const { data: contacts = [], isLoading } = useContactsQuery({
search: debouncedSearch.trim() || undefined,
ownerId: Number.isFinite(ownerIdFilter) ? ownerIdFilter : undefined,
})
const createContact = useCreateContactMutation()
const updateContact = useUpdateContactMutation()
const deleteContact = useDeleteContactMutation()
const { toast } = useToast()
const ownerOptions = useMemo(() => {
const ids = new Set<number>()
contacts.forEach((contact) => {
if (contact.owner_id) ids.add(contact.owner_id)
})
return Array.from(ids).sort((a, b) => a - b)
}, [contacts])
const openCreateDrawer = () => {
setEditingContact(null)
setDrawerOpen(true)
}
const openEditDrawer = (contact: Contact) => {
setEditingContact(contact)
setDrawerOpen(true)
}
const handleDelete = useCallback(
async (contact: Contact) => {
const confirmed = window.confirm(`Удалить контакт «${contact.name}»?`)
if (!confirmed) return
try {
await deleteContact.mutateAsync(contact.id)
toast({ title: 'Контакт удалён', description: 'Запись больше не отображается в списке.' })
} catch (error) {
toast({ title: 'Ошибка удаления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
}
},
[deleteContact, toast],
)
const columns = useMemo<ColumnDef<Contact>[]>(
() => [
{
accessorKey: 'name',
header: 'Контакт',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.email ?? '—'}</p>
</div>
),
},
{
accessorKey: 'phone',
header: 'Телефон',
cell: ({ row }) => row.original.phone ?? '—',
},
{
accessorKey: 'owner_id',
header: 'Владелец',
cell: ({ row }) => <span className="text-sm text-muted-foreground">Сотрудник #{row.original.owner_id}</span>,
},
{
accessorKey: 'created_at',
header: 'Создан',
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.created_at)}</span>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => openEditDrawer(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Редактировать</span>
</Button>
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => handleDelete(row.original)}>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Удалить</span>
</Button>
</div>
),
},
],
[handleDelete],
)
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>
<DataTable
columns={columns}
data={contacts}
isLoading={isLoading}
renderToolbar={
<DataTableToolbar
searchPlaceholder="Поиск по имени или email"
searchValue={search}
onSearchChange={setSearch}
actions={
<Button onClick={openCreateDrawer} className="gap-2">
<Plus className="h-4 w-4" />
Новый контакт
</Button>
}
>
{ownerOptions.length ? (
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Все владельцы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все владельцы</SelectItem>
{ownerOptions.map((ownerId) => (
<SelectItem key={ownerId} value={String(ownerId)}>
Сотрудник #{ownerId}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</DataTableToolbar>
}
/>
<ContactFormDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
contact={editingContact}
isSubmitting={createContact.isPending || updateContact.isPending}
onSubmit={async (values) => {
const payload = {
name: values.name,
email: values.email ? values.email : null,
phone: values.phone ? values.phone : null,
owner_id: values.ownerId ? Number(values.ownerId) : undefined,
}
try {
if (editingContact) {
await updateContact.mutateAsync({ contactId: editingContact.id, payload })
toast({ title: 'Контакт обновлён', description: 'Изменения сохранены.' })
} else {
await createContact.mutateAsync(payload)
toast({ title: 'Контакт создан', description: 'Добавлен новый контакт.' })
}
setDrawerOpen(false)
} catch (error) {
toast({ title: 'Ошибка сохранения', description: error instanceof Error ? error.message : 'Попробуйте ещё раз', variant: 'destructive' })
}
}}
key={editingContact?.id ?? 'create'}
/>
</div>
)
}
interface ContactFormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
contact: Contact | null
onSubmit: (values: ContactFormValues) => Promise<void>
isSubmitting: boolean
}
const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting }: ContactFormDrawerProps) => {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues,
})
useEffect(() => {
if (contact) {
form.reset({
name: contact.name,
email: contact.email ?? '',
phone: contact.phone ?? '',
ownerId: contact.owner_id ? String(contact.owner_id) : '',
})
} else {
form.reset(defaultValues)
}
}, [contact, form])
const handleSubmit = async (values: ContactFormValues) => {
await onSubmit(values)
form.reset(defaultValues)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-md sm:max-w-lg">
<SheetHeader>
<SheetTitle>{contact ? 'Редактирование контакта' : 'Новый контакт'}</SheetTitle>
<SheetDescription>Укажите основные данные и при необходимости закрепите владельца.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Имя</FormLabel>
<FormControl>
<Input placeholder="Мария Иванова" autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-mail</FormLabel>
<FormControl>
<Input type="email" placeholder="maria@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Телефон</FormLabel>
<FormControl>
<Input placeholder="+7 999 000-00-00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ownerId"
render={({ field }) => (
<FormItem>
<FormLabel>ID владельца (необязательно)</FormLabel>
<FormControl>
<Input type="number" min={1} placeholder="Например, 42" {...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 ? 'Сохраняем…' : contact ? 'Сохранить' : 'Создать'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
)
}
export default ContactsPage
+580
View File
@@ -0,0 +1,580 @@
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, 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 { useCreateDealMutation, useDealsQuery, useUpdateDealMutation } from '@/features/deals/hooks'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { 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 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 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}
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 ? 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
}
const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: DealCreateDrawerProps) => {
const form = useForm<DealCreateFormValues>({
resolver: zodResolver(dealCreateSchema),
defaultValues: { title: '', contactId: '', amount: '', currency: 'USD', ownerId: '' },
})
const handleSubmit = async (values: DealCreateFormValues) => {
await onSubmit(values)
form.reset({ title: '', contactId: '', amount: '', currency: 'USD', ownerId: '' })
}
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>ID контакта</FormLabel>
<FormControl>
<Input type="number" placeholder="101" {...field} />
</FormControl>
<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>ID владельца (необязательно)</FormLabel>
<FormControl>
<Input type="number" placeholder="42" {...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>
)
}
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
@@ -0,0 +1,91 @@
import { Building2, RefreshCw } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useOrganizationsQuery, useInvalidateOrganizations } from '@/features/organizations/hooks'
import { useAuthStore } from '@/stores/auth-store'
import { formatDate } from '@/lib/utils'
const OrganizationsPage = () => {
const { data: organizations, isLoading, isFetching } = useOrganizationsQuery()
const activeOrganizationId = useAuthStore((state) => state.activeOrganizationId)
const setActiveOrganization = useAuthStore((state) => state.setActiveOrganization)
const invalidate = useInvalidateOrganizations()
const { toast } = useToast()
const handleSwitch = (id: number) => {
setActiveOrganization(id)
toast({ title: 'Контекст переключён', description: 'Все запросы теперь выполняются в выбранной организации.' })
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Организации</h1>
<p className="text-sm text-muted-foreground">Список компаний, к которым у вас есть доступ.</p>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={invalidate} disabled={isFetching}>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
Обновить
</Button>
</div>
{isLoading ? (
<div className="grid gap-4 lg:grid-cols-2">
{[...Array(2)].map((_, index) => (
<Card key={index} className="border-dashed">
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-6 w-24" />
<Skeleton className="mt-4 h-4 w-40" />
</CardContent>
</Card>
))}
</div>
) : organizations && organizations.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{organizations.map((org) => (
<Card key={org.id} className={org.id === activeOrganizationId ? 'border-primary shadow-md' : undefined}>
<CardHeader className="flex flex-row items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Building2 className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-lg">{org.name}</CardTitle>
<CardDescription>ID {org.id}</CardDescription>
</div>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Создана {formatDate(org.created_at)}</p>
{org.id === activeOrganizationId ? (
<p className="text-sm font-medium text-primary">Активная организация</p>
) : null}
</div>
{org.id === activeOrganizationId ? null : (
<Button variant="outline" size="sm" onClick={() => handleSwitch(org.id)}>
Сделать активной
</Button>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed text-center">
<CardHeader>
<CardTitle>Нет организаций</CardTitle>
<CardDescription>Обратитесь к администратору, чтобы вас добавили в рабочую область.</CardDescription>
</CardHeader>
</Card>
)}
</div>
)
}
export default OrganizationsPage
+260
View File
@@ -0,0 +1,260 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { type ColumnDef } from '@tanstack/react-table'
import { 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 { TaskStatusPill } from '@/components/crm/task-status-pill'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { useCreateTaskMutation, useTasksQuery } from '@/features/tasks/hooks'
import { useDebounce } from '@/hooks/use-debounce'
import { formatDate, formatRelativeDate } from '@/lib/utils'
import type { Task } from '@/types/crm'
const taskFormSchema = z
.object({
title: z.string().min(3, 'Минимум 3 символа'),
dealId: z.string().min(1, 'Укажите ID сделки'),
description: z.string().max(500, 'Описание до 500 символов').optional(),
dueDate: z.string().optional(),
})
.refine((values) => {
if (!values.dueDate) return true
const selected = new Date(values.dueDate)
const today = new Date()
selected.setHours(0, 0, 0, 0)
today.setHours(0, 0, 0, 0)
return selected >= today
}, { message: 'Дата не может быть в прошлом', path: ['dueDate'] })
type TaskFormValues = z.infer<typeof taskFormSchema>
const defaultTaskValues: TaskFormValues = { title: '', dealId: '', description: '', dueDate: '' }
const TasksPage = () => {
const [search, setSearch] = useState('')
const [dealFilter, setDealFilter] = useState('')
const [onlyOpen, setOnlyOpen] = useState(true)
const [dueAfter, setDueAfter] = useState('')
const [dueBefore, setDueBefore] = useState('')
const [drawerOpen, setDrawerOpen] = useState(false)
const debouncedDealId = useDebounce(dealFilter, 300)
const { toast } = useToast()
const { data: tasks = [], isLoading } = useTasksQuery({
dealId: debouncedDealId ? Number(debouncedDealId) : undefined,
onlyOpen,
dueAfter: dueAfter || undefined,
dueBefore: dueBefore || undefined,
})
const filteredTasks = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return tasks
return tasks.filter((task) => task.title.toLowerCase().includes(query))
}, [tasks, search])
const createTask = useCreateTaskMutation()
const columns = useMemo<ColumnDef<Task>[]>(
() => [
{
accessorKey: 'title',
header: 'Задача',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.title}</p>
<p className="text-xs text-muted-foreground">Сделка #{row.original.deal_id}</p>
</div>
),
},
{
accessorKey: 'due_date',
header: 'Срок',
cell: ({ row }) => (
<div>
<p>{row.original.due_date ? formatDate(row.original.due_date) : '—'}</p>
<p className="text-xs text-muted-foreground">{row.original.due_date ? formatRelativeDate(row.original.due_date) : ''}</p>
</div>
),
},
{
accessorKey: 'is_done',
header: 'Статус',
cell: ({ row }) => <TaskStatusPill done={row.original.is_done} />,
},
{
accessorKey: 'created_at',
header: 'Создана',
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.created_at)}</span>,
},
],
[],
)
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-semibold text-foreground">Задачи</h1>
<p className="text-sm text-muted-foreground">Контролируйте follow-up по сделкам и создавайте напоминания.</p>
</header>
<DataTable
columns={columns}
data={filteredTasks}
isLoading={isLoading}
renderToolbar={
<DataTableToolbar
searchPlaceholder="Поиск по названию"
searchValue={search}
onSearchChange={setSearch}
actions={
<Button className="gap-2" onClick={() => setDrawerOpen(true)}>
+ Новая задача
</Button>
}
>
<Input
type="number"
placeholder="ID сделки"
value={dealFilter}
onChange={(event) => setDealFilter(event.target.value)}
className="w-[140px]"
/>
<div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-1.5 text-sm">
<Switch checked={onlyOpen} onCheckedChange={setOnlyOpen} id="only-open" />
<label htmlFor="only-open" className="cursor-pointer">
Только открытые
</label>
</div>
<Input type="date" value={dueAfter} onChange={(event) => setDueAfter(event.target.value)} className="w-[170px]" />
<Input type="date" value={dueBefore} onChange={(event) => setDueBefore(event.target.value)} className="w-[170px]" />
</DataTableToolbar>
}
/>
<TaskDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
isSubmitting={createTask.isPending}
onSubmit={async (values) => {
const payload = {
deal_id: Number(values.dealId),
title: values.title,
description: values.description ? values.description : undefined,
due_date: values.dueDate || undefined,
}
try {
await createTask.mutateAsync(payload)
toast({ title: 'Задача создана', description: 'Добавлено напоминание для сделки.' })
setDrawerOpen(false)
} catch (error) {
toast({ title: 'Ошибка создания', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
}
}}
/>
</div>
)
}
interface TaskDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: TaskFormValues) => Promise<void>
isSubmitting: boolean
}
const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: TaskDrawerProps) => {
const form = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: defaultTaskValues,
})
const handleSubmit = async (values: TaskFormValues) => {
await onSubmit(values)
form.reset(defaultTaskValues)
}
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="dealId"
render={({ field }) => (
<FormItem>
<FormLabel>ID сделки</FormLabel>
<FormControl>
<Input type="number" placeholder="201" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dueDate"
render={({ field }) => (
<FormItem>
<FormLabel>Срок выполнения</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Описание</FormLabel>
<FormControl>
<Textarea rows={4} placeholder="Кратко опишите следующий шаг" {...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 TasksPage