524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { Icons } from '@/components/icons';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
ProjectPartiesEditor,
|
|
type ProjectPartyEditorItem
|
|
} from '@/features/crm/components/project-parties-editor';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle
|
|
} from '@/components/ui/sheet';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
|
import { opportunityProjectPartiesOptions } from '../api/queries';
|
|
import { createOpportunityMutation, updateOpportunityMutation } from '../api/mutations';
|
|
import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenceData } from '../api/types';
|
|
import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema';
|
|
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project';
|
|
|
|
function toDefaultValues(
|
|
opportunity: OpportunityRecord | undefined,
|
|
referenceData: OpportunityReferenceData
|
|
): OpportunityFormValues {
|
|
return {
|
|
customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? '',
|
|
contactId: opportunity?.contactId ?? undefined,
|
|
assignedToUserId: opportunity?.assignedToUserId ?? undefined,
|
|
title: opportunity?.title ?? '',
|
|
description: opportunity?.description ?? '',
|
|
requirement: opportunity?.requirement ?? '',
|
|
projectName: opportunity?.projectName ?? '',
|
|
projectLocation: opportunity?.projectLocation ?? '',
|
|
branchId: opportunity?.branchId ?? undefined,
|
|
productType: opportunity?.productType ?? referenceData.productTypes[0]?.id ?? '',
|
|
status: opportunity?.status ?? referenceData.statuses[0]?.id ?? '',
|
|
priority:
|
|
opportunity?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
|
leadChannel: opportunity?.leadChannel ?? undefined,
|
|
estimatedValue: opportunity?.estimatedValue ?? undefined,
|
|
chancePercent: opportunity?.chancePercent ?? undefined,
|
|
expectedCloseDate: opportunity?.expectedCloseDate ? opportunity.expectedCloseDate.slice(0, 10) : '',
|
|
projectCloseDate: opportunity?.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : '',
|
|
deliveryDate: opportunity?.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : '',
|
|
competitor: opportunity?.competitor ?? '',
|
|
source: opportunity?.source ?? '',
|
|
isHotProject: opportunity?.isHotProject ?? false,
|
|
hotProjectAutoSuggested: opportunity?.hotProjectAutoSuggested ?? false,
|
|
hotProjectManuallyOverridden: opportunity?.hotProjectManuallyOverridden ?? false,
|
|
notes: opportunity?.notes ?? '',
|
|
isActive: opportunity?.isActive ?? true
|
|
};
|
|
}
|
|
|
|
export function OpportunityFormSheet({
|
|
opportunity,
|
|
open,
|
|
onOpenChange,
|
|
referenceData,
|
|
workspace = 'opportunity'
|
|
}: {
|
|
opportunity?: OpportunityRecord;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
referenceData: OpportunityReferenceData;
|
|
workspace?: 'lead' | 'opportunity';
|
|
}) {
|
|
const isEdit = !!opportunity;
|
|
const defaultValues = useMemo(
|
|
() => toDefaultValues(opportunity, referenceData),
|
|
[opportunity, referenceData]
|
|
);
|
|
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
|
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
|
const {
|
|
FormTextField,
|
|
FormTextareaField,
|
|
FormSelectField,
|
|
FormSwitchField,
|
|
FormDatePickerField,
|
|
FormCurrencyField,
|
|
FormPercentageField
|
|
} = useFormFields<OpportunityFormValues>();
|
|
const projectPartiesQuery = useQuery({
|
|
...opportunityProjectPartiesOptions(opportunity?.id ?? ''),
|
|
enabled: open && !!opportunity?.id
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
...createOpportunityMutation,
|
|
onSuccess: () => {
|
|
toast.success(`สร้าง${recordLabel}สำเร็จ`);
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) =>
|
|
toast.error(error instanceof Error ? error.message : `ไม่สามารถสร้าง${recordLabel}ได้`)
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
...updateOpportunityMutation,
|
|
onSuccess: () => {
|
|
toast.success(`อัปเดต${recordLabel}สำเร็จ`);
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) =>
|
|
toast.error(error instanceof Error ? error.message : `ไม่สามารถอัปเดต${recordLabel}ได้`)
|
|
});
|
|
|
|
const contactsForCustomer = referenceData.contacts.filter(
|
|
(contact) => contact.customerId === selectedCustomerId
|
|
);
|
|
const selectedCustomer = referenceData.customers.find((customer) => customer.id === selectedCustomerId);
|
|
|
|
const form = useAppForm({
|
|
defaultValues,
|
|
validators: {
|
|
onSubmit: opportunitySchema
|
|
},
|
|
onSubmit: async ({ value }) => {
|
|
const payload: OpportunityMutationPayload = {
|
|
customerId: value.customerId,
|
|
contactId: value.contactId || null,
|
|
assignedToUserId: value.assignedToUserId || null,
|
|
title: value.title,
|
|
description: value.description,
|
|
requirement: value.requirement,
|
|
projectName: value.projectName,
|
|
projectLocation: value.projectLocation,
|
|
branchId: value.branchId || null,
|
|
productType: value.productType,
|
|
status: value.status,
|
|
priority: value.priority,
|
|
leadChannel: value.leadChannel || null,
|
|
estimatedValue:
|
|
typeof value.estimatedValue === 'number' ? value.estimatedValue : null,
|
|
chancePercent:
|
|
typeof value.chancePercent === 'number' ? value.chancePercent : null,
|
|
expectedCloseDate: value.expectedCloseDate || null,
|
|
projectCloseDate: value.projectCloseDate || null,
|
|
deliveryDate: value.deliveryDate || null,
|
|
competitor: value.competitor,
|
|
source: value.source,
|
|
isHotProject: value.isHotProject ?? false,
|
|
hotProjectAutoSuggested: value.hotProjectAutoSuggested ?? false,
|
|
hotProjectManuallyOverridden: value.hotProjectManuallyOverridden ?? false,
|
|
notes: value.notes,
|
|
isActive: value.isActive ?? true,
|
|
projectParties: projectParties
|
|
.filter((item) => item.customerId && item.role)
|
|
.map((item) => ({
|
|
customerId: item.customerId,
|
|
role: item.role,
|
|
remark: item.remark || null
|
|
}))
|
|
};
|
|
|
|
if (isEdit && opportunity) {
|
|
await updateMutation.mutateAsync({ id: opportunity.id, values: payload });
|
|
return;
|
|
}
|
|
|
|
await createMutation.mutateAsync(payload);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
setSelectedCustomerId(defaultValues.customerId);
|
|
form.reset(defaultValues);
|
|
setProjectParties(
|
|
opportunity
|
|
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
|
|
key: item.id,
|
|
customerId: item.customerId,
|
|
role: item.role,
|
|
remark: item.remark ?? ''
|
|
}))
|
|
: []
|
|
);
|
|
}, [defaultValues, opportunity, form, open, projectPartiesQuery.data?.items]);
|
|
|
|
useEffect(() => {
|
|
if (!open || isEdit) {
|
|
return;
|
|
}
|
|
|
|
const ownerUserId =
|
|
referenceData.customers.find((customer) => customer.id === selectedCustomerId)?.ownerUserId ?? '';
|
|
|
|
form.setFieldValue('assignedToUserId', ownerUserId);
|
|
}, [form, isEdit, open, referenceData.customers, selectedCustomerId]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
const projectCloseDate = form.getFieldValue('projectCloseDate');
|
|
const manuallyOverridden = form.getFieldValue('hotProjectManuallyOverridden');
|
|
|
|
if (manuallyOverridden) {
|
|
form.setFieldValue('hotProjectAutoSuggested', isHotProjectSuggested(projectCloseDate));
|
|
return;
|
|
}
|
|
|
|
const suggested = isHotProjectSuggested(projectCloseDate);
|
|
form.setFieldValue('hotProjectAutoSuggested', suggested);
|
|
form.setFieldValue('isHotProject', suggested);
|
|
}, [form, open, form.state.values.projectCloseDate, form.state.values.hotProjectManuallyOverridden]);
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
|
|
const recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย';
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
|
<SheetHeader>
|
|
<SheetTitle>{isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`}</SheetTitle>
|
|
<SheetDescription>
|
|
{workspace === 'lead'
|
|
? 'บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย'
|
|
: 'บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา'}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className='flex-1 overflow-auto'>
|
|
<form.AppForm>
|
|
<form.Form id='opportunity-form-sheet' className='grid gap-4 md:grid-cols-2'>
|
|
<form.AppField
|
|
name='customerId'
|
|
children={(field) => (
|
|
<field.FieldSet>
|
|
<field.Field>
|
|
<field.FieldLabel>ลูกค้าผู้รับใบเสนอราคา *</field.FieldLabel>
|
|
<Select
|
|
value={field.state.value ?? ''}
|
|
onValueChange={(value) => {
|
|
field.handleChange(value);
|
|
field.handleBlur();
|
|
setSelectedCustomerId(value);
|
|
form.setFieldValue('contactId', '');
|
|
}}
|
|
>
|
|
<SelectTrigger aria-label='ลูกค้า'>
|
|
<SelectValue placeholder='เลือกลูกค้า' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{referenceData.customers.map((customer) => (
|
|
<SelectItem key={customer.id} value={customer.id}>
|
|
{customer.name} ({customer.code})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</field.Field>
|
|
<field.FieldError />
|
|
</field.FieldSet>
|
|
)}
|
|
/>
|
|
|
|
<form.AppField
|
|
name='contactId'
|
|
children={(field) => (
|
|
<field.FieldSet>
|
|
<field.Field>
|
|
<field.FieldLabel>ผู้ติดต่อ</field.FieldLabel>
|
|
<Select
|
|
value={field.state.value ?? ''}
|
|
onValueChange={(value) => {
|
|
field.handleChange(value === '__none__' ? '' : value);
|
|
field.handleBlur();
|
|
}}
|
|
>
|
|
<SelectTrigger aria-label='ผู้ติดต่อ'>
|
|
<SelectValue placeholder='เลือกผู้ติดต่อ' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{contactsForCustomer.length ? (
|
|
contactsForCustomer.map((contact) => (
|
|
<SelectItem key={contact.id} value={contact.id}>
|
|
{contact.name}
|
|
{contact.isPrimary ? ' (ผู้ติดต่อหลัก)' : ''}
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value='__none__'>ไม่เลือกผู้ติดต่อ</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</field.Field>
|
|
<field.FieldError />
|
|
</field.FieldSet>
|
|
)}
|
|
/>
|
|
<FormSelectField
|
|
name='assignedToUserId'
|
|
label='Suggested Sales Owner'
|
|
description={
|
|
selectedCustomer?.ownerName
|
|
? `Suggested from customer owner: ${selectedCustomer.ownerName}`
|
|
: 'Optional sales owner suggestion for this lead or opportunity'
|
|
}
|
|
options={referenceData.assignableUsers.map((user) => ({
|
|
value: user.id,
|
|
label: user.name
|
|
}))}
|
|
/>
|
|
|
|
<FormTextField
|
|
name='title'
|
|
label='ชื่อรายการ'
|
|
required
|
|
placeholder='Warehouse crane upgrade opportunity'
|
|
/>
|
|
<FormTextField
|
|
name='projectName'
|
|
label='ชื่อโครงการ'
|
|
placeholder='Bangna Warehouse Expansion'
|
|
/>
|
|
<FormTextField
|
|
name='projectLocation'
|
|
label='สถานที่โครงการ'
|
|
placeholder='Bangkok'
|
|
/>
|
|
<FormTextField
|
|
name='source'
|
|
label='ที่มา'
|
|
placeholder='Existing customer referral'
|
|
/>
|
|
<FormSelectField
|
|
name='branchId'
|
|
label='สาขา'
|
|
options={referenceData.branches.map((branch) => ({
|
|
value: branch.id,
|
|
label: branch.name
|
|
}))}
|
|
/>
|
|
<FormSelectField
|
|
name='productType'
|
|
label='ประเภทสินค้า'
|
|
required
|
|
options={referenceData.productTypes.map((item) => ({
|
|
value: item.id,
|
|
label: item.label
|
|
}))}
|
|
/>
|
|
<FormSelectField
|
|
name='status'
|
|
label='สถานะ'
|
|
required
|
|
options={referenceData.statuses.map((item) => ({
|
|
value: item.id,
|
|
label: item.label
|
|
}))}
|
|
/>
|
|
<FormSelectField
|
|
name='priority'
|
|
label='ความสำคัญ'
|
|
required
|
|
options={referenceData.priorities.map((item) => ({
|
|
value: item.id,
|
|
label: item.label
|
|
}))}
|
|
/>
|
|
<FormSelectField
|
|
name='leadChannel'
|
|
label='ที่มาของลีด'
|
|
options={referenceData.leadChannels.map((item) => ({
|
|
value: item.id,
|
|
label: item.label
|
|
}))}
|
|
/>
|
|
<FormCurrencyField
|
|
name='estimatedValue'
|
|
label='มูลค่าประมาณการ'
|
|
currencyLabel='THB'
|
|
placeholder='2500000'
|
|
/>
|
|
<FormPercentageField
|
|
name='chancePercent'
|
|
label='โอกาสปิดการขาย %'
|
|
placeholder='60'
|
|
/>
|
|
<FormDatePickerField
|
|
name='expectedCloseDate'
|
|
label='วันที่คาดว่าจะปิดการขาย'
|
|
/>
|
|
<FormDatePickerField
|
|
name='projectCloseDate'
|
|
label='Project Close Date'
|
|
/>
|
|
<FormDatePickerField
|
|
name='deliveryDate'
|
|
label='Delivery Date'
|
|
/>
|
|
<FormTextField name='competitor' label='คู่แข่ง' placeholder='Other vendor name' />
|
|
<div className='md:col-span-2'>
|
|
<FormTextareaField
|
|
name='description'
|
|
size='md'
|
|
label='รายละเอียด'
|
|
placeholder='สรุปภาพรวมของลีดหรือโอกาสขาย'
|
|
/>
|
|
</div>
|
|
<div className='md:col-span-2'>
|
|
<FormTextareaField
|
|
name='requirement'
|
|
size='md'
|
|
label='ความต้องการ'
|
|
placeholder='ความต้องการเชิงเทคนิคหรือเชิงพาณิชย์'
|
|
/>
|
|
</div>
|
|
<div className='md:col-span-2'>
|
|
<FormTextareaField
|
|
name='notes'
|
|
size='md'
|
|
label='หมายเหตุ'
|
|
placeholder='หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน'
|
|
/>
|
|
</div>
|
|
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
|
<form.AppField
|
|
name='isHotProject'
|
|
children={(field) => (
|
|
<field.FieldSet>
|
|
<field.Field orientation='horizontal'>
|
|
<div className='flex flex-1 flex-col gap-1.5 leading-snug'>
|
|
<field.FieldLabel className='text-base'>โครงการด่วน</field.FieldLabel>
|
|
<field.FieldDescription>
|
|
ใช้สำหรับงานเร่งด่วนหรือโครงการสำคัญเชิงกลยุทธ์
|
|
</field.FieldDescription>
|
|
</div>
|
|
<Switch
|
|
checked={Boolean(field.state.value)}
|
|
onCheckedChange={(checked) => {
|
|
field.handleChange(checked);
|
|
field.handleBlur();
|
|
form.setFieldValue('hotProjectManuallyOverridden', true);
|
|
form.setFieldValue(
|
|
'hotProjectAutoSuggested',
|
|
isHotProjectSuggested(form.getFieldValue('projectCloseDate'))
|
|
);
|
|
}}
|
|
/>
|
|
</field.Field>
|
|
<field.FieldError />
|
|
</field.FieldSet>
|
|
)}
|
|
/>
|
|
<FormSwitchField
|
|
name='isActive'
|
|
label='ใช้งานอยู่'
|
|
description='รายการที่ปิดใช้งานจะยังอยู่ในประวัติ แต่ไม่ควรถูกดำเนินการต่อ'
|
|
/>
|
|
</div>
|
|
<div className='md:col-span-2'>
|
|
<ProjectPartiesEditor
|
|
customers={referenceData.customers}
|
|
roles={referenceData.projectPartyRoles}
|
|
items={projectParties}
|
|
onChange={setProjectParties}
|
|
/>
|
|
</div>
|
|
</form.Form>
|
|
</form.AppForm>
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
|
ยกเลิก
|
|
</Button>
|
|
<Button type='submit' form='opportunity-form-sheet' isLoading={isPending}>
|
|
<Icons.check className='mr-2 h-4 w-4' />
|
|
{isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
export function OpportunityFormSheetTrigger({
|
|
referenceData,
|
|
workspace = 'opportunity'
|
|
}: {
|
|
referenceData: OpportunityReferenceData;
|
|
workspace?: 'lead' | 'opportunity';
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
|
|
return (
|
|
<>
|
|
<Button onClick={() => setOpen(true)}>
|
|
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'เพิ่มลีด' : 'เพิ่มโอกาสขาย'}
|
|
</Button>
|
|
<OpportunityFormSheet
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
referenceData={referenceData}
|
|
workspace={workspace}
|
|
/>
|
|
</>
|
|
);
|
|
}
|