329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
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
|