diff --git a/frontend/dev-dist/sw.js b/frontend/dev-dist/sw.js index 4d200a3..948273e 100644 --- a/frontend/dev-dist/sw.js +++ b/frontend/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-c5fd805d'], (function (workbox) { 'use strict'; "revision": "d41d8cd98f00b204e9800998ecf8427e" }, { "url": "index.html", - "revision": "0.nhqann807f" + "revision": "0.6kfctj76vbg" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/frontend/src/components/crm/deal-stage-badge.tsx b/frontend/src/components/crm/deal-stage-badge.tsx new file mode 100644 index 0000000..99258ed --- /dev/null +++ b/frontend/src/components/crm/deal-stage-badge.tsx @@ -0,0 +1,19 @@ +import { Badge } from '@/components/ui/badge' +import type { DealStage } from '@/types/crm' + +export const dealStageLabels: Record = { + qualification: 'Квалификация', + proposal: 'Предложение', + negotiation: 'Переговоры', + closed: 'Закрыта', +} + +interface DealStageBadgeProps { + stage: DealStage +} + +export const DealStageBadge = ({ stage }: DealStageBadgeProps) => ( + + {dealStageLabels[stage]} + +) diff --git a/frontend/src/components/crm/deal-status-badge.tsx b/frontend/src/components/crm/deal-status-badge.tsx new file mode 100644 index 0000000..0a01b90 --- /dev/null +++ b/frontend/src/components/crm/deal-status-badge.tsx @@ -0,0 +1,22 @@ +import { Badge, type BadgeProps } from '@/components/ui/badge' +import type { DealStatus } from '@/types/crm' + +export const dealStatusLabels: Record = { + new: 'Новая', + in_progress: 'В работе', + won: 'Успех', + lost: 'Закрыта', +} + +const statusVariant: Record = { + new: 'warning', + in_progress: 'secondary', + won: 'success', + lost: 'destructive', +} + +interface DealStatusBadgeProps { + status: DealStatus +} + +export const DealStatusBadge = ({ status }: DealStatusBadgeProps) => {dealStatusLabels[status]} diff --git a/frontend/src/components/crm/task-status-pill.tsx b/frontend/src/components/crm/task-status-pill.tsx new file mode 100644 index 0000000..4824805 --- /dev/null +++ b/frontend/src/components/crm/task-status-pill.tsx @@ -0,0 +1,12 @@ +import { CheckCircle2, CircleDashed } from 'lucide-react' + +interface TaskStatusPillProps { + done: boolean +} + +export const TaskStatusPill = ({ done }: TaskStatusPillProps) => ( + + {done ? : } + {done ? 'Выполнена' : 'Открыта'} + +) diff --git a/frontend/src/components/data-table/data-table-toolbar.tsx b/frontend/src/components/data-table/data-table-toolbar.tsx new file mode 100644 index 0000000..63a6c43 --- /dev/null +++ b/frontend/src/components/data-table/data-table-toolbar.tsx @@ -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) => ( +
+
+
+ {onSearchChange ? ( +
+ + onSearchChange(event.target.value)} + placeholder={searchPlaceholder} + className="pl-8" + /> +
+ ) : null} + {children} +
+ {actions ?
{actions}
: null} +
+
+) diff --git a/frontend/src/components/data-table/data-table.tsx b/frontend/src/components/data-table/data-table.tsx new file mode 100644 index 0000000..ec9eefa --- /dev/null +++ b/frontend/src/components/data-table/data-table.tsx @@ -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 { + columns: ColumnDef[] + data: TData[] + isLoading?: boolean + renderToolbar?: React.ReactNode + emptyState?: React.ReactNode + skeletonRows?: number +} + +export const DataTable = ({ columns, data, isLoading = false, renderToolbar, emptyState, skeletonRows = 5 }: DataTableProps) => { + const [sorting, setSorting] = useState([]) + const memoizedData = useMemo(() => data, [data]) + + const table = useReactTable({ + data: memoizedData, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + const leafColumns = table.getAllLeafColumns() + + return ( + + {renderToolbar} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {isLoading ? ( + [...Array(skeletonRows)].map((_, index) => ( + + {leafColumns.map((column) => ( + + + + ))} + + )) + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + {emptyState ?? ( +
+ +

Нет данных для отображения

+
+ )} +
+
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/features/analytics/api.ts b/frontend/src/features/analytics/api.ts new file mode 100644 index 0000000..8f53387 --- /dev/null +++ b/frontend/src/features/analytics/api.ts @@ -0,0 +1,7 @@ +import { apiClient } from '@/lib/api-client' +import type { DealFunnelResponse, DealSummaryResponse } from '@/types/crm' + +export const getDealSummary = (days = 30) => + apiClient.get('/analytics/deals/summary', { params: { days } }) + +export const getDealFunnel = () => apiClient.get('/analytics/deals/funnel') diff --git a/frontend/src/features/analytics/hooks.ts b/frontend/src/features/analytics/hooks.ts new file mode 100644 index 0000000..e35a281 --- /dev/null +++ b/frontend/src/features/analytics/hooks.ts @@ -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(), + }) diff --git a/frontend/src/features/contacts/api.ts b/frontend/src/features/contacts/api.ts new file mode 100644 index 0000000..ab7d9f3 --- /dev/null +++ b/frontend/src/features/contacts/api.ts @@ -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> + +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('/contacts/', { params: mapFilters(filters) }) + +export const createContact = (payload: ContactPayload) => apiClient.post('/contacts/', payload) + +export const updateContact = (contactId: number, payload: ContactUpdatePayload) => + apiClient.patch(`/contacts/${contactId}`, payload) + +export const deleteContact = (contactId: number) => apiClient.delete(`/contacts/${contactId}`) diff --git a/frontend/src/features/contacts/hooks.ts b/frontend/src/features/contacts/hooks.ts new file mode 100644 index 0000000..52d4000 --- /dev/null +++ b/frontend/src/features/contacts/hooks.ts @@ -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(), + }) +} diff --git a/frontend/src/features/deals/api.ts b/frontend/src/features/deals/api.ts new file mode 100644 index 0000000..02377a5 --- /dev/null +++ b/frontend/src/features/deals/api.ts @@ -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('/deals/', { params: mapFilters(filters) }) + +export const createDeal = (payload: DealPayload) => apiClient.post('/deals/', payload) + +export const updateDeal = (dealId: number, payload: DealUpdatePayload) => + apiClient.patch(`/deals/${dealId}`, payload) diff --git a/frontend/src/features/deals/hooks.ts b/frontend/src/features/deals/hooks.ts new file mode 100644 index 0000000..6c1dda1 --- /dev/null +++ b/frontend/src/features/deals/hooks.ts @@ -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(), + }) +} diff --git a/frontend/src/features/tasks/api.ts b/frontend/src/features/tasks/api.ts new file mode 100644 index 0000000..78e48fe --- /dev/null +++ b/frontend/src/features/tasks/api.ts @@ -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('/tasks/', { params: mapFilters(filters) }) + +export const createTask = (payload: TaskPayload) => apiClient.post('/tasks/', payload) diff --git a/frontend/src/features/tasks/hooks.ts b/frontend/src/features/tasks/hooks.ts new file mode 100644 index 0000000..58f61c0 --- /dev/null +++ b/frontend/src/features/tasks/hooks.ts @@ -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(), + }) +} diff --git a/frontend/src/hooks/use-debounce.ts b/frontend/src/hooks/use-debounce.ts new file mode 100644 index 0000000..ac94e8a --- /dev/null +++ b/frontend/src/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react' + +export const useDebounce = (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 +} diff --git a/frontend/src/pages/analytics/analytics-page.tsx b/frontend/src/pages/analytics/analytics-page.tsx new file mode 100644 index 0000000..9689f1b --- /dev/null +++ b/frontend/src/pages/analytics/analytics-page.tsx @@ -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 = { + 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 ( +
+
+
+

Аналитика сделок

+

Сводка по статусам и этапам с конверсией и динамикой.

+
+ +
+ +
+ {summary ? ( + <> + + + acc + item.count, 0)} secondary="Всего записей" /> + + + ) : ( + + )} +
+ +
+ + + Статусы сделок + Количество сделок и суммы по каждому статусу. + + + {summaryQuery.isLoading ? ( + + ) : ( + + + + + + } cursor={{ fill: 'transparent' }} /> + + + + )} + + + + + + Воронка продаж + Распределение сделок по этапам и статусам. + + + {funnelQuery.isLoading ? ( + + ) : ( + + + + + `${Math.round(value * 100)}%`} /> + } cursor={{ fill: 'transparent' }} /> + + {(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => ( + + ))} + + + )} + + +
+
+ ) +} + +const SummaryCard = ({ title, primary, secondary }: { title: string; primary: string | number; secondary: string }) => ( + + + {title} + {secondary} + + +

{primary}

+
+
+) + +const SummarySkeleton = () => ( + <> + {[...Array(4)].map((_, index) => ( + + + + + + + + + ))} + +) + +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 ( +
+

{data.status}

+

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

+

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

+
+ ) +} + +const FunnelTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: Record }> }) => { + if (!active || !payload?.length) return null + const [{ payload: data }] = payload + return ( +
+

{data.stage}

+ {(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => ( +

+ {dealStatusLabels[status]}: {Math.round((data[status] || 0) * 100)}% +

+ ))} +
+ ) +} + +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 diff --git a/frontend/src/pages/contacts/contacts-page.tsx b/frontend/src/pages/contacts/contacts-page.tsx new file mode 100644 index 0000000..a1a3e49 --- /dev/null +++ b/frontend/src/pages/contacts/contacts-page.tsx @@ -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 {} + +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(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() + 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[]>( + () => [ + { + accessorKey: 'name', + header: 'Контакт', + cell: ({ row }) => ( +
+

{row.original.name}

+

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

+
+ ), + }, + { + accessorKey: 'phone', + header: 'Телефон', + cell: ({ row }) => row.original.phone ?? '—', + }, + { + accessorKey: 'owner_id', + header: 'Владелец', + cell: ({ row }) => Сотрудник #{row.original.owner_id}, + }, + { + accessorKey: 'created_at', + header: 'Создан', + cell: ({ row }) => {formatDate(row.original.created_at)}, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( +
+ + +
+ ), + }, + ], + [handleDelete], + ) + + return ( +
+
+

Контакты

+

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

+
+ + + Новый контакт + + } + > + {ownerOptions.length ? ( + + ) : null} + + } + /> + { + 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'} + /> +
+ ) +} + +interface ContactFormDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + contact: Contact | null + onSubmit: (values: ContactFormValues) => Promise + isSubmitting: boolean +} + +const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting }: ContactFormDrawerProps) => { + const form = useForm({ + 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 ( + + + + {contact ? 'Редактирование контакта' : 'Новый контакт'} + Укажите основные данные и при необходимости закрепите владельца. + +
+ + ( + + Имя + + + + + + )} + /> + ( + + E-mail + + + + + + )} + /> + ( + + Телефон + + + + + + )} + /> + ( + + ID владельца (необязательно) + + + + + + )} + /> +
+ + +
+ + +
+
+ ) +} + +export default ContactsPage diff --git a/frontend/src/pages/deals/deals-page.tsx b/frontend/src/pages/deals/deals-page.tsx new file mode 100644 index 0000000..5f96f3a --- /dev/null +++ b/frontend/src/pages/deals/deals-page.tsx @@ -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 + +const dealUpdateSchema = z.object({ + status: z.enum(dealStatusList), + stage: z.enum(dealStageList), + amount: z.string().optional(), + currency: z.string().min(3).max(3).optional(), +}) + +type DealUpdateFormValues = z.infer + +const mapAmount = (value?: string | null) => { + if (!value) return 0 + const number = Number(value) + return Number.isFinite(number) ? number : 0 +} + +const DealsPage = () => { + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState<'all' | DealStatus>('all') + const [stageFilter, setStageFilter] = useState<'all' | DealStage>('all') + const [ownerFilter, setOwnerFilter] = useState('') + const [minAmount, setMinAmount] = useState('') + const [maxAmount, setMaxAmount] = useState('') + const [createOpen, setCreateOpen] = useState(false) + const [dealToEdit, setDealToEdit] = useState(null) + const { toast } = useToast() + + const filters = useMemo(() => { + const ownerNumber = ownerFilter ? Number(ownerFilter) : undefined + const min = minAmount ? Number(minAmount) : undefined + const max = maxAmount ? Number(maxAmount) : undefined + return { + status: statusFilter === 'all' ? undefined : [statusFilter], + stage: stageFilter === 'all' ? undefined : stageFilter, + ownerId: Number.isFinite(ownerNumber) ? ownerNumber : undefined, + minAmount: Number.isFinite(min) ? min : undefined, + maxAmount: Number.isFinite(max) ? max : undefined, + } + }, [statusFilter, stageFilter, ownerFilter, minAmount, maxAmount]) + + const { data: deals = [], isLoading } = useDealsQuery(filters) + const 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[]>( + () => [ + { + accessorKey: 'title', + header: 'Сделка', + cell: ({ row }) => ( +
+

{row.original.title}

+

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

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

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

+

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

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

Сделки

+

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

+
+ +
+ + + + +
+ + + + Воронка по этапам + Количество и сумма сделок на каждом этапе. + + + + + + + + } cursor={{ fill: 'transparent' }} /> + + + + + + + setCreateOpen(true)}> + + Новая сделка + + } + > + + + setOwnerFilter(event.target.value)} + className="w-[140px]" + /> +
+ setMinAmount(event.target.value)} + className="w-[130px]" + /> + setMaxAmount(event.target.value)} + className="w-[130px]" + /> +
+ + } + /> + + { + const payload = { + title: values.title, + contact_id: Number(values.contactId), + amount: values.amount ? Number(values.amount) : undefined, + currency: values.currency ? values.currency.toUpperCase() : undefined, + owner_id: values.ownerId ? Number(values.ownerId) : undefined, + } + try { + await createDeal.mutateAsync(payload) + toast({ title: 'Сделка создана', description: 'Запись появилась в воронке.' }) + setCreateOpen(false) + } catch (error) { + toast({ title: 'Не удалось создать сделку', description: error instanceof Error ? error.message : 'Попробуйте снова', variant: 'destructive' }) + } + }} + /> + + { + if (!open) setDealToEdit(null) + }} + isSubmitting={updateDeal.isPending} + onSubmit={async (values) => { + if (!dealToEdit) return + const payload = { + status: values.status, + stage: values.stage, + amount: values.amount ? Number(values.amount) : undefined, + currency: values.currency ? values.currency.toUpperCase() : undefined, + } + try { + await updateDeal.mutateAsync({ dealId: dealToEdit.id, payload }) + toast({ title: 'Сделка обновлена', description: 'Статус и этап сохранены.' }) + setDealToEdit(null) + } catch (error) { + toast({ title: 'Ошибка обновления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' }) + } + }} + /> +
+ ) +} + +interface StatCardProps { + title: string + value: string | number + description: string +} + +const StatCard = ({ title, value, description }: StatCardProps) => ( + + + {title} + {description} + + +

{value}

+
+
+) + +const StageTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { stage: string; count: number; value: number } }> }) => { + if (!active || !payload?.length) return null + const [{ payload: data }] = payload + return ( +
+

{data.stage}

+

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

+

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

+
+ ) +} + +interface DealCreateDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (values: DealCreateFormValues) => Promise + isSubmitting: boolean +} + +const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: DealCreateDrawerProps) => { + const form = useForm({ + 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 ( + + + + Новая сделка + Заполните данные для создания сделки. + +
+ + ( + + Название + + + + + + )} + /> + ( + + ID контакта + + + + + + )} + /> + ( + + Сумма + + + + + + )} + /> + ( + + Валюта + + + + + + )} + /> + ( + + ID владельца (необязательно) + + + + + + )} + /> +
+ + +
+ + +
+
+ ) +} + +interface DealUpdateDrawerProps { + deal: Deal | null + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (values: DealUpdateFormValues) => Promise + isSubmitting: boolean +} + +const DealUpdateDrawer = ({ deal, open, onOpenChange, onSubmit, isSubmitting }: DealUpdateDrawerProps) => { + const form = useForm({ + resolver: zodResolver(dealUpdateSchema), + defaultValues: { status: 'new', stage: 'qualification', amount: '', currency: 'USD' }, + }) + + useEffect(() => { + if (deal) { + form.reset({ + status: deal.status, + stage: deal.stage, + amount: deal.amount ?? '', + currency: deal.currency ?? 'USD', + }) + } + }, [deal, form]) + + if (!deal) return null + + const handleSubmit = async (values: DealUpdateFormValues) => { + await onSubmit(values) + } + + return ( + + + + Обновление сделки + Измените статус, этап или сумму. + +
+ + ( + + Статус + + + + )} + /> + ( + + Этап + + + + )} + /> + ( + + Сумма + + + + + + )} + /> + ( + + Валюта + + + + + + )} + /> +
+ + +
+ + +
+
+ ) +} + +export default DealsPage diff --git a/frontend/src/pages/organizations/organizations-page.tsx b/frontend/src/pages/organizations/organizations-page.tsx new file mode 100644 index 0000000..8995053 --- /dev/null +++ b/frontend/src/pages/organizations/organizations-page.tsx @@ -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 ( +
+
+
+

Организации

+

Список компаний, к которым у вас есть доступ.

+
+ +
+ + {isLoading ? ( +
+ {[...Array(2)].map((_, index) => ( + + + + + + + + + + ))} +
+ ) : organizations && organizations.length ? ( +
+ {organizations.map((org) => ( + + +
+ +
+
+ {org.name} + ID {org.id} +
+
+ +
+

Создана {formatDate(org.created_at)}

+ {org.id === activeOrganizationId ? ( +

Активная организация

+ ) : null} +
+ {org.id === activeOrganizationId ? null : ( + + )} +
+
+ ))} +
+ ) : ( + + + Нет организаций + Обратитесь к администратору, чтобы вас добавили в рабочую область. + + + )} +
+ ) +} + +export default OrganizationsPage diff --git a/frontend/src/pages/tasks/tasks-page.tsx b/frontend/src/pages/tasks/tasks-page.tsx new file mode 100644 index 0000000..83b41e1 --- /dev/null +++ b/frontend/src/pages/tasks/tasks-page.tsx @@ -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 + +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[]>( + () => [ + { + accessorKey: 'title', + header: 'Задача', + cell: ({ row }) => ( +
+

{row.original.title}

+

Сделка #{row.original.deal_id}

+
+ ), + }, + { + accessorKey: 'due_date', + header: 'Срок', + cell: ({ row }) => ( +
+

{row.original.due_date ? formatDate(row.original.due_date) : '—'}

+

{row.original.due_date ? formatRelativeDate(row.original.due_date) : ''}

+
+ ), + }, + { + accessorKey: 'is_done', + header: 'Статус', + cell: ({ row }) => , + }, + { + accessorKey: 'created_at', + header: 'Создана', + cell: ({ row }) => {formatDate(row.original.created_at)}, + }, + ], + [], + ) + + return ( +
+
+

Задачи

+

Контролируйте follow-up по сделкам и создавайте напоминания.

+
+ setDrawerOpen(true)}> + + Новая задача + + } + > + setDealFilter(event.target.value)} + className="w-[140px]" + /> +
+ + +
+ setDueAfter(event.target.value)} className="w-[170px]" /> + setDueBefore(event.target.value)} className="w-[170px]" /> + + } + /> + { + 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' }) + } + }} + /> +
+ ) +} + +interface TaskDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (values: TaskFormValues) => Promise + isSubmitting: boolean +} + +const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: TaskDrawerProps) => { + const form = useForm({ + resolver: zodResolver(taskFormSchema), + defaultValues: defaultTaskValues, + }) + + const handleSubmit = async (values: TaskFormValues) => { + await onSubmit(values) + form.reset(defaultTaskValues) + } + + return ( + + + + Новая задача + Запланируйте следующий шаг для сделки. + +
+ + ( + + Название + + + + + + )} + /> + ( + + ID сделки + + + + + + )} + /> + ( + + Срок выполнения + + + + + + )} + /> + ( + + Описание + +