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:
parent
4fe3d0480e
commit
8718df9686
|
|
@ -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"), {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
@ -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(),
|
||||
})
|
||||
|
|
@ -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}`)
|
||||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'
|
||||
|
||||
import { dealStageLabels } from '@/components/crm/deal-stage-badge'
|
||||
import { dealStatusLabels } from '@/components/crm/deal-status-badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useDealFunnelQuery, useDealSummaryQuery } from '@/features/analytics/hooks'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { DealFunnelResponse, DealStatus } from '@/types/crm'
|
||||
|
||||
const dayOptions = [7, 14, 30, 60, 90, 120]
|
||||
|
||||
const statusColors: Record<DealStatus, string> = {
|
||||
new: '#fbbf24',
|
||||
in_progress: '#38bdf8',
|
||||
won: '#22c55e',
|
||||
lost: '#f87171',
|
||||
}
|
||||
|
||||
const parseDecimal = (value?: string | number | null) => {
|
||||
if (value === null || value === undefined) return 0
|
||||
const num = typeof value === 'string' ? Number(value) : value
|
||||
return Number.isFinite(num) ? num : 0
|
||||
}
|
||||
|
||||
const AnalyticsPage = () => {
|
||||
const [days, setDays] = useState(30)
|
||||
const summaryQuery = useDealSummaryQuery(days)
|
||||
const funnelQuery = useDealFunnelQuery()
|
||||
|
||||
const summary = summaryQuery.data
|
||||
const funnel = funnelQuery.data
|
||||
|
||||
const statusChartData = useMemo(
|
||||
() =>
|
||||
summary?.by_status.map((item) => ({
|
||||
status: dealStatusLabels[item.status],
|
||||
count: item.count,
|
||||
amount: parseDecimal(item.amount_sum),
|
||||
rawStatus: item.status,
|
||||
})) ?? [],
|
||||
[summary],
|
||||
)
|
||||
|
||||
const funnelChartData = useMemo(() => buildFunnelChartData(funnel), [funnel])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Аналитика сделок</h1>
|
||||
<p className="text-sm text-muted-foreground">Сводка по статусам и этапам с конверсией и динамикой.</p>
|
||||
</div>
|
||||
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
|
||||
<SelectTrigger className="w-[200px] bg-background">
|
||||
<SelectValue placeholder="Период" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dayOptions.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
Последние {option} дней
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summary ? (
|
||||
<>
|
||||
<SummaryCard title="Сделок в работе" primary={summary.total_deals} secondary={`${summary.new_deals.count} за ${summary.new_deals.days} дн.`} />
|
||||
<SummaryCard title="Выиграно" primary={formatCurrency(summary.won.amount_sum)} secondary={`Средний чек ${formatCurrency(summary.won.average_amount)}`} />
|
||||
<SummaryCard title="По статусам" primary={summary.by_status.reduce((acc, item) => acc + item.count, 0)} secondary="Всего записей" />
|
||||
<SummaryCard title="Активность" primary={`${summary.new_deals.count}`} secondary={`Новых за ${summary.new_deals.days} дн.`} />
|
||||
</>
|
||||
) : (
|
||||
<SummarySkeleton />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Статусы сделок</CardTitle>
|
||||
<CardDescription>Количество сделок и суммы по каждому статусу.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[320px]">
|
||||
{summaryQuery.isLoading ? (
|
||||
<Skeleton className="h-full w-full rounded-xl" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={statusChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="status" tick={{ fill: 'currentColor', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip content={<StatusTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} name="Сделки" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Воронка продаж</CardTitle>
|
||||
<CardDescription>Распределение сделок по этапам и статусам.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[320px]">
|
||||
{funnelQuery.isLoading ? (
|
||||
<Skeleton className="h-full w-full rounded-xl" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={funnelChartData} stackOffset="expand">
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="stage" tick={{ fill: 'currentColor', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} tickFormatter={(value) => `${Math.round(value * 100)}%`} />
|
||||
<Tooltip content={<FunnelTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
<Legend />
|
||||
{(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => (
|
||||
<Bar key={status} dataKey={status} stackId="status" fill={statusColors[status]} name={dealStatusLabels[status]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SummaryCard = ({ title, primary, secondary }: { title: string; primary: string | number; secondary: string }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription>{secondary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{primary}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const SummarySkeleton = () => (
|
||||
<>
|
||||
{[...Array(4)].map((_, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const StatusTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { status: string; count: number; amount: number } }> }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
const [{ payload: data }] = payload
|
||||
return (
|
||||
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
|
||||
<p className="font-semibold">{data.status}</p>
|
||||
<p>Сделок: {data.count}</p>
|
||||
<p>Сумма: {formatCurrency(data.amount)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FunnelTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: Record<string, number> }> }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
const [{ payload: data }] = payload
|
||||
return (
|
||||
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
|
||||
<p className="font-semibold">{data.stage}</p>
|
||||
{(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).map((status) => (
|
||||
<p key={status}>
|
||||
{dealStatusLabels[status]}: {Math.round((data[status] || 0) * 100)}%
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const buildFunnelChartData = (funnel?: DealFunnelResponse) => {
|
||||
if (!funnel) return []
|
||||
return funnel.stages.map((stage) => {
|
||||
const total = stage.total || 1
|
||||
return {
|
||||
stage: dealStageLabels[stage.stage],
|
||||
...(['new', 'in_progress', 'won', 'lost'] as DealStatus[]).reduce(
|
||||
(acc, status) => ({
|
||||
...acc,
|
||||
[status]: (stage.by_status[status] ?? 0) / total,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default AnalyticsPage
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useContactsQuery, useCreateContactMutation, useDeleteContactMutation, useUpdateContactMutation } from '@/features/contacts/hooks'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { Contact } from '@/types/crm'
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(2, 'Имя не короче двух символов'),
|
||||
email: z.string().email('Некорректный email').or(z.literal('')).optional(),
|
||||
phone: z.string().min(6, 'Телефон слишком короткий').or(z.literal('')).optional(),
|
||||
ownerId: z.string().optional(),
|
||||
})
|
||||
|
||||
interface ContactFormValues extends z.infer<typeof contactFormSchema> {}
|
||||
|
||||
const defaultValues: ContactFormValues = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
ownerId: '',
|
||||
}
|
||||
|
||||
const ContactsPage = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [ownerFilter, setOwnerFilter] = useState('all')
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [editingContact, setEditingContact] = useState<Contact | null>(null)
|
||||
const debouncedSearch = useDebounce(search, 400)
|
||||
const ownerIdFilter = ownerFilter === 'all' ? undefined : Number(ownerFilter)
|
||||
const { data: contacts = [], isLoading } = useContactsQuery({
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
ownerId: Number.isFinite(ownerIdFilter) ? ownerIdFilter : undefined,
|
||||
})
|
||||
const createContact = useCreateContactMutation()
|
||||
const updateContact = useUpdateContactMutation()
|
||||
const deleteContact = useDeleteContactMutation()
|
||||
const { toast } = useToast()
|
||||
|
||||
const ownerOptions = useMemo(() => {
|
||||
const ids = new Set<number>()
|
||||
contacts.forEach((contact) => {
|
||||
if (contact.owner_id) ids.add(contact.owner_id)
|
||||
})
|
||||
return Array.from(ids).sort((a, b) => a - b)
|
||||
}, [contacts])
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setEditingContact(null)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
const openEditDrawer = (contact: Contact) => {
|
||||
setEditingContact(contact)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (contact: Contact) => {
|
||||
const confirmed = window.confirm(`Удалить контакт «${contact.name}»?`)
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await deleteContact.mutateAsync(contact.id)
|
||||
toast({ title: 'Контакт удалён', description: 'Запись больше не отображается в списке.' })
|
||||
} catch (error) {
|
||||
toast({ title: 'Ошибка удаления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
|
||||
}
|
||||
},
|
||||
[deleteContact, toast],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<Contact>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Контакт',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.email ?? '—'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: 'Телефон',
|
||||
cell: ({ row }) => row.original.phone ?? '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner_id',
|
||||
header: 'Владелец',
|
||||
cell: ({ row }) => <span className="text-sm text-muted-foreground">Сотрудник #{row.original.owner_id}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Создан',
|
||||
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDrawer(row.original)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Редактировать</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => handleDelete(row.original)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleDelete],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Контакты</h1>
|
||||
<p className="text-sm text-muted-foreground">Управляйте базой контактов и быстро создавайте новые записи.</p>
|
||||
</header>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={contacts}
|
||||
isLoading={isLoading}
|
||||
renderToolbar={
|
||||
<DataTableToolbar
|
||||
searchPlaceholder="Поиск по имени или email"
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
actions={
|
||||
<Button onClick={openCreateDrawer} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Новый контакт
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ownerOptions.length ? (
|
||||
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
|
||||
<SelectTrigger className="w-[200px] bg-background">
|
||||
<SelectValue placeholder="Все владельцы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все владельцы</SelectItem>
|
||||
{ownerOptions.map((ownerId) => (
|
||||
<SelectItem key={ownerId} value={String(ownerId)}>
|
||||
Сотрудник #{ownerId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</DataTableToolbar>
|
||||
}
|
||||
/>
|
||||
<ContactFormDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
contact={editingContact}
|
||||
isSubmitting={createContact.isPending || updateContact.isPending}
|
||||
onSubmit={async (values) => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
email: values.email ? values.email : null,
|
||||
phone: values.phone ? values.phone : null,
|
||||
owner_id: values.ownerId ? Number(values.ownerId) : undefined,
|
||||
}
|
||||
try {
|
||||
if (editingContact) {
|
||||
await updateContact.mutateAsync({ contactId: editingContact.id, payload })
|
||||
toast({ title: 'Контакт обновлён', description: 'Изменения сохранены.' })
|
||||
} else {
|
||||
await createContact.mutateAsync(payload)
|
||||
toast({ title: 'Контакт создан', description: 'Добавлен новый контакт.' })
|
||||
}
|
||||
setDrawerOpen(false)
|
||||
} catch (error) {
|
||||
toast({ title: 'Ошибка сохранения', description: error instanceof Error ? error.message : 'Попробуйте ещё раз', variant: 'destructive' })
|
||||
}
|
||||
}}
|
||||
key={editingContact?.id ?? 'create'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContactFormDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
contact: Contact | null
|
||||
onSubmit: (values: ContactFormValues) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting }: ContactFormDrawerProps) => {
|
||||
const form = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (contact) {
|
||||
form.reset({
|
||||
name: contact.name,
|
||||
email: contact.email ?? '',
|
||||
phone: contact.phone ?? '',
|
||||
ownerId: contact.owner_id ? String(contact.owner_id) : '',
|
||||
})
|
||||
} else {
|
||||
form.reset(defaultValues)
|
||||
}
|
||||
}, [contact, form])
|
||||
|
||||
const handleSubmit = async (values: ContactFormValues) => {
|
||||
await onSubmit(values)
|
||||
form.reset(defaultValues)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full max-w-md sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{contact ? 'Редактирование контакта' : 'Новый контакт'}</SheetTitle>
|
||||
<SheetDescription>Укажите основные данные и при необходимости закрепите владельца.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Имя</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Мария Иванова" autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="maria@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Телефон</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="+7 999 000-00-00" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ownerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID владельца (необязательно)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={1} placeholder="Например, 42" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Сохраняем…' : contact ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactsPage
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
|
||||
import { DealStageBadge, dealStageLabels } from '@/components/crm/deal-stage-badge'
|
||||
import { DealStatusBadge, dealStatusLabels } from '@/components/crm/deal-status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCreateDealMutation, useDealsQuery, useUpdateDealMutation } from '@/features/deals/hooks'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { Deal, DealStage, DealStatus } from '@/types/crm'
|
||||
|
||||
const dealStatusList: DealStatus[] = ['new', 'in_progress', 'won', 'lost']
|
||||
const dealStageList: DealStage[] = ['qualification', 'proposal', 'negotiation', 'closed']
|
||||
|
||||
const dealCreateSchema = z.object({
|
||||
title: z.string().min(3, 'Минимум 3 символа'),
|
||||
contactId: z.string().min(1, 'Введите ID контакта'),
|
||||
amount: z.string().optional(),
|
||||
currency: z.string().min(3).max(3).optional(),
|
||||
ownerId: z.string().optional(),
|
||||
})
|
||||
|
||||
type DealCreateFormValues = z.infer<typeof dealCreateSchema>
|
||||
|
||||
const dealUpdateSchema = z.object({
|
||||
status: z.enum(dealStatusList),
|
||||
stage: z.enum(dealStageList),
|
||||
amount: z.string().optional(),
|
||||
currency: z.string().min(3).max(3).optional(),
|
||||
})
|
||||
|
||||
type DealUpdateFormValues = z.infer<typeof dealUpdateSchema>
|
||||
|
||||
const mapAmount = (value?: string | null) => {
|
||||
if (!value) return 0
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : 0
|
||||
}
|
||||
|
||||
const DealsPage = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | DealStatus>('all')
|
||||
const [stageFilter, setStageFilter] = useState<'all' | DealStage>('all')
|
||||
const [ownerFilter, setOwnerFilter] = useState('')
|
||||
const [minAmount, setMinAmount] = useState('')
|
||||
const [maxAmount, setMaxAmount] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [dealToEdit, setDealToEdit] = useState<Deal | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const ownerNumber = ownerFilter ? Number(ownerFilter) : undefined
|
||||
const min = minAmount ? Number(minAmount) : undefined
|
||||
const max = maxAmount ? Number(maxAmount) : undefined
|
||||
return {
|
||||
status: statusFilter === 'all' ? undefined : [statusFilter],
|
||||
stage: stageFilter === 'all' ? undefined : stageFilter,
|
||||
ownerId: Number.isFinite(ownerNumber) ? ownerNumber : undefined,
|
||||
minAmount: Number.isFinite(min) ? min : undefined,
|
||||
maxAmount: Number.isFinite(max) ? max : undefined,
|
||||
}
|
||||
}, [statusFilter, stageFilter, ownerFilter, minAmount, maxAmount])
|
||||
|
||||
const { data: deals = [], isLoading } = useDealsQuery(filters)
|
||||
const filteredDeals = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return deals
|
||||
return deals.filter((deal) => deal.title.toLowerCase().includes(query))
|
||||
}, [deals, search])
|
||||
|
||||
const createDeal = useCreateDealMutation()
|
||||
const updateDeal = useUpdateDealMutation()
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = deals.length
|
||||
const pipeline = deals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
|
||||
const won = deals.filter((deal) => deal.status === 'won')
|
||||
const wonAmount = won.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
|
||||
const conversion = total ? Math.round((won.length / total) * 100) : 0
|
||||
return { total, pipeline, wonAmount, conversion }
|
||||
}, [deals])
|
||||
|
||||
const stageChartData = useMemo(
|
||||
() =>
|
||||
dealStageList.map((stage) => {
|
||||
const stageDeals = deals.filter((deal) => deal.stage === stage)
|
||||
return {
|
||||
stage: dealStageLabels[stage],
|
||||
count: stageDeals.length,
|
||||
value: stageDeals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0),
|
||||
}
|
||||
}),
|
||||
[deals],
|
||||
)
|
||||
|
||||
const handleEditDeal = useCallback((deal: Deal) => setDealToEdit(deal), [])
|
||||
|
||||
const columns = useMemo<ColumnDef<Deal>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: 'Сделка',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.title}</p>
|
||||
<p className="text-xs text-muted-foreground">Контакт #{row.original.contact_id}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: 'Сумма',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{formatCurrency(row.original.amount, row.original.currency ?? 'USD')}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.currency ?? '—'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <DealStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'stage',
|
||||
header: 'Этап',
|
||||
cell: ({ row }) => <DealStageBadge stage={row.original.stage} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner_id',
|
||||
header: 'Владелец',
|
||||
cell: ({ row }) => <span className="text-sm text-muted-foreground">Сотрудник #{row.original.owner_id}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: 'Обновлена',
|
||||
cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDate(row.original.updated_at)}</span>,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditDeal(row.original)}>
|
||||
Обновить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleEditDeal],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Сделки</h1>
|
||||
<p className="text-sm text-muted-foreground">Следите за воронкой продаж и обновляйте статусы в один клик.</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard title="Всего сделок" value={stats.total} description="Количество в текущем списке" />
|
||||
<StatCard title="Потенциал" value={formatCurrency(stats.pipeline)} description="Суммарный pipeline" />
|
||||
<StatCard title="Выиграно" value={formatCurrency(stats.wonAmount)} description="Сумма выигранных сделок" />
|
||||
<StatCard title="Конверсия" value={`${stats.conversion}%`} description="Выиграно от всех" />
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Воронка по этапам</CardTitle>
|
||||
<CardDescription>Количество и сумма сделок на каждом этапе.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stageChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="stage" tick={{ fill: 'currentColor', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: 'currentColor', fontSize: 12 }} />
|
||||
<Tooltip content={<StageTooltip />} cursor={{ fill: 'transparent' }} />
|
||||
<Bar dataKey="count" name="Сделки" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredDeals}
|
||||
isLoading={isLoading}
|
||||
renderToolbar={
|
||||
<DataTableToolbar
|
||||
searchPlaceholder="Поиск по названию"
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
actions={
|
||||
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
|
||||
+ Новая сделка
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
|
||||
<SelectTrigger className="w-[180px] bg-background">
|
||||
<SelectValue placeholder="Статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все статусы</SelectItem>
|
||||
{dealStatusList.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{dealStatusLabels[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={stageFilter} onValueChange={(value) => setStageFilter(value as typeof stageFilter)}>
|
||||
<SelectTrigger className="w-[200px] bg-background">
|
||||
<SelectValue placeholder="Этап" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все этапы</SelectItem>
|
||||
{dealStageList.map((stage) => (
|
||||
<SelectItem key={stage} value={stage}>
|
||||
{dealStageLabels[stage]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="ID владельца"
|
||||
value={ownerFilter}
|
||||
onChange={(event) => setOwnerFilter(event.target.value)}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Мин сумма"
|
||||
value={minAmount}
|
||||
onChange={(event) => setMinAmount(event.target.value)}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Макс сумма"
|
||||
value={maxAmount}
|
||||
onChange={(event) => setMaxAmount(event.target.value)}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</DataTableToolbar>
|
||||
}
|
||||
/>
|
||||
|
||||
<DealCreateDrawer
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
isSubmitting={createDeal.isPending}
|
||||
onSubmit={async (values) => {
|
||||
const payload = {
|
||||
title: values.title,
|
||||
contact_id: Number(values.contactId),
|
||||
amount: values.amount ? Number(values.amount) : undefined,
|
||||
currency: values.currency ? values.currency.toUpperCase() : undefined,
|
||||
owner_id: values.ownerId ? Number(values.ownerId) : undefined,
|
||||
}
|
||||
try {
|
||||
await createDeal.mutateAsync(payload)
|
||||
toast({ title: 'Сделка создана', description: 'Запись появилась в воронке.' })
|
||||
setCreateOpen(false)
|
||||
} catch (error) {
|
||||
toast({ title: 'Не удалось создать сделку', description: error instanceof Error ? error.message : 'Попробуйте снова', variant: 'destructive' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<DealUpdateDrawer
|
||||
deal={dealToEdit}
|
||||
open={Boolean(dealToEdit)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDealToEdit(null)
|
||||
}}
|
||||
isSubmitting={updateDeal.isPending}
|
||||
onSubmit={async (values) => {
|
||||
if (!dealToEdit) return
|
||||
const payload = {
|
||||
status: values.status,
|
||||
stage: values.stage,
|
||||
amount: values.amount ? Number(values.amount) : undefined,
|
||||
currency: values.currency ? values.currency.toUpperCase() : undefined,
|
||||
}
|
||||
try {
|
||||
await updateDeal.mutateAsync({ dealId: dealToEdit.id, payload })
|
||||
toast({ title: 'Сделка обновлена', description: 'Статус и этап сохранены.' })
|
||||
setDealToEdit(null)
|
||||
} catch (error) {
|
||||
toast({ title: 'Ошибка обновления', description: error instanceof Error ? error.message : 'Попробуйте позже', variant: 'destructive' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
}
|
||||
|
||||
const StatCard = ({ title, value, description }: StatCardProps) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const StageTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { stage: string; count: number; value: number } }> }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
const [{ payload: data }] = payload
|
||||
return (
|
||||
<div className="rounded-md border bg-background/90 px-4 py-2 text-sm shadow">
|
||||
<p className="font-semibold">{data.stage}</p>
|
||||
<p>Сделок: {data.count}</p>
|
||||
<p>Сумма: {formatCurrency(data.value)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DealCreateDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (values: DealCreateFormValues) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: DealCreateDrawerProps) => {
|
||||
const form = useForm<DealCreateFormValues>({
|
||||
resolver: zodResolver(dealCreateSchema),
|
||||
defaultValues: { title: '', contactId: '', amount: '', currency: 'USD', ownerId: '' },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: DealCreateFormValues) => {
|
||||
await onSubmit(values)
|
||||
form.reset({ title: '', contactId: '', amount: '', currency: 'USD', ownerId: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новая сделка</SheetTitle>
|
||||
<SheetDescription>Заполните данные для создания сделки.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Название</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Реставрация сайта" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID контакта</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="101" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Сумма</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Валюта</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="USD" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ownerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID владельца (необязательно)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="42" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Создаём…' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
interface DealUpdateDrawerProps {
|
||||
deal: Deal | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (values: DealUpdateFormValues) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const DealUpdateDrawer = ({ deal, open, onOpenChange, onSubmit, isSubmitting }: DealUpdateDrawerProps) => {
|
||||
const form = useForm<DealUpdateFormValues>({
|
||||
resolver: zodResolver(dealUpdateSchema),
|
||||
defaultValues: { status: 'new', stage: 'qualification', amount: '', currency: 'USD' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (deal) {
|
||||
form.reset({
|
||||
status: deal.status,
|
||||
stage: deal.stage,
|
||||
amount: deal.amount ?? '',
|
||||
currency: deal.currency ?? 'USD',
|
||||
})
|
||||
}
|
||||
}, [deal, form])
|
||||
|
||||
if (!deal) return null
|
||||
|
||||
const handleSubmit = async (values: DealUpdateFormValues) => {
|
||||
await onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Обновление сделки</SheetTitle>
|
||||
<SheetDescription>Измените статус, этап или сумму.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="mt-6 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Статус</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите статус" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{dealStatusList.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{dealStatusLabels[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Этап</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите этап" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{dealStageList.map((stage) => (
|
||||
<SelectItem key={stage} value={stage}>
|
||||
{dealStageLabels[stage]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Сумма</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="0" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Валюта</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="USD" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Сохраняем…' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default DealsPage
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { Building2, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useOrganizationsQuery, useInvalidateOrganizations } from '@/features/organizations/hooks'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
const OrganizationsPage = () => {
|
||||
const { data: organizations, isLoading, isFetching } = useOrganizationsQuery()
|
||||
const activeOrganizationId = useAuthStore((state) => state.activeOrganizationId)
|
||||
const setActiveOrganization = useAuthStore((state) => state.setActiveOrganization)
|
||||
const invalidate = useInvalidateOrganizations()
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleSwitch = (id: number) => {
|
||||
setActiveOrganization(id)
|
||||
toast({ title: 'Контекст переключён', description: 'Все запросы теперь выполняются в выбранной организации.' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Организации</h1>
|
||||
<p className="text-sm text-muted-foreground">Список компаний, к которым у вас есть доступ.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={invalidate} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{[...Array(2)].map((_, index) => (
|
||||
<Card key={index} className="border-dashed">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="mt-4 h-4 w-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : organizations && organizations.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{organizations.map((org) => (
|
||||
<Card key={org.id} className={org.id === activeOrganizationId ? 'border-primary shadow-md' : undefined}>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{org.name}</CardTitle>
|
||||
<CardDescription>ID {org.id}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Создана {formatDate(org.created_at)}</p>
|
||||
{org.id === activeOrganizationId ? (
|
||||
<p className="text-sm font-medium text-primary">Активная организация</p>
|
||||
) : null}
|
||||
</div>
|
||||
{org.id === activeOrganizationId ? null : (
|
||||
<Button variant="outline" size="sm" onClick={() => handleSwitch(org.id)}>
|
||||
Сделать активной
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>Нет организаций</CardTitle>
|
||||
<CardDescription>Обратитесь к администратору, чтобы вас добавили в рабочую область.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationsPage
|
||||
|
|
@ -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
|
||||
|
|
@ -10,6 +10,11 @@ import { RequireAuth } from '@/routes/guards/require-auth'
|
|||
const LoginPage = lazy(() => import('@/pages/auth/login-page'))
|
||||
const RegisterPage = lazy(() => import('@/pages/auth/register-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([
|
||||
{
|
||||
|
|
@ -35,6 +40,11 @@ export const router = createBrowserRouter([
|
|||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'contacts', element: <ContactsPage /> },
|
||||
{ path: 'deals', element: <DealsPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'organizations', element: <OrganizationsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue