task-d.6.2

This commit is contained in:
phaichayon
2026-07-01 13:57:16 +07:00
parent 0a144f0f9e
commit ed28ff6b50
71 changed files with 4137 additions and 51727 deletions

View File

@@ -16,10 +16,12 @@ type Params = {
};
const opportunityRequestSchema = opportunitySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
expectedCloseDate: z.string().nullable().optional(),
projectCloseDate: z.string().nullable().optional(),
deliveryDate: z.string().nullable().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
@@ -169,4 +171,3 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Unable to delete opportunity' }, { status: 500 });
}
}

View File

@@ -12,7 +12,9 @@ const opportunityRequestSchema = opportunitySchema.extend({
assignedToUserId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
expectedCloseDate: z.string().nullable().optional(),
projectCloseDate: z.string().nullable().optional(),
deliveryDate: z.string().nullable().optional()
});
function buildAccessContext(

View File

@@ -497,10 +497,16 @@ export const crmOpportunities = pgTable(
estimatedValue: doublePrecision('estimated_value'),
chancePercent: integer('chance_percent'),
expectedCloseDate: timestamp('expected_close_date', { withTimezone: true }),
projectCloseDate: timestamp('project_close_date', { withTimezone: true }),
deliveryDate: timestamp('delivery_date', { withTimezone: true }),
competitor: text('competitor'),
source: text('source'),
notes: text('notes'),
isHotProject: boolean('is_hot_project').default(false).notNull(),
hotProjectAutoSuggested: boolean('hot_project_auto_suggested').default(false).notNull(),
hotProjectManuallyOverridden: boolean('hot_project_manually_overridden')
.default(false)
.notNull(),
isActive: boolean('is_active').default(true).notNull(),
pipelineStage: text('pipeline_stage').default('lead').notNull(),
closedAt: timestamp('closed_at', { withTimezone: true }),
@@ -637,7 +643,13 @@ export const crmQuotations = pgTable(
taxAmount: doublePrecision('tax_amount').default(0).notNull(),
totalAmount: doublePrecision('total_amount').default(0).notNull(),
chancePercent: integer('chance_percent'),
projectCloseDate: timestamp('project_close_date', { withTimezone: true }),
deliveryDate: timestamp('delivery_date', { withTimezone: true }),
isHotProject: boolean('is_hot_project').default(false).notNull(),
hotProjectAutoSuggested: boolean('hot_project_auto_suggested').default(false).notNull(),
hotProjectManuallyOverridden: boolean('hot_project_manually_overridden')
.default(false)
.notNull(),
competitor: text('competitor'),
salesmanId: text('salesman_id'),
isSent: boolean('is_sent').default(false).notNull(),

View File

@@ -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} />

View File

@@ -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[];

View File

@@ -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={

View File

@@ -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({
</>
);
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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} />
</>
);
}

View File

@@ -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>;

View File

@@ -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,

View 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
};
}