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

View File

@ -82,7 +82,7 @@ define(['./workbox-c5fd805d'], (function (workbox) { 'use strict';
"revision": "d41d8cd98f00b204e9800998ecf8427e" "revision": "d41d8cd98f00b204e9800998ecf8427e"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.nhqann807f" "revision": "0.6kfctj76vbg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -0,0 +1,19 @@
import { Badge } from '@/components/ui/badge'
import type { DealStage } from '@/types/crm'
export const dealStageLabels: Record<DealStage, string> = {
qualification: 'Квалификация',
proposal: 'Предложение',
negotiation: 'Переговоры',
closed: 'Закрыта',
}
interface DealStageBadgeProps {
stage: DealStage
}
export const DealStageBadge = ({ stage }: DealStageBadgeProps) => (
<Badge variant="outline" className="bg-muted/40">
{dealStageLabels[stage]}
</Badge>
)

View File

@ -0,0 +1,22 @@
import { Badge, type BadgeProps } from '@/components/ui/badge'
import type { DealStatus } from '@/types/crm'
export const dealStatusLabels: Record<DealStatus, string> = {
new: 'Новая',
in_progress: 'В работе',
won: 'Успех',
lost: 'Закрыта',
}
const statusVariant: Record<DealStatus, BadgeProps['variant']> = {
new: 'warning',
in_progress: 'secondary',
won: 'success',
lost: 'destructive',
}
interface DealStatusBadgeProps {
status: DealStatus
}
export const DealStatusBadge = ({ status }: DealStatusBadgeProps) => <Badge variant={statusVariant[status]}>{dealStatusLabels[status]}</Badge>

View File

@ -0,0 +1,12 @@
import { CheckCircle2, CircleDashed } from 'lucide-react'
interface TaskStatusPillProps {
done: boolean
}
export const TaskStatusPill = ({ done }: TaskStatusPillProps) => (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold">
{done ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <CircleDashed className="h-4 w-4 text-amber-500" />}
{done ? 'Выполнена' : 'Открыта'}
</span>
)

View File

@ -0,0 +1,39 @@
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
interface DataTableToolbarProps {
searchPlaceholder?: string
searchValue?: string
onSearchChange?: (value: string) => void
children?: React.ReactNode
actions?: React.ReactNode
}
export const DataTableToolbar = ({
searchPlaceholder = 'Поиск…',
searchValue = '',
onSearchChange,
children,
actions,
}: DataTableToolbarProps) => (
<div className="flex flex-col gap-3 border-b bg-muted/20 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-wrap items-center gap-3">
{onSearchChange ? (
<div className="relative w-full min-w-[220px] max-w-sm">
<Search className="pointer-events-none absolute left-2.5 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder}
className="pl-8"
/>
</div>
) : null}
{children}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
</div>
)

View File

@ -0,0 +1,84 @@
import { type ColumnDef, type SortingState, flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
import { Loader2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[]
data: TData[]
isLoading?: boolean
renderToolbar?: React.ReactNode
emptyState?: React.ReactNode
skeletonRows?: number
}
export const DataTable = <TData,>({ columns, data, isLoading = false, renderToolbar, emptyState, skeletonRows = 5 }: DataTableProps<TData>) => {
const [sorting, setSorting] = useState<SortingState>([])
const memoizedData = useMemo(() => data, [data])
const table = useReactTable({
data: memoizedData,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const leafColumns = table.getAllLeafColumns()
return (
<Card className="space-y-4 border bg-card">
{renderToolbar}
<div className="overflow-hidden rounded-xl border bg-background">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(skeletonRows)].map((_, index) => (
<TableRow key={`skeleton-${index}`}>
{leafColumns.map((column) => (
<TableCell key={`${column.id}-${index}`}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center text-sm text-muted-foreground">
{emptyState ?? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5" />
<p>Нет данных для отображения</p>
</div>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
)
}

View File

@ -0,0 +1,7 @@
import { apiClient } from '@/lib/api-client'
import type { DealFunnelResponse, DealSummaryResponse } from '@/types/crm'
export const getDealSummary = (days = 30) =>
apiClient.get<DealSummaryResponse>('/analytics/deals/summary', { params: { days } })
export const getDealFunnel = () => apiClient.get<DealFunnelResponse>('/analytics/deals/funnel')

View File

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { getDealFunnel, getDealSummary } from '@/features/analytics/api'
export const analyticsQueryKey = ['analytics'] as const
export const useDealSummaryQuery = (days = 30) =>
useQuery({
queryKey: [...analyticsQueryKey, 'summary', days],
queryFn: () => getDealSummary(days),
})
export const useDealFunnelQuery = () =>
useQuery({
queryKey: [...analyticsQueryKey, 'funnel'],
queryFn: () => getDealFunnel(),
})

View File

@ -0,0 +1,35 @@
import { apiClient } from '@/lib/api-client'
import type { Contact } from '@/types/crm'
export interface ContactFilters {
page?: number
pageSize?: number
search?: string
ownerId?: number
}
export interface ContactPayload {
name: string
email?: string | null
phone?: string | null
owner_id?: number | null
}
export type ContactUpdatePayload = Partial<Omit<ContactPayload, 'owner_id'>>
const mapFilters = (filters?: ContactFilters) => ({
page: filters?.page,
page_size: filters?.pageSize,
search: filters?.search,
owner_id: filters?.ownerId,
})
export const listContacts = (filters?: ContactFilters) =>
apiClient.get<Contact[]>('/contacts/', { params: mapFilters(filters) })
export const createContact = (payload: ContactPayload) => apiClient.post<Contact, ContactPayload>('/contacts/', payload)
export const updateContact = (contactId: number, payload: ContactUpdatePayload) =>
apiClient.patch<Contact, ContactUpdatePayload>(`/contacts/${contactId}`, payload)
export const deleteContact = (contactId: number) => apiClient.delete<void>(`/contacts/${contactId}`)

View File

@ -0,0 +1,51 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createContact, deleteContact, listContacts, updateContact } from '@/features/contacts/api'
import type { ContactFilters, ContactUpdatePayload } from '@/features/contacts/api'
export const contactsQueryKey = ['contacts'] as const
const serializeFilters = (filters?: ContactFilters) => ({
page: filters?.page ?? 1,
pageSize: filters?.pageSize,
search: filters?.search ?? '',
ownerId: filters?.ownerId ?? null,
})
export const useContactsQuery = (filters?: ContactFilters) =>
useQuery({
queryKey: [...contactsQueryKey, serializeFilters(filters)],
queryFn: () => listContacts(filters),
})
export const useInvalidateContacts = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: contactsQueryKey })
}
export const useCreateContactMutation = () => {
const invalidate = useInvalidateContacts()
return useMutation({
mutationFn: createContact,
onSuccess: () => invalidate(),
})
}
export const useUpdateContactMutation = () => {
const invalidate = useInvalidateContacts()
return useMutation({
mutationFn: ({ contactId, payload }: { contactId: number; payload: ContactUpdatePayload }) =>
updateContact(contactId, payload),
onSuccess: () => {
invalidate()
},
})
}
export const useDeleteContactMutation = () => {
const invalidate = useInvalidateContacts()
return useMutation({
mutationFn: (contactId: number) => deleteContact(contactId),
onSuccess: () => invalidate(),
})
}

View File

@ -0,0 +1,48 @@
import { apiClient } from '@/lib/api-client'
import type { Deal, DealStage, DealStatus } from '@/types/crm'
export interface DealFilters {
page?: number
pageSize?: number
status?: DealStatus[]
stage?: DealStage | null
ownerId?: number
minAmount?: number | null
maxAmount?: number | null
orderBy?: 'created_at' | 'amount' | 'updated_at'
order?: 'asc' | 'desc'
}
export interface DealPayload {
contact_id: number
title: string
amount?: number | string | null
currency?: string | null
owner_id?: number | null
}
export interface DealUpdatePayload {
status?: DealStatus
stage?: DealStage
amount?: number | string | null
currency?: string | null
}
const mapFilters = (filters?: DealFilters) => ({
page: filters?.page,
page_size: filters?.pageSize,
status: filters?.status,
stage: filters?.stage ?? undefined,
owner_id: filters?.ownerId,
min_amount: filters?.minAmount ?? undefined,
max_amount: filters?.maxAmount ?? undefined,
order_by: filters?.orderBy,
order: filters?.order,
})
export const listDeals = (filters?: DealFilters) => apiClient.get<Deal[]>('/deals/', { params: mapFilters(filters) })
export const createDeal = (payload: DealPayload) => apiClient.post<Deal, DealPayload>('/deals/', payload)
export const updateDeal = (dealId: number, payload: DealUpdatePayload) =>
apiClient.patch<Deal, DealUpdatePayload>(`/deals/${dealId}`, payload)

View File

@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createDeal, listDeals, updateDeal } from '@/features/deals/api'
import type { DealFilters, DealPayload, DealUpdatePayload } from '@/features/deals/api'
export const dealsQueryKey = ['deals'] as const
const serializeFilters = (filters?: DealFilters) => ({
page: filters?.page ?? 1,
pageSize: filters?.pageSize,
status: filters?.status ?? [],
stage: filters?.stage ?? null,
ownerId: filters?.ownerId ?? null,
minAmount: filters?.minAmount ?? null,
maxAmount: filters?.maxAmount ?? null,
orderBy: filters?.orderBy ?? 'created_at',
order: filters?.order ?? 'desc',
})
export const useDealsQuery = (filters?: DealFilters) =>
useQuery({
queryKey: [...dealsQueryKey, serializeFilters(filters)],
queryFn: () => listDeals(filters),
})
export const useInvalidateDeals = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: dealsQueryKey })
}
export const useCreateDealMutation = () => {
const invalidate = useInvalidateDeals()
return useMutation({
mutationFn: (payload: DealPayload) => createDeal(payload),
onSuccess: () => invalidate(),
})
}
export const useUpdateDealMutation = () => {
const invalidate = useInvalidateDeals()
return useMutation({
mutationFn: ({ dealId, payload }: { dealId: number; payload: DealUpdatePayload }) => updateDeal(dealId, payload),
onSuccess: () => invalidate(),
})
}

View File

@ -0,0 +1,35 @@
import { apiClient } from '@/lib/api-client'
import type { Task } from '@/types/crm'
export interface TaskFilters {
dealId?: number
onlyOpen?: boolean
dueBefore?: string | Date | null
dueAfter?: string | Date | null
}
export interface TaskPayload {
deal_id: number
title: string
description?: string | null
due_date?: string | null
}
const normalizeDate = (value?: string | Date | null) => {
if (!value) return undefined
if (value instanceof Date) {
return value.toISOString().slice(0, 10)
}
return value
}
const mapFilters = (filters?: TaskFilters) => ({
deal_id: filters?.dealId,
only_open: filters?.onlyOpen,
due_before: normalizeDate(filters?.dueBefore ?? undefined),
due_after: normalizeDate(filters?.dueAfter ?? undefined),
})
export const listTasks = (filters?: TaskFilters) => apiClient.get<Task[]>('/tasks/', { params: mapFilters(filters) })
export const createTask = (payload: TaskPayload) => apiClient.post<Task, TaskPayload>('/tasks/', payload)

View File

@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createTask, listTasks } from '@/features/tasks/api'
import type { TaskFilters, TaskPayload } from '@/features/tasks/api'
export const tasksQueryKey = ['tasks'] as const
const serializeFilters = (filters?: TaskFilters) => ({
dealId: filters?.dealId ?? null,
onlyOpen: filters?.onlyOpen ?? false,
dueBefore: filters?.dueBefore ? String(filters.dueBefore) : null,
dueAfter: filters?.dueAfter ? String(filters.dueAfter) : null,
})
export const useTasksQuery = (filters?: TaskFilters) =>
useQuery({
queryKey: [...tasksQueryKey, serializeFilters(filters)],
queryFn: () => listTasks(filters),
})
export const useInvalidateTasks = () => {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: tasksQueryKey })
}
export const useCreateTaskMutation = () => {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (payload: TaskPayload) => createTask(payload),
onSuccess: () => invalidate(),
})
}

View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
export const useDebounce = <T>(value: T, delay = 300) => {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delay)
return () => window.clearTimeout(timer)
}, [value, delay])
return debounced
}

View File

@ -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

View File

@ -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

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

View File

@ -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

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

View File

@ -10,6 +10,11 @@ import { RequireAuth } from '@/routes/guards/require-auth'
const LoginPage = lazy(() => import('@/pages/auth/login-page')) const LoginPage = lazy(() => import('@/pages/auth/login-page'))
const RegisterPage = lazy(() => import('@/pages/auth/register-page')) const RegisterPage = lazy(() => import('@/pages/auth/register-page'))
const DashboardPage = lazy(() => import('@/pages/dashboard/dashboard-page')) const DashboardPage = lazy(() => import('@/pages/dashboard/dashboard-page'))
const ContactsPage = lazy(() => import('@/pages/contacts/contacts-page'))
const DealsPage = lazy(() => import('@/pages/deals/deals-page'))
const TasksPage = lazy(() => import('@/pages/tasks/tasks-page'))
const AnalyticsPage = lazy(() => import('@/pages/analytics/analytics-page'))
const OrganizationsPage = lazy(() => import('@/pages/organizations/organizations-page'))
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@ -35,6 +40,11 @@ export const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <Navigate to="/dashboard" replace /> }, { index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> }, { path: 'dashboard', element: <DashboardPage /> },
{ path: 'contacts', element: <ContactsPage /> },
{ path: 'deals', element: <DealsPage /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'organizations', element: <OrganizationsPage /> },
], ],
}, },
], ],