task-d.6.2
This commit is contained in:
@@ -37,6 +37,12 @@ export interface QuotationOpportunityLookup {
|
||||
projectName: string | null;
|
||||
projectLocation: string | null;
|
||||
branchId: string | null;
|
||||
productType: string;
|
||||
projectCloseDate: string | null;
|
||||
deliveryDate: string | null;
|
||||
isHotProject: boolean;
|
||||
hotProjectAutoSuggested: boolean;
|
||||
hotProjectManuallyOverridden: boolean;
|
||||
}
|
||||
|
||||
export interface QuotationSalesmanLookup {
|
||||
@@ -121,7 +127,11 @@ export interface QuotationRecord {
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
chancePercent: number | null;
|
||||
projectCloseDate: string | null;
|
||||
deliveryDate: string | null;
|
||||
isHotProject: boolean;
|
||||
hotProjectAutoSuggested: boolean;
|
||||
hotProjectManuallyOverridden: boolean;
|
||||
competitor: string | null;
|
||||
salesmanId: string | null;
|
||||
salesmanName: string | null;
|
||||
@@ -381,7 +391,11 @@ export interface QuotationMutationPayload {
|
||||
exchangeRate?: number | null;
|
||||
status: string;
|
||||
chancePercent?: number | null;
|
||||
projectCloseDate?: string | null;
|
||||
deliveryDate?: string | null;
|
||||
isHotProject?: boolean;
|
||||
hotProjectAutoSuggested?: boolean;
|
||||
hotProjectManuallyOverridden?: boolean;
|
||||
competitor?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,11 @@ 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 { Input } from '@/components/ui/input';
|
||||
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 { ProjectPartiesEditor, type ProjectPartyEditorItem } from '@/features/crm/components/project-parties-editor';
|
||||
import {
|
||||
CrmCurrencyInput,
|
||||
CrmDateInput,
|
||||
@@ -17,26 +18,16 @@ import {
|
||||
CrmPercentageInput,
|
||||
CrmTextarea
|
||||
} from '@/features/crm/components/crm-form-controls';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 { quotationCustomersOptions } from '../api/queries';
|
||||
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project';
|
||||
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
||||
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
||||
import { quotationCustomersOptions } from '../api/queries';
|
||||
import type {
|
||||
QuotationMutationPayload,
|
||||
QuotationOpportunityLookup,
|
||||
QuotationRecord,
|
||||
QuotationReferenceData
|
||||
} from '../api/types';
|
||||
import { quotationSchema } from '../schemas/quotation.schema';
|
||||
|
||||
type FormState = {
|
||||
opportunityId: string;
|
||||
@@ -53,6 +44,8 @@ type FormState = {
|
||||
exchangeRate: string;
|
||||
status: string;
|
||||
chancePercent: string;
|
||||
projectCloseDate: string;
|
||||
deliveryDate: string;
|
||||
competitor: string;
|
||||
reference: string;
|
||||
notes: string;
|
||||
@@ -63,32 +56,105 @@ type FormState = {
|
||||
sentVia: string;
|
||||
revisionRemark: string;
|
||||
isHotProject: boolean;
|
||||
hotProjectAutoSuggested: boolean;
|
||||
hotProjectManuallyOverridden: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export interface QuotationCreatePrefill {
|
||||
opportunityId?: string | null;
|
||||
customerId?: string | null;
|
||||
contactId?: string | null;
|
||||
projectName?: string | null;
|
||||
projectLocation?: string | null;
|
||||
branchId?: string | null;
|
||||
quotationType?: string | null;
|
||||
projectCloseDate?: string | null;
|
||||
deliveryDate?: string | null;
|
||||
isHotProject?: boolean | null;
|
||||
hotProjectAutoSuggested?: boolean | null;
|
||||
hotProjectManuallyOverridden?: boolean | null;
|
||||
projectParties?: ProjectPartyEditorItem[];
|
||||
}
|
||||
|
||||
type PrefillFieldKey =
|
||||
| 'customerId'
|
||||
| 'contactId'
|
||||
| 'projectName'
|
||||
| 'projectLocation'
|
||||
| 'branchId'
|
||||
| 'quotationType'
|
||||
| 'projectCloseDate'
|
||||
| 'deliveryDate'
|
||||
| 'isHotProject'
|
||||
| 'hotProjectAutoSuggested'
|
||||
| 'hotProjectManuallyOverridden';
|
||||
|
||||
const PREFILL_FIELD_KEYS: PrefillFieldKey[] = [
|
||||
'customerId',
|
||||
'contactId',
|
||||
'projectName',
|
||||
'projectLocation',
|
||||
'branchId',
|
||||
'quotationType',
|
||||
'projectCloseDate',
|
||||
'deliveryDate',
|
||||
'isHotProject',
|
||||
'hotProjectAutoSuggested',
|
||||
'hotProjectManuallyOverridden'
|
||||
];
|
||||
|
||||
function toOpportunityPrefill(
|
||||
opportunity: QuotationOpportunityLookup | undefined,
|
||||
referenceData: QuotationReferenceData
|
||||
): QuotationCreatePrefill | null {
|
||||
if (!opportunity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quotationType = referenceData.quotationTypes.some((item) => item.id === opportunity.productType)
|
||||
? opportunity.productType
|
||||
: null;
|
||||
|
||||
return {
|
||||
opportunityId: opportunity.id,
|
||||
customerId: opportunity.customerId,
|
||||
contactId: opportunity.contactId,
|
||||
projectName: opportunity.projectName,
|
||||
projectLocation: opportunity.projectLocation,
|
||||
branchId: opportunity.branchId,
|
||||
quotationType,
|
||||
projectCloseDate: opportunity.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : null,
|
||||
deliveryDate: opportunity.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : null,
|
||||
isHotProject: opportunity.isHotProject,
|
||||
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden
|
||||
};
|
||||
}
|
||||
|
||||
function toFormState(
|
||||
quotation: QuotationRecord | undefined,
|
||||
referenceData: QuotationReferenceData
|
||||
referenceData: QuotationReferenceData,
|
||||
initialPrefill?: QuotationCreatePrefill | null
|
||||
): FormState {
|
||||
return {
|
||||
opportunityId: quotation?.opportunityId ?? '',
|
||||
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||
contactId: quotation?.contactId ?? '',
|
||||
quotationDate:
|
||||
quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
|
||||
opportunityId: quotation?.opportunityId ?? initialPrefill?.opportunityId ?? '',
|
||||
customerId: quotation?.customerId ?? initialPrefill?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||
contactId: quotation?.contactId ?? initialPrefill?.contactId ?? '',
|
||||
quotationDate: quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
|
||||
validUntil: quotation?.validUntil?.slice(0, 10) ?? '',
|
||||
quotationType: quotation?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
|
||||
projectName: quotation?.projectName ?? '',
|
||||
projectLocation: quotation?.projectLocation ?? '',
|
||||
quotationType:
|
||||
quotation?.quotationType ?? initialPrefill?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
|
||||
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? '',
|
||||
projectLocation: quotation?.projectLocation ?? initialPrefill?.projectLocation ?? '',
|
||||
attention: quotation?.attention ?? '',
|
||||
branchId: quotation?.branchId ?? '',
|
||||
branchId: quotation?.branchId ?? initialPrefill?.branchId ?? '',
|
||||
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '',
|
||||
exchangeRate: String(quotation?.exchangeRate ?? 1),
|
||||
status: quotation?.status ?? referenceData.statuses[0]?.id ?? '',
|
||||
chancePercent:
|
||||
quotation?.chancePercent === null || quotation?.chancePercent === undefined
|
||||
? ''
|
||||
: String(quotation.chancePercent),
|
||||
chancePercent: quotation?.chancePercent !== null && quotation?.chancePercent !== undefined ? String(quotation.chancePercent) : '',
|
||||
projectCloseDate: quotation?.projectCloseDate?.slice(0, 10) ?? initialPrefill?.projectCloseDate ?? '',
|
||||
deliveryDate: quotation?.deliveryDate?.slice(0, 10) ?? initialPrefill?.deliveryDate ?? '',
|
||||
competitor: quotation?.competitor ?? '',
|
||||
reference: quotation?.reference ?? '',
|
||||
notes: quotation?.notes ?? '',
|
||||
@@ -98,25 +164,23 @@ function toFormState(
|
||||
taxRate: String(quotation?.taxRate ?? 0),
|
||||
sentVia: quotation?.sentVia ?? '',
|
||||
revisionRemark: quotation?.revisionRemark ?? '',
|
||||
isHotProject: quotation?.isHotProject ?? false,
|
||||
isHotProject: quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
|
||||
hotProjectAutoSuggested:
|
||||
quotation?.hotProjectAutoSuggested ?? initialPrefill?.hotProjectAutoSuggested ?? false,
|
||||
hotProjectManuallyOverridden:
|
||||
quotation?.hotProjectManuallyOverridden ?? initialPrefill?.hotProjectManuallyOverridden ?? false,
|
||||
isActive: quotation?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required = false,
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
required,
|
||||
children
|
||||
}: React.PropsWithChildren<{ label: string; required?: boolean }>) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='mb-2 text-sm font-medium'>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>
|
||||
{label}
|
||||
{required ? ' *' : ''}
|
||||
</div>
|
||||
@@ -129,20 +193,28 @@ export function QuotationFormSheet({
|
||||
quotation,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
referenceData,
|
||||
initialPrefill
|
||||
}: {
|
||||
quotation?: QuotationRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: QuotationReferenceData;
|
||||
initialPrefill?: QuotationCreatePrefill | null;
|
||||
}) {
|
||||
const isEdit = !!quotation;
|
||||
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
|
||||
const isEdit = Boolean(quotation);
|
||||
const defaultState = useMemo(
|
||||
() => toFormState(quotation, referenceData, initialPrefill),
|
||||
[quotation, referenceData, initialPrefill]
|
||||
);
|
||||
const [state, setState] = useState<FormState>(defaultState);
|
||||
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||
const [dirtyFields, setDirtyFields] = useState<Set<string>>(new Set());
|
||||
const [projectPartiesDirty, setProjectPartiesDirty] = useState(false);
|
||||
|
||||
const projectPartiesQuery = useQuery({
|
||||
...quotationCustomersOptions(quotation?.id ?? ''),
|
||||
enabled: open && !!quotation?.id
|
||||
enabled: open && Boolean(quotation?.id)
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -151,8 +223,9 @@ export function QuotationFormSheet({
|
||||
toast.success('Quotation created successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create quotation')
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create quotation');
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -161,46 +234,127 @@ export function QuotationFormSheet({
|
||||
toast.success('Quotation updated successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update quotation')
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update quotation');
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setState(defaultState);
|
||||
setProjectParties(
|
||||
quotation
|
||||
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
|
||||
key: item.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark ?? ''
|
||||
}))
|
||||
: []
|
||||
);
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
}, [defaultState, open, projectPartiesQuery.data?.items, quotation]);
|
||||
|
||||
setState(defaultState);
|
||||
setDirtyFields(new Set());
|
||||
setProjectPartiesDirty(false);
|
||||
|
||||
if (quotation) {
|
||||
setProjectParties(
|
||||
(projectPartiesQuery.data?.items ?? []).map((item) => ({
|
||||
key: item.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark ?? ''
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectParties(initialPrefill?.projectParties ?? []);
|
||||
}, [defaultState, initialPrefill, open, projectPartiesQuery.data?.items, quotation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggested = isHotProjectSuggested(state.projectCloseDate);
|
||||
|
||||
setState((current) => {
|
||||
if (current.hotProjectManuallyOverridden) {
|
||||
if (current.hotProjectAutoSuggested === suggested) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, hotProjectAutoSuggested: suggested };
|
||||
}
|
||||
|
||||
if (current.hotProjectAutoSuggested === suggested && current.isHotProject === suggested) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
isHotProject: suggested,
|
||||
hotProjectAutoSuggested: suggested
|
||||
};
|
||||
});
|
||||
}, [open, state.projectCloseDate, state.hotProjectManuallyOverridden]);
|
||||
|
||||
const contacts = useMemo(
|
||||
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
|
||||
[referenceData.contacts, state.customerId]
|
||||
);
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
function markDirty(key: string) {
|
||||
setDirtyFields((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K], dirty = true) {
|
||||
setState((current) => ({ ...current, [key]: value }));
|
||||
|
||||
if (dirty) {
|
||||
markDirty(key);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPrefill(prefill: QuotationCreatePrefill | null) {
|
||||
if (!prefill) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
const next = { ...current };
|
||||
|
||||
if (prefill.opportunityId !== undefined) {
|
||||
next.opportunityId = prefill.opportunityId ?? '';
|
||||
}
|
||||
|
||||
for (const key of PREFILL_FIELD_KEYS) {
|
||||
if (dirtyFields.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = prefill[key];
|
||||
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(next[key] as FormState[typeof key]) =
|
||||
typeof next[key] === 'boolean' ? Boolean(value) : ((value ?? '') as FormState[typeof key]);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!projectPartiesDirty && prefill.projectParties) {
|
||||
setProjectParties(prefill.projectParties);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpportunityChange(value: string) {
|
||||
const opportunity = referenceData.opportunities.find((item) => item.id === value);
|
||||
setState((current) => ({
|
||||
...current,
|
||||
opportunityId: value === '__none__' ? '' : value,
|
||||
customerId: opportunity?.customerId ?? current.customerId,
|
||||
contactId: opportunity?.contactId ?? '',
|
||||
projectName: opportunity?.projectName ?? current.projectName,
|
||||
projectLocation: opportunity?.projectLocation ?? current.projectLocation,
|
||||
branchId: opportunity?.branchId ?? current.branchId
|
||||
}));
|
||||
const nextOpportunityId = value === '__none__' ? '' : value;
|
||||
const opportunity = referenceData.opportunities.find((item) => item.id === nextOpportunityId);
|
||||
markDirty('opportunityId');
|
||||
applyPrefill({
|
||||
...toOpportunityPrefill(opportunity, referenceData),
|
||||
opportunityId: nextOpportunityId
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -221,7 +375,11 @@ export function QuotationFormSheet({
|
||||
exchangeRate: state.exchangeRate ? Number(state.exchangeRate) : 1,
|
||||
status: state.status,
|
||||
chancePercent: state.chancePercent ? Number(state.chancePercent) : null,
|
||||
projectCloseDate: state.projectCloseDate || null,
|
||||
deliveryDate: state.deliveryDate || null,
|
||||
isHotProject: state.isHotProject,
|
||||
hotProjectAutoSuggested: state.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: state.hotProjectManuallyOverridden,
|
||||
competitor: state.competitor,
|
||||
reference: state.reference,
|
||||
notes: state.notes,
|
||||
@@ -237,10 +395,18 @@ export function QuotationFormSheet({
|
||||
.map((item) => ({
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
isPrimary: false,
|
||||
remark: item.remark || null
|
||||
}))
|
||||
};
|
||||
|
||||
const parsed = quotationSchema.safeParse(payload);
|
||||
|
||||
if (!parsed.success) {
|
||||
toast.error(parsed.error.issues[0]?.message ?? 'Invalid quotation payload');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && quotation) {
|
||||
await updateMutation.mutateAsync({ id: quotation.id, values: payload });
|
||||
return;
|
||||
@@ -261,253 +427,290 @@ export function QuotationFormSheet({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
|
||||
<div className='grid gap-4 py-4 md:grid-cols-2'>
|
||||
<Field label='Opportunity'>
|
||||
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select opportunity' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No linked opportunity</SelectItem>
|
||||
{referenceData.opportunities.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.code} - {item.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<form id='quotation-form-sheet' onSubmit={onSubmit} className='grid flex-1 gap-4 overflow-auto py-2 md:grid-cols-2'>
|
||||
<Field label='Opportunity'>
|
||||
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select opportunity' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No linked opportunity</SelectItem>
|
||||
{referenceData.opportunities.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.code} - {item.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='ลูกค้าผู้รับใบเสนอราคา'>
|
||||
<Select
|
||||
value={state.customerId}
|
||||
onValueChange={(value) => {
|
||||
setField('customerId', value);
|
||||
setField('contactId', '');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select customer' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.customers.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Customer' required>
|
||||
<Select
|
||||
value={state.customerId}
|
||||
onValueChange={(value) => {
|
||||
setField('customerId', value);
|
||||
setField('contactId', '');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select customer' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.customers.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Contact'>
|
||||
<Select
|
||||
value={state.contactId || '__none__'}
|
||||
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select contact' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No contact</SelectItem>
|
||||
{contacts.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Contact'>
|
||||
<Select
|
||||
value={state.contactId || '__none__'}
|
||||
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select contact' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No contact</SelectItem>
|
||||
{contacts.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Quotation Type' required>
|
||||
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.quotationTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Quotation Type' required>
|
||||
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.quotationTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Quotation Date' required>
|
||||
<CrmDateInput value={state.quotationDate} onChange={(value) => setField('quotationDate', value)} />
|
||||
</Field>
|
||||
<Field label='Quotation Date' required>
|
||||
<CrmDateInput value={state.quotationDate} onChange={(value) => setField('quotationDate', value)} />
|
||||
</Field>
|
||||
|
||||
<Field label='Valid Until'>
|
||||
<CrmDateInput value={state.validUntil} onChange={(value) => setField('validUntil', value)} />
|
||||
</Field>
|
||||
<Field label='Valid Until'>
|
||||
<CrmDateInput value={state.validUntil} onChange={(value) => setField('validUntil', value)} />
|
||||
</Field>
|
||||
|
||||
<Field label='Branch'>
|
||||
<Select value={state.branchId || '__none__'} onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select branch' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||
{referenceData.branches.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Project Close Date'>
|
||||
<CrmDateInput value={state.projectCloseDate} onChange={(value) => setField('projectCloseDate', value)} />
|
||||
</Field>
|
||||
|
||||
<Field label='Status'>
|
||||
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.statuses.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Delivery Date'>
|
||||
<CrmDateInput value={state.deliveryDate} onChange={(value) => setField('deliveryDate', value)} />
|
||||
</Field>
|
||||
|
||||
<Field label='Currency'>
|
||||
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select currency' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.currencies.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Branch'>
|
||||
<Select
|
||||
value={state.branchId || '__none__'}
|
||||
onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select branch' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||
{referenceData.branches.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Exchange Rate'>
|
||||
<CrmNumberInput value={state.exchangeRate} onChange={(value) => setField('exchangeRate', value)} step='0.0001' />
|
||||
</Field>
|
||||
<Field label='Status'>
|
||||
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.statuses.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Project Name'>
|
||||
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
|
||||
</Field>
|
||||
<Field label='Currency'>
|
||||
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select currency' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.currencies.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Project Location'>
|
||||
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
|
||||
</Field>
|
||||
<Field label='Exchange Rate'>
|
||||
<CrmNumberInput value={state.exchangeRate} onChange={(value) => setField('exchangeRate', value)} step='0.0001' />
|
||||
</Field>
|
||||
|
||||
<Field label='Attention'>
|
||||
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
|
||||
</Field>
|
||||
<Field label='Project Name'>
|
||||
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
|
||||
</Field>
|
||||
|
||||
<Field label='Reference'>
|
||||
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
|
||||
</Field>
|
||||
<Field label='Project Location'>
|
||||
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
|
||||
</Field>
|
||||
|
||||
<Field label='Salesman'>
|
||||
<Select value={state.salesmanId || '__none__'} onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select salesman' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||
{referenceData.salesmen.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Attention'>
|
||||
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
|
||||
</Field>
|
||||
|
||||
<Field label='Chance %'>
|
||||
<CrmPercentageInput value={state.chancePercent} onChange={(value) => setField('chancePercent', value)} />
|
||||
</Field>
|
||||
<Field label='Reference'>
|
||||
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
|
||||
</Field>
|
||||
|
||||
<Field label='Discount'>
|
||||
<CrmCurrencyInput value={state.discount} onChange={(value) => setField('discount', value)} currencyLabel='THB' />
|
||||
</Field>
|
||||
<Field label='Salesman'>
|
||||
<Select
|
||||
value={state.salesmanId || '__none__'}
|
||||
onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select salesman' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||
{referenceData.salesmen.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Discount Type'>
|
||||
<Select value={state.discountType || '__none__'} onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select discount type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>None</SelectItem>
|
||||
{referenceData.discountTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Chance %'>
|
||||
<CrmPercentageInput value={state.chancePercent} onChange={(value) => setField('chancePercent', value)} />
|
||||
</Field>
|
||||
|
||||
<Field label='Tax Rate %'>
|
||||
<CrmPercentageInput value={state.taxRate} onChange={(value) => setField('taxRate', value)} />
|
||||
</Field>
|
||||
<Field label='Discount'>
|
||||
<CrmCurrencyInput value={state.discount} onChange={(value) => setField('discount', value)} currencyLabel='THB' />
|
||||
</Field>
|
||||
|
||||
<Field label='Sent Via'>
|
||||
<Select value={state.sentVia || '__none__'} onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select sent via' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Not sent yet</SelectItem>
|
||||
{referenceData.sentVias.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label='Discount Type'>
|
||||
<Select
|
||||
value={state.discountType || '__none__'}
|
||||
onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select discount type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>None</SelectItem>
|
||||
{referenceData.discountTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Competitor'>
|
||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||
</Field>
|
||||
<Field label='Tax Rate %'>
|
||||
<CrmPercentageInput value={state.taxRate} onChange={(value) => setField('taxRate', value)} />
|
||||
</Field>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<ProjectPartiesEditor
|
||||
customers={referenceData.customers}
|
||||
roles={referenceData.projectPartyRoles}
|
||||
items={projectParties}
|
||||
onChange={setProjectParties}
|
||||
<Field label='Sent Via'>
|
||||
<Select
|
||||
value={state.sentVia || '__none__'}
|
||||
onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select sent via' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Not sent yet</SelectItem>
|
||||
{referenceData.sentVias.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Competitor'>
|
||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||
</Field>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<ProjectPartiesEditor
|
||||
customers={referenceData.customers}
|
||||
roles={referenceData.projectPartyRoles}
|
||||
items={projectParties}
|
||||
onChange={(items) => {
|
||||
setProjectParties(items);
|
||||
setProjectPartiesDirty(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<Field label='Notes'>
|
||||
<CrmTextarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' size='md' />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<Field label='Revision Remark'>
|
||||
<CrmTextarea
|
||||
value={state.revisionRemark}
|
||||
onChange={(e) => setField('revisionRemark', e.target.value)}
|
||||
placeholder='Revision note or approval context'
|
||||
size='md'
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<Field label='Notes'>
|
||||
<CrmTextarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' size='md' />
|
||||
</Field>
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Hot Project</div>
|
||||
<div className='text-muted-foreground text-sm'>Auto-suggested when project close date is within 3 months.</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={state.isHotProject}
|
||||
onCheckedChange={(checked) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
isHotProject: checked,
|
||||
hotProjectManuallyOverridden: true,
|
||||
hotProjectAutoSuggested: isHotProjectSuggested(current.projectCloseDate)
|
||||
}));
|
||||
markDirty('isHotProject');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<Field label='Revision Remark'>
|
||||
<CrmTextarea value={state.revisionRemark} onChange={(e) => setField('revisionRemark', e.target.value)} placeholder='Revision note or approval context' size='md' />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Hot Project</div>
|
||||
<div className='text-muted-foreground text-sm'>Highlight urgent or strategic deals.</div>
|
||||
</div>
|
||||
<Switch checked={state.isHotProject} onCheckedChange={(checked) => setField('isHotProject', checked)} />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Active Record</div>
|
||||
<div className='text-muted-foreground text-sm'>Inactive quotations stay as history only.</div>
|
||||
</div>
|
||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setField('isActive', checked)} />
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Active Record</div>
|
||||
<div className='text-muted-foreground text-sm'>Inactive quotations stay as history only.</div>
|
||||
</div>
|
||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setField('isActive', checked)} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -535,10 +738,10 @@ export function QuotationFormSheetTrigger({
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Quotation
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Quotation
|
||||
</Button>
|
||||
<QuotationFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as z from 'zod';
|
||||
import { isValidDateOnlyString } from '@/features/crm/shared/hot-project';
|
||||
|
||||
function coerceOptionalNumber(message: string) {
|
||||
return z.preprocess((value) => {
|
||||
@@ -38,41 +39,65 @@ function coerceRequiredNumber(message: string) {
|
||||
}, z.number().refine((value) => !Number.isNaN(value), message));
|
||||
}
|
||||
|
||||
function optionalDateOnly(message: string) {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine((value) => !value || isValidDateOnlyString(value), message);
|
||||
}
|
||||
|
||||
function requiredDateOnly(message: string) {
|
||||
return z
|
||||
.string()
|
||||
.min(1, message)
|
||||
.refine((value) => isValidDateOnlyString(value), message);
|
||||
}
|
||||
|
||||
export const quotationSchema = z.object({
|
||||
opportunityId: z.string().optional().nullable(),
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
quotationDate: z.string().min(1, 'Quotation date is required'),
|
||||
validUntil: z.string().optional().nullable(),
|
||||
quotationType: z.string().min(1, 'Please select a quotation type'),
|
||||
quotationDate: requiredDateOnly('Quotation date must be YYYY-MM-DD'),
|
||||
validUntil: optionalDateOnly('Valid until date must be YYYY-MM-DD'),
|
||||
quotationType: z.string().min(1, 'Please select quotation type'),
|
||||
projectName: z.string().optional(),
|
||||
projectLocation: z.string().optional(),
|
||||
attention: z.string().optional(),
|
||||
branchId: z.string().optional().nullable(),
|
||||
currency: z.string().min(1, 'Please select a currency'),
|
||||
exchangeRate: coerceOptionalNumber('Exchange rate must be a number'),
|
||||
status: z.string().min(1, 'Please select a status'),
|
||||
chancePercent: coerceOptionalNumber('Chance percent must be a number')
|
||||
.refine(
|
||||
(value) => value === undefined || (value >= 0 && value <= 100),
|
||||
'Chance percent must be between 0 and 100'
|
||||
),
|
||||
currency: z.string().min(1, 'Please select currency'),
|
||||
exchangeRate: coerceOptionalNumber('Exchange rate must be number'),
|
||||
status: z.string().min(1, 'Please select status'),
|
||||
chancePercent: coerceOptionalNumber('Chance percent must be number').refine(
|
||||
(value) => value === undefined || (value >= 0 && value <= 100),
|
||||
'Chance percent must be between 0 and 100'
|
||||
),
|
||||
projectCloseDate: optionalDateOnly('Project close date must be YYYY-MM-DD'),
|
||||
deliveryDate: optionalDateOnly('Delivery date must be YYYY-MM-DD'),
|
||||
isHotProject: z.boolean().default(false),
|
||||
hotProjectAutoSuggested: z.boolean().default(false),
|
||||
hotProjectManuallyOverridden: z.boolean().default(false),
|
||||
competitor: z.string().optional(),
|
||||
reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
salesmanId: z.string().optional().nullable(),
|
||||
discount: coerceOptionalNumber('Discount must be a number'),
|
||||
discount: coerceOptionalNumber('Discount must be number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'Discount must be 0 or greater'
|
||||
),
|
||||
discountType: z.string().optional().nullable(),
|
||||
taxRate: coerceOptionalNumber('Tax rate must be a number'),
|
||||
isActive: z.boolean().default(true),
|
||||
taxRate: coerceOptionalNumber('Tax rate must be number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'Tax rate must be 0 or greater'
|
||||
),
|
||||
sentVia: z.string().optional().nullable(),
|
||||
revisionRemark: z.string().optional().nullable(),
|
||||
isActive: z.boolean().default(true),
|
||||
projectParties: z
|
||||
.array(
|
||||
z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
role: z.string().min(1, 'Please select a role'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
role: z.string().min(1, 'Please select role'),
|
||||
isPrimary: z.boolean().default(false).optional(),
|
||||
remark: z.string().nullable().optional()
|
||||
})
|
||||
@@ -81,70 +106,70 @@ export const quotationSchema = z.object({
|
||||
});
|
||||
|
||||
export const quotationItemSchema = z.object({
|
||||
productType: z.string().min(1, 'Please select a product type'),
|
||||
productType: z.string().min(1, 'Please select product type'),
|
||||
description: z.string().min(2, 'Description must be at least 2 characters'),
|
||||
quantity: coerceRequiredNumber('Quantity must be a number').refine(
|
||||
quantity: coerceRequiredNumber('Quantity must be number').refine(
|
||||
(value) => value > 0,
|
||||
'Quantity must be greater than 0'
|
||||
),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: coerceRequiredNumber('Unit price must be a number').refine(
|
||||
unitPrice: coerceRequiredNumber('Unit price must be number').refine(
|
||||
(value) => value >= 0,
|
||||
'Unit price must be 0 or greater'
|
||||
),
|
||||
discount: coerceOptionalNumber('Discount must be a number').refine(
|
||||
discount: coerceOptionalNumber('Discount must be number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'Discount must be 0 or greater'
|
||||
),
|
||||
discountType: z.string().nullable().optional(),
|
||||
taxRate: coerceOptionalNumber('Tax rate must be a number').refine(
|
||||
taxRate: coerceOptionalNumber('Tax rate must be number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'Tax rate must be 0 or greater'
|
||||
),
|
||||
notes: z.string().optional(),
|
||||
sortOrder: coerceOptionalNumber('Sort order must be a number')
|
||||
sortOrder: coerceOptionalNumber('Sort order must be number')
|
||||
});
|
||||
|
||||
export const quotationCustomerSchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
role: z.string().min(1, 'Please select a role'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
role: z.string().min(1, 'Please select role'),
|
||||
isPrimary: z.boolean().default(false),
|
||||
remark: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const quotationTopicSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
topicType: z.string().min(1, 'Please select a topic type'),
|
||||
topicType: z.string().min(1, 'Please select topic type'),
|
||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||
sortOrder: coerceOptionalNumber('Sort order must be a number'),
|
||||
sortOrder: coerceOptionalNumber('Sort order must be number'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
content: z.string().min(1, 'Topic item content is required'),
|
||||
sortOrder: coerceOptionalNumber('Sort order must be a number')
|
||||
content: z.string().min(1, 'Topic item content required'),
|
||||
sortOrder: coerceOptionalNumber('Sort order must be number')
|
||||
})
|
||||
)
|
||||
.min(1, 'At least one topic item is required')
|
||||
.min(1, 'At least one topic item required')
|
||||
});
|
||||
|
||||
export const quotationFollowupSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||
followupType: z.string().min(1, 'Please select a follow-up type'),
|
||||
followupDate: requiredDateOnly('Follow-up date must be YYYY-MM-DD'),
|
||||
followupType: z.string().min(1, 'Please select follow-up type'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
outcome: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
nextFollowupDate: z.string().optional().nullable(),
|
||||
nextFollowupDate: optionalDateOnly('Next follow-up date must be YYYY-MM-DD'),
|
||||
nextAction: z.string().optional()
|
||||
});
|
||||
|
||||
export const quotationAttachmentSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
fileName: z.string().min(1, 'Stored file name is required'),
|
||||
originalFileName: z.string().min(1, 'Original file name is required'),
|
||||
filePath: z.string().min(1, 'File path is required'),
|
||||
fileSize: coerceOptionalNumber('File size must be a number'),
|
||||
fileName: z.string().min(1, 'Stored file name required'),
|
||||
originalFileName: z.string().min(1, 'Original file name required'),
|
||||
filePath: z.string().min(1, 'File path required'),
|
||||
fileSize: coerceOptionalNumber('File size must be number'),
|
||||
fileType: z.string().nullable().optional(),
|
||||
description: z.string().optional()
|
||||
});
|
||||
@@ -159,4 +184,3 @@ export type QuotationCustomerFormValues = z.input<typeof quotationCustomerSchema
|
||||
export type QuotationTopicFormValues = z.input<typeof quotationTopicSchema>;
|
||||
export type QuotationFollowupFormValues = z.input<typeof quotationFollowupSchema>;
|
||||
export type QuotationAttachmentFormValues = z.input<typeof quotationAttachmentSchema>;
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
import { resolveHotProjectState } from '@/features/crm/shared/hot-project';
|
||||
|
||||
const QUOTATION_OPTION_CATEGORIES = {
|
||||
status: 'crm_quotation_status',
|
||||
@@ -271,7 +272,11 @@ function mapQuotationRecord(
|
||||
taxAmount: row.taxAmount,
|
||||
totalAmount: row.totalAmount,
|
||||
chancePercent: row.chancePercent,
|
||||
projectCloseDate: row.projectCloseDate?.toISOString() ?? null,
|
||||
deliveryDate: row.deliveryDate?.toISOString() ?? null,
|
||||
isHotProject: row.isHotProject,
|
||||
hotProjectAutoSuggested: row.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: row.hotProjectManuallyOverridden,
|
||||
competitor: row.competitor,
|
||||
salesmanId: row.salesmanId,
|
||||
salesmanName: options?.salesmanName ?? null,
|
||||
@@ -1242,7 +1247,13 @@ export async function getQuotationReferenceData(
|
||||
title: opportunity.title,
|
||||
projectName: opportunity.projectName,
|
||||
projectLocation: opportunity.projectLocation,
|
||||
branchId: opportunity.branchId
|
||||
branchId: opportunity.branchId,
|
||||
productType: opportunity.productType,
|
||||
projectCloseDate: opportunity.projectCloseDate?.toISOString() ?? null,
|
||||
deliveryDate: opportunity.deliveryDate?.toISOString() ?? null,
|
||||
isHotProject: opportunity.isHotProject,
|
||||
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden
|
||||
})),
|
||||
salesmen: salesmen.map<QuotationSalesmanLookup>((salesman) => ({
|
||||
id: salesman.id,
|
||||
@@ -1464,11 +1475,17 @@ export async function createQuotation(
|
||||
payload.status
|
||||
);
|
||||
|
||||
const opportunity = await assertOpportunityLinkIntegrity({
|
||||
organizationId,
|
||||
opportunityId: payload.opportunityId,
|
||||
customerId: payload.customerId
|
||||
});
|
||||
const opportunity = await assertOpportunityLinkIntegrity({
|
||||
organizationId,
|
||||
opportunityId: payload.opportunityId,
|
||||
customerId: payload.customerId
|
||||
});
|
||||
const hotProjectState = resolveHotProjectState({
|
||||
projectCloseDate: payload.projectCloseDate ?? opportunity?.projectCloseDate?.toISOString().slice(0, 10) ?? null,
|
||||
isHotProject: payload.isHotProject ?? opportunity?.isHotProject ?? false,
|
||||
hotProjectManuallyOverridden:
|
||||
payload.hotProjectManuallyOverridden ?? opportunity?.hotProjectManuallyOverridden ?? false
|
||||
});
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: 'quotation',
|
||||
@@ -1503,10 +1520,16 @@ const documentCode = await generateNextDocumentCode({
|
||||
exchangeRate: payload.exchangeRate ?? 1,
|
||||
discount: payload.discount ?? 0,
|
||||
discountType: payload.discountType ?? null,
|
||||
taxRate: payload.taxRate ?? 0,
|
||||
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
|
||||
isHotProject: payload.isHotProject ?? opportunity?.isHotProject ?? false,
|
||||
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
|
||||
taxRate: payload.taxRate ?? 0,
|
||||
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
|
||||
projectCloseDate: payload.projectCloseDate
|
||||
? new Date(payload.projectCloseDate)
|
||||
: opportunity?.projectCloseDate ?? null,
|
||||
deliveryDate: payload.deliveryDate ? new Date(payload.deliveryDate) : opportunity?.deliveryDate ?? null,
|
||||
isHotProject: hotProjectState.isHotProject,
|
||||
hotProjectAutoSuggested: hotProjectState.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: hotProjectState.hotProjectManuallyOverridden,
|
||||
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
|
||||
salesmanId: payload.salesmanId ?? null,
|
||||
isSent: false,
|
||||
sentVia: payload.sentVia ?? null,
|
||||
@@ -1567,14 +1590,20 @@ export async function updateQuotation(
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||
const opportunity = await assertOpportunityLinkIntegrity({
|
||||
organizationId,
|
||||
opportunityId: payload.opportunityId,
|
||||
customerId: payload.customerId
|
||||
});
|
||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||
const opportunity = await assertOpportunityLinkIntegrity({
|
||||
organizationId,
|
||||
opportunityId: payload.opportunityId,
|
||||
customerId: payload.customerId
|
||||
});
|
||||
const hotProjectState = resolveHotProjectState({
|
||||
projectCloseDate: payload.projectCloseDate ?? opportunity?.projectCloseDate?.toISOString().slice(0, 10) ?? null,
|
||||
isHotProject: payload.isHotProject ?? existing.isHotProject,
|
||||
hotProjectManuallyOverridden:
|
||||
payload.hotProjectManuallyOverridden ?? existing.hotProjectManuallyOverridden
|
||||
});
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(crmQuotations)
|
||||
.set({
|
||||
@@ -1596,10 +1625,16 @@ export async function updateQuotation(
|
||||
exchangeRate: payload.exchangeRate ?? 1,
|
||||
discount: payload.discount ?? 0,
|
||||
discountType: payload.discountType ?? null,
|
||||
taxRate: payload.taxRate ?? 0,
|
||||
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
|
||||
taxRate: payload.taxRate ?? 0,
|
||||
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
|
||||
projectCloseDate: payload.projectCloseDate
|
||||
? new Date(payload.projectCloseDate)
|
||||
: opportunity?.projectCloseDate ?? null,
|
||||
deliveryDate: payload.deliveryDate ? new Date(payload.deliveryDate) : opportunity?.deliveryDate ?? null,
|
||||
isHotProject: hotProjectState.isHotProject,
|
||||
hotProjectAutoSuggested: hotProjectState.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: hotProjectState.hotProjectManuallyOverridden,
|
||||
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
|
||||
salesmanId: payload.salesmanId ?? null,
|
||||
sentVia: payload.sentVia ?? null,
|
||||
isActive: payload.isActive ?? true,
|
||||
|
||||
Reference in New Issue
Block a user