-
Legacy approved PDF detected
-
- This quotation still points to the old `public/generated` storage path and has
- not been migrated into the artifact storage model yet.
+
+
+ Legacy approved PDF detected
+
+
+ This quotation still points to the old `public/generated`
+ storage path and has not been migrated into the artifact
+ storage model yet.
) : null}
@@ -2133,8 +2432,3 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
);
}
-
-
-
-
-
diff --git a/src/features/crm/quotations/components/quotation-form-sheet.tsx b/src/features/crm/quotations/components/quotation-form-sheet.tsx
index 09445f6..815647a 100644
--- a/src/features/crm/quotations/components/quotation-form-sheet.tsx
+++ b/src/features/crm/quotations/components/quotation-form-sheet.tsx
@@ -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 (
-
-
+
+
{label}
{required ? ' *' : ''}
@@ -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
(defaultState);
const [projectParties, setProjectParties] = useState([]);
+ const [dirtyFields, setDirtyFields] = useState>(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(key: K, value: FormState[K]) {
+ function markDirty(key: string) {
+ setDirtyFields((current) => {
+ const next = new Set(current);
+ next.add(key);
+ return next;
+ });
+ }
+
+ function setField(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) {
@@ -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({
-