feat: enhance forms with improved select components and data handling for contacts and deals
Test / test (push) Successful in 17s Details

This commit is contained in:
Artem Kashaev 2025-12-01 14:16:24 +05:00
parent 8718df9686
commit ecb6daad1b
5 changed files with 122 additions and 34 deletions

View File

@ -82,7 +82,7 @@ define(['./workbox-c5fd805d'], (function (workbox) { 'use strict';
"revision": "d41d8cd98f00b204e9800998ecf8427e"
}, {
"url": "index.html",
"revision": "0.6kfctj76vbg"
"revision": "0.b5rg9utgn3"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
@ -38,7 +38,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80',
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-lg animate-in fade-in-80',
position === 'popper' && 'data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1',
className,
)}

View File

@ -8,7 +8,7 @@ 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 { 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'
@ -50,7 +50,7 @@ const ContactsPage = () => {
const deleteContact = useDeleteContactMutation()
const { toast } = useToast()
const ownerOptions = useMemo(() => {
const ownerIds = useMemo(() => {
const ids = new Set<number>()
contacts.forEach((contact) => {
if (contact.owner_id) ids.add(contact.owner_id)
@ -58,6 +58,8 @@ const ContactsPage = () => {
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)
@ -151,14 +153,14 @@ const ContactsPage = () => {
</Button>
}
>
{ownerOptions.length ? (
{ownerIds.length ? (
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="Все владельцы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все владельцы</SelectItem>
{ownerOptions.map((ownerId) => (
{ownerIds.map((ownerId) => (
<SelectItem key={ownerId} value={String(ownerId)}>
Сотрудник #{ownerId}
</SelectItem>
@ -174,6 +176,7 @@ const ContactsPage = () => {
onOpenChange={setDrawerOpen}
contact={editingContact}
isSubmitting={createContact.isPending || updateContact.isPending}
ownerOptions={ownerSelectOptions}
onSubmit={async (values) => {
const payload = {
name: values.name,
@ -206,9 +209,10 @@ interface ContactFormDrawerProps {
contact: Contact | null
onSubmit: (values: ContactFormValues) => Promise<void>
isSubmitting: boolean
ownerOptions: Array<{ value: string; label: string }>
}
const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting }: ContactFormDrawerProps) => {
const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting, ownerOptions }: ContactFormDrawerProps) => {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues,
@ -285,10 +289,23 @@ const ContactFormDrawer = ({ open, onOpenChange, contact, onSubmit, isSubmitting
name="ownerId"
render={({ field }) => (
<FormItem>
<FormLabel>ID владельца (необязательно)</FormLabel>
<FormControl>
<Input type="number" min={1} placeholder="Например, 42" {...field} />
</FormControl>
<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>
)}

View File

@ -11,14 +11,15 @@ import { DealStageBadge, dealStageLabels } from '@/components/crm/deal-stage-bad
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 { 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 } from '@/features/contacts/hooks'
import { useCreateDealMutation, useDealsQuery, useUpdateDealMutation } from '@/features/deals/hooks'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { Deal, DealStage, DealStatus } from '@/types/crm'
import type { Contact, Deal, DealStage, DealStatus } from '@/types/crm'
const dealStatusList: DealStatus[] = ['new', 'in_progress', 'won', 'lost']
const dealStageList: DealStage[] = ['qualification', 'proposal', 'negotiation', 'closed']
@ -73,6 +74,7 @@ const DealsPage = () => {
}, [statusFilter, stageFilter, ownerFilter, minAmount, maxAmount])
const { data: deals = [], isLoading } = useDealsQuery(filters)
const { data: contacts = [], isLoading: contactsLoading } = useContactsQuery({ pageSize: 100 })
const filteredDeals = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return deals
@ -82,6 +84,21 @@ const DealsPage = () => {
const createDeal = useCreateDealMutation()
const updateDeal = useUpdateDealMutation()
const ownerOptions = useMemo(
() =>
Array.from(
new Set(
[
...deals.map((deal) => deal.owner_id),
...contacts.map((contact) => contact.owner_id).filter((id): id is number => typeof id === 'number'),
],
),
)
.filter((id) => id !== undefined && id !== null)
.map((id) => ({ value: String(id), label: `Сотрудник #${id}` })),
[contacts, deals],
)
const stats = useMemo(() => {
const total = deals.length
const pipeline = deals.reduce((acc, deal) => acc + mapAmount(deal.amount), 0)
@ -265,13 +282,16 @@ const DealsPage = () => {
open={createOpen}
onOpenChange={setCreateOpen}
isSubmitting={createDeal.isPending}
contacts={contacts}
contactsLoading={contactsLoading}
ownerOptions={ownerOptions}
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,
owner_id: values.ownerId && values.ownerId !== 'auto' ? Number(values.ownerId) : undefined,
}
try {
await createDeal.mutateAsync(payload)
@ -346,17 +366,20 @@ interface DealCreateDrawerProps {
onOpenChange: (open: boolean) => void
onSubmit: (values: DealCreateFormValues) => Promise<void>
isSubmitting: boolean
contacts: Contact[]
contactsLoading: boolean
ownerOptions: Array<{ value: string; label: string }>
}
const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: DealCreateDrawerProps) => {
const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting, contacts, contactsLoading, ownerOptions }: DealCreateDrawerProps) => {
const form = useForm<DealCreateFormValues>({
resolver: zodResolver(dealCreateSchema),
defaultValues: { title: '', contactId: '', amount: '', currency: 'USD', ownerId: '' },
defaultValues: { title: '', contactId: '', amount: '', currency: 'USD', ownerId: 'auto' },
})
const handleSubmit = async (values: DealCreateFormValues) => {
await onSubmit(values)
form.reset({ title: '', contactId: '', amount: '', currency: 'USD', ownerId: '' })
form.reset({ title: '', contactId: '', amount: '', currency: 'USD', ownerId: 'auto' })
}
return (
@ -386,10 +409,24 @@ const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: DealCr
name="contactId"
render={({ field }) => (
<FormItem>
<FormLabel>ID контакта</FormLabel>
<FormControl>
<Input type="number" placeholder="101" {...field} />
</FormControl>
<FormLabel>Контакт</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={contactsLoading || !contacts.length}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={contactsLoading ? 'Загружаем контакты…' : 'Выберите контакт'} />
</SelectTrigger>
</FormControl>
<SelectContent>
{contacts.map((contact) => (
<SelectItem key={contact.id} value={String(contact.id)}>
{contact.name} · #{contact.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{contacts.length ? 'Контакт будет связан со сделкой.' : 'Сначала создайте контакт в разделе «Контакты».'}
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -425,10 +462,23 @@ const DealCreateDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: DealCr
name="ownerId"
render={({ field }) => (
<FormItem>
<FormLabel>ID владельца (необязательно)</FormLabel>
<FormControl>
<Input type="number" placeholder="42" {...field} />
</FormControl>
<FormLabel>Владелец (необязательно)</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={ownerOptions.length ? 'Выберите владельца' : 'Только автоназначение'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Назначить автоматически</SelectItem>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>При отсутствии выбора сделка будет закреплена за вами.</FormDescription>
<FormMessage />
</FormItem>
)}

View File

@ -8,16 +8,18 @@ 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 { 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 { Task } from '@/types/crm'
import type { Deal, Task } from '@/types/crm'
const taskFormSchema = z
.object({
@ -63,6 +65,7 @@ const TasksPage = () => {
}, [tasks, search])
const createTask = useCreateTaskMutation()
const { data: deals = [], isLoading: dealsLoading } = useDealsQuery({ pageSize: 100, orderBy: 'updated_at', order: 'desc' })
const columns = useMemo<ColumnDef<Task>[]>(
() => [
@ -129,7 +132,7 @@ const TasksPage = () => {
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" />
<Switch checked={onlyOpen} onCheckedChange={(value) => setOnlyOpen(value === true)} id="only-open" />
<label htmlFor="only-open" className="cursor-pointer">
Только открытые
</label>
@ -143,6 +146,8 @@ const TasksPage = () => {
open={drawerOpen}
onOpenChange={setDrawerOpen}
isSubmitting={createTask.isPending}
deals={deals}
dealsLoading={dealsLoading}
onSubmit={async (values) => {
const payload = {
deal_id: Number(values.dealId),
@ -168,9 +173,11 @@ interface TaskDrawerProps {
onOpenChange: (open: boolean) => void
onSubmit: (values: TaskFormValues) => Promise<void>
isSubmitting: boolean
deals: Deal[]
dealsLoading: boolean
}
const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: TaskDrawerProps) => {
const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting, deals, dealsLoading }: TaskDrawerProps) => {
const form = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: defaultTaskValues,
@ -208,10 +215,24 @@ const TaskDrawer = ({ open, onOpenChange, onSubmit, isSubmitting }: TaskDrawerPr
name="dealId"
render={({ field }) => (
<FormItem>
<FormLabel>ID сделки</FormLabel>
<FormControl>
<Input type="number" placeholder="201" {...field} />
</FormControl>
<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>
)}