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

329 lines
13 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 { 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, 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 { 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 ownerIds = 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 ownerSelectOptions = useMemo(() => ownerIds.map((ownerId) => ({ value: String(ownerId), label: `Сотрудник #${ownerId}` })), [ownerIds])
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>
}
>
{ownerIds.length ? (
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Все владельцы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все владельцы</SelectItem>
{ownerIds.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}
ownerOptions={ownerSelectOptions}
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
ownerOptions: Array<{ value: string; label: string }>
}
const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting, ownerOptions }: 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>Владелец</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={!ownerOptions.length}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={ownerOptions.length ? 'Выберите владельца' : 'Назначение недоступно'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Назначить меня</SelectItem>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Выберите руководителя из списка или оставьте поле пустым, чтобы назначить себя.</FormDescription>
<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