feat: enhance forms with improved select components and data handling for contacts and deals
Test / test (push) Successful in 17s
Details
Test / test (push) Successful in 17s
Details
This commit is contained in:
parent
8718df9686
commit
ecb6daad1b
|
|
@ -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"), {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue