282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||
import { 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 { useDealsQuery } from '@/features/deals/hooks'
|
||
import { useDebounce } from '@/hooks/use-debounce'
|
||
import { formatDate, formatRelativeDate } from '@/lib/utils'
|
||
import type { Deal, 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 { data: deals = [], isLoading: dealsLoading } = useDealsQuery({ pageSize: 100, orderBy: 'updated_at', order: 'desc' })
|
||
|
||
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={(value) => setOnlyOpen(value === true)} 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}
|
||
deals={deals}
|
||
dealsLoading={dealsLoading}
|
||
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
|
||
deals: Deal[]
|
||
dealsLoading: boolean
|
||
}
|
||
|
||
const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting, deals, dealsLoading }: 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>Сделка</FormLabel>
|
||
<Select value={field.value} onValueChange={field.onChange} disabled={dealsLoading || !deals.length}>
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder={dealsLoading ? 'Загружаем сделки…' : 'Выберите сделку'} />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{deals.map((deal) => (
|
||
<SelectItem key={deal.id} value={String(deal.id)}>
|
||
{deal.title} · #{deal.id}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormDescription>
|
||
{deals.length ? 'Задача появится в таймлайне выбранной сделки.' : 'Сначала создайте сделку в разделе «Сделки».'}
|
||
</FormDescription>
|
||
<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
|