task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

View File

@@ -0,0 +1,465 @@
'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 { 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';
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) : '',
competitor: opportunity?.competitor ?? '',
source: opportunity?.source ?? '',
isHotProject: opportunity?.isHotProject ?? 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,
competitor: value.competitor,
source: value.source,
isHotProject: value.isHotProject ?? 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]);
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='วันที่คาดว่าจะปิดการขาย'
/>
<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'>
<FormSwitchField
name='isHotProject'
label='โครงการด่วน'
description='ใช้สำหรับงานเร่งด่วนหรือโครงการสำคัญเชิงกลยุทธ์'
/>
<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}
/>
</>
);
}