test_task_crm/frontend/src/pages/tasks/tasks-page.tsx

282 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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