task-d.6.2
This commit is contained in:
@@ -40,6 +40,9 @@ export function LeadDetail({
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(leadByIdOptions(leadId));
|
||||
const lead = data.lead;
|
||||
const priorityLabel =
|
||||
referenceData.priorities.find((priority) => priority.id === lead.priority)?.label ??
|
||||
lead.priority;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
|
||||
@@ -96,7 +99,7 @@ export function LeadDetail({
|
||||
<FieldItem label='Project Location' value={lead.projectLocation} />
|
||||
<FieldItem label='Lead Source' value={lead.leadChannelLabel} />
|
||||
<FieldItem label='Awareness' value={lead.awarenessLabel} />
|
||||
<FieldItem label='Priority' value={lead.priority} />
|
||||
<FieldItem label='Priority' value={priorityLabel} />
|
||||
<FieldItem label='Marketing Owner' value={lead.ownerMarketingName} />
|
||||
<FieldItem label='Suggested Sales Owner' value={lead.suggestedSalesOwnerName} />
|
||||
<FieldItem label='Assigned Sales Owner' value={lead.assignedSalesOwnerName} />
|
||||
|
||||
@@ -100,10 +100,14 @@ export interface OpportunityRecord {
|
||||
estimatedValue: number | null;
|
||||
chancePercent: number | null;
|
||||
expectedCloseDate: string | null;
|
||||
projectCloseDate: string | null;
|
||||
deliveryDate: string | null;
|
||||
competitor: string | null;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
hotProjectAutoSuggested: boolean;
|
||||
hotProjectManuallyOverridden: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: OpportunityPipelineStage;
|
||||
closedAt: string | null;
|
||||
@@ -266,9 +270,13 @@ export interface OpportunityMutationPayload {
|
||||
estimatedValue?: number | null;
|
||||
chancePercent?: number | null;
|
||||
expectedCloseDate?: string | null;
|
||||
projectCloseDate?: string | null;
|
||||
deliveryDate?: string | null;
|
||||
competitor?: string;
|
||||
source?: string;
|
||||
isHotProject?: boolean;
|
||||
hotProjectAutoSuggested?: boolean;
|
||||
hotProjectManuallyOverridden?: boolean;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
projectParties?: OpportunityProjectPartyMutationPayload[];
|
||||
|
||||
@@ -110,10 +110,33 @@ export function OpportunityDetail({
|
||||
() => new Map(referenceData.productTypes.map((item) => [item.id, item.label])),
|
||||
[referenceData]
|
||||
);
|
||||
const priorityMap = useMemo(
|
||||
() => new Map(referenceData.priorities.map((item) => [item.id, item.label])),
|
||||
[referenceData]
|
||||
);
|
||||
const priorityMap = useMemo(
|
||||
() => new Map(referenceData.priorities.map((item) => [item.id, item.label])),
|
||||
[referenceData]
|
||||
);
|
||||
const quotationInitialPrefill = useMemo(
|
||||
() => ({
|
||||
opportunityId: opportunity.id,
|
||||
customerId: opportunity.customerId,
|
||||
contactId: opportunity.contactId,
|
||||
projectName: opportunity.projectName,
|
||||
projectLocation: opportunity.projectLocation,
|
||||
branchId: opportunity.branchId,
|
||||
quotationType: opportunity.productType,
|
||||
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,
|
||||
projectParties: projectPartiesData.items.map((item) => ({
|
||||
key: item.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark ?? ''
|
||||
}))
|
||||
}),
|
||||
[opportunity, projectPartiesData.items]
|
||||
);
|
||||
|
||||
const customer = customerMap.get(opportunity.customerId);
|
||||
const contact = opportunity.contactId ? contactMap.get(opportunity.contactId) : null;
|
||||
@@ -143,6 +166,7 @@ export function OpportunityDetail({
|
||||
open={quotationOpen}
|
||||
onOpenChange={setQuotationOpen}
|
||||
referenceData={quotationReferenceData}
|
||||
initialPrefill={quotationInitialPrefill}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -208,6 +232,18 @@ export function OpportunityDetail({
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Project Close Date'
|
||||
value={
|
||||
opportunity.projectCloseDate
|
||||
? formatDate(opportunity.projectCloseDate)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Delivery Date'
|
||||
value={opportunity.deliveryDate ? formatDate(opportunity.deliveryDate) : null}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Estimated Value'
|
||||
value={
|
||||
|
||||
@@ -25,11 +25,13 @@ import {
|
||||
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,
|
||||
@@ -50,14 +52,18 @@ function toDefaultValues(
|
||||
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
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,11 +150,15 @@ export function OpportunityFormSheet({
|
||||
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,
|
||||
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)
|
||||
@@ -196,7 +206,25 @@ export function OpportunityFormSheet({
|
||||
referenceData.customers.find((customer) => customer.id === selectedCustomerId)?.ownerUserId ?? '';
|
||||
|
||||
form.setFieldValue('assignedToUserId', ownerUserId);
|
||||
}, [form, isEdit, open, referenceData.customers, selectedCustomerId]);
|
||||
}, [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;
|
||||
|
||||
@@ -376,6 +404,14 @@ export function OpportunityFormSheet({
|
||||
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
|
||||
@@ -402,10 +438,33 @@ export function OpportunityFormSheet({
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||
<FormSwitchField
|
||||
<form.AppField
|
||||
name='isHotProject'
|
||||
label='โครงการด่วน'
|
||||
description='ใช้สำหรับงานเร่งด่วนหรือโครงการสำคัญเชิงกลยุทธ์'
|
||||
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'
|
||||
@@ -462,4 +521,3 @@ export function OpportunityFormSheetTrigger({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as z from 'zod';
|
||||
import { isValidDateOnlyString } from '@/features/crm/shared/hot-project';
|
||||
|
||||
export {
|
||||
opportunityMarkCancelledSchema,
|
||||
@@ -36,6 +37,14 @@ function coerceOptionalNumber(message: string) {
|
||||
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
|
||||
}
|
||||
|
||||
function optionalDateOnly(message: string) {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine((value) => !value || isValidDateOnlyString(value), message);
|
||||
}
|
||||
|
||||
export const opportunitySchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
@@ -58,10 +67,14 @@ export const opportunitySchema = z.object({
|
||||
(value) => value === undefined || (value >= 0 && value <= 100),
|
||||
'Chance percent must be between 0 and 100'
|
||||
),
|
||||
expectedCloseDate: z.string().optional().nullable(),
|
||||
expectedCloseDate: optionalDateOnly('Expected close date must be YYYY-MM-DD'),
|
||||
projectCloseDate: optionalDateOnly('Project close date must be YYYY-MM-DD'),
|
||||
deliveryDate: optionalDateOnly('Delivery date must be YYYY-MM-DD'),
|
||||
competitor: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
isHotProject: z.boolean().default(false),
|
||||
hotProjectAutoSuggested: z.boolean().default(false),
|
||||
hotProjectManuallyOverridden: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
projectParties: z
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
OpportunityRecord,
|
||||
OpportunityReferenceData
|
||||
} from '../api/types';
|
||||
import { resolveHotProjectState } from '@/features/crm/shared/hot-project';
|
||||
import {
|
||||
buildProjectPartyKey,
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
@@ -148,10 +149,14 @@ function mapOpportunityRecord(
|
||||
estimatedValue: row.estimatedValue,
|
||||
chancePercent: row.chancePercent,
|
||||
expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null,
|
||||
projectCloseDate: row.projectCloseDate?.toISOString() ?? null,
|
||||
deliveryDate: row.deliveryDate?.toISOString() ?? null,
|
||||
competitor: row.competitor,
|
||||
source: row.source,
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
hotProjectAutoSuggested: row.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: row.hotProjectManuallyOverridden,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as OpportunityPipelineStage,
|
||||
closedAt: row.closedAt?.toISOString() ?? null,
|
||||
@@ -1150,6 +1155,7 @@ export async function createOpportunity(
|
||||
payload: OpportunityMutationPayload
|
||||
) {
|
||||
await validateOpportunityPayload(organizationId, payload, accessContext);
|
||||
const hotProjectState = resolveHotProjectState(payload);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
@@ -1180,10 +1186,14 @@ export async function createOpportunity(
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
chancePercent: payload.chancePercent ?? null,
|
||||
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
||||
projectCloseDate: payload.projectCloseDate ? new Date(payload.projectCloseDate) : null,
|
||||
deliveryDate: payload.deliveryDate ? new Date(payload.deliveryDate) : null,
|
||||
competitor: payload.competitor?.trim() || null,
|
||||
source: payload.source?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isHotProject: hotProjectState.isHotProject,
|
||||
hotProjectAutoSuggested: hotProjectState.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: hotProjectState.hotProjectManuallyOverridden,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
assignedToUserId: payload.assignedToUserId ?? null,
|
||||
@@ -1215,6 +1225,7 @@ export async function updateOpportunity(
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
await validateOpportunityPayload(organizationId, payload, accessContext);
|
||||
const hotProjectState = resolveHotProjectState(payload);
|
||||
const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
assertOpportunityOpenForEditing(current);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(current);
|
||||
@@ -1238,10 +1249,14 @@ export async function updateOpportunity(
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
chancePercent: payload.chancePercent ?? null,
|
||||
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
||||
projectCloseDate: payload.projectCloseDate ? new Date(payload.projectCloseDate) : null,
|
||||
deliveryDate: payload.deliveryDate ? new Date(payload.deliveryDate) : null,
|
||||
competitor: payload.competitor?.trim() || null,
|
||||
source: payload.source?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isHotProject: hotProjectState.isHotProject,
|
||||
hotProjectAutoSuggested: hotProjectState.hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: hotProjectState.hotProjectManuallyOverridden,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
updatedBy: userId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
src/features/crm/shared/hot-project.ts
Normal file
63
src/features/crm/shared/hot-project.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { addMonths, endOfDay, isValid, parse } from 'date-fns';
|
||||
|
||||
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export interface HotProjectResolutionInput {
|
||||
projectCloseDate?: string | null;
|
||||
isHotProject?: boolean | null;
|
||||
hotProjectManuallyOverridden?: boolean | null;
|
||||
}
|
||||
|
||||
export interface HotProjectResolution {
|
||||
isHotProject: boolean;
|
||||
hotProjectAutoSuggested: boolean;
|
||||
hotProjectManuallyOverridden: boolean;
|
||||
}
|
||||
|
||||
export function isValidDateOnlyString(value: string | null | undefined): value is string {
|
||||
if (!value || !DATE_ONLY_PATTERN.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parse(value, 'yyyy-MM-dd', new Date());
|
||||
return isValid(parsed);
|
||||
}
|
||||
|
||||
export function isHotProjectSuggested(projectCloseDate: string | null | undefined, now = new Date()) {
|
||||
if (!isValidDateOnlyString(projectCloseDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parse(projectCloseDate, 'yyyy-MM-dd', now);
|
||||
return parsed.getTime() <= endOfDay(addMonths(now, 3)).getTime();
|
||||
}
|
||||
|
||||
export function resolveHotProjectState(
|
||||
input: HotProjectResolutionInput,
|
||||
now = new Date()
|
||||
): HotProjectResolution {
|
||||
const hotProjectAutoSuggested = isHotProjectSuggested(input.projectCloseDate, now);
|
||||
const hotProjectManuallyOverridden = Boolean(input.hotProjectManuallyOverridden);
|
||||
|
||||
if (hotProjectManuallyOverridden) {
|
||||
return {
|
||||
isHotProject: Boolean(input.isHotProject),
|
||||
hotProjectAutoSuggested,
|
||||
hotProjectManuallyOverridden: true
|
||||
};
|
||||
}
|
||||
|
||||
if (hotProjectAutoSuggested) {
|
||||
return {
|
||||
isHotProject: true,
|
||||
hotProjectAutoSuggested: true,
|
||||
hotProjectManuallyOverridden: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isHotProject: Boolean(input.isHotProject),
|
||||
hotProjectAutoSuggested: false,
|
||||
hotProjectManuallyOverridden: false
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user