preview pdf
This commit is contained in:
@@ -52,8 +52,9 @@ import {
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
|
||||
const ENQUIRY_OPTION_CATEGORIES = {
|
||||
status: 'crm_opportunity_stage',
|
||||
productType: 'crm_product_type',
|
||||
status: 'crm_opportunity_status',
|
||||
stage: 'crm_opportunity_stage',
|
||||
productType: 'crm_product_type',
|
||||
priority: 'crm_priority',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
followupType: 'crm_followup_type',
|
||||
@@ -692,9 +693,10 @@ export async function getOpportunityReferenceData(
|
||||
organizationId: string
|
||||
): Promise<OpportunityReferenceData> {
|
||||
const [
|
||||
branches,
|
||||
stages,
|
||||
outcomeStatuses,
|
||||
branches,
|
||||
statuses,
|
||||
stages,
|
||||
outcomeStatuses,
|
||||
productTypes,
|
||||
priorities,
|
||||
leadChannels,
|
||||
@@ -707,9 +709,10 @@ export async function getOpportunityReferenceData(
|
||||
contacts,
|
||||
assignableUsers
|
||||
] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.stage, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||
@@ -751,9 +754,9 @@ export async function getOpportunityReferenceData(
|
||||
const ownerMap = new Map(ownerRows.map((row) => [row.id, row.name]));
|
||||
|
||||
return {
|
||||
branches: branches.map(mapBranch),
|
||||
statuses: stages.map(mapOption),
|
||||
stages: stages.map(mapOption),
|
||||
branches: branches.map(mapBranch),
|
||||
statuses: statuses.map(mapOption),
|
||||
stages: stages.map(mapOption),
|
||||
outcomeStatuses: outcomeStatuses.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
priorities: priorities.map(mapOption),
|
||||
|
||||
@@ -73,6 +73,28 @@ export async function downloadQuotationCustomerPackage(quotationId: string): Pro
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateApprovedQuotationPdf(quotationId: string): Promise<{
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
}> {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? 'Failed to generate approved PDF');
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('content-disposition') ?? '';
|
||||
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: fileNameMatch?.[1] ?? `${quotationId}-approved.pdf`
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadApprovedQuotationPdf(quotationId: string): Promise<{
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertModal } from "@/components/modal/alert-modal";
|
||||
import { Icons } from "@/components/icons";
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import {
|
||||
downloadApprovedQuotationPdf,
|
||||
downloadQuotationCustomerPackage,
|
||||
generateApprovedQuotationPdf,
|
||||
} from "../api/service";
|
||||
import type {
|
||||
QuotationAttachmentMutationPayload,
|
||||
@@ -814,7 +815,8 @@ export function QuotationDetail({
|
||||
canDownloadCustomerPackage: boolean;
|
||||
canGenerateApprovedPdf: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||
const { data: itemsData } = useSuspenseQuery(
|
||||
quotationItemsOptions(quotationId),
|
||||
);
|
||||
@@ -1064,21 +1066,39 @@ export function QuotationDetail({
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
const downloadApprovedPdf = useMutation({
|
||||
mutationFn: async () => downloadApprovedQuotationPdf(quotationId),
|
||||
onSuccess: ({ blob, fileName }) => {
|
||||
downloadBlobFile(blob, fileName);
|
||||
toast.success("Approved PDF downloaded successfully");
|
||||
const downloadApprovedPdf = useMutation({
|
||||
mutationFn: async () => downloadApprovedQuotationPdf(quotationId),
|
||||
onSuccess: ({ blob, fileName }) => {
|
||||
downloadBlobFile(blob, fileName);
|
||||
toast.success("Approved PDF downloaded successfully");
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to download approved PDF",
|
||||
),
|
||||
});
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to download approved PDF",
|
||||
),
|
||||
});
|
||||
const createApprovedPdf = useMutation({
|
||||
mutationFn: async () => generateApprovedQuotationPdf(quotationId),
|
||||
onSuccess: async ({ blob, fileName }) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) }),
|
||||
queryClient.invalidateQueries({ queryKey: quotationKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) }),
|
||||
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) }),
|
||||
]);
|
||||
setCustomerPackageError(null);
|
||||
downloadBlobFile(blob, fileName);
|
||||
toast.success("Approved PDF generated successfully");
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to generate approved PDF",
|
||||
),
|
||||
});
|
||||
|
||||
const branchMap = useMemo(
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
[referenceData.branches],
|
||||
);
|
||||
@@ -1134,7 +1154,7 @@ export function QuotationDetail({
|
||||
? customerPackageError
|
||||
: hasOfficialDocument
|
||||
? "Generate the package from the approved PDF and active document library files."
|
||||
: "Customer package requires an approved PDF before it can be generated.";
|
||||
: "Customer package requires an approved PDF before it can be generated. Generate the official PDF first.";
|
||||
const canReviseByStatus = [
|
||||
"accepted",
|
||||
"rejected",
|
||||
@@ -2044,13 +2064,24 @@ export function QuotationDetail({
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canGenerateCustomerPackage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() =>
|
||||
customerPackage
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canGenerateApprovedPdf && !hasOfficialDocument ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={createApprovedPdf.isPending}
|
||||
onClick={() => createApprovedPdf.mutate()}
|
||||
>
|
||||
<Icons.badgeCheck className="mr-2 h-4 w-4" />
|
||||
Generate Official PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{canGenerateCustomerPackage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hasOfficialDocument}
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() =>
|
||||
customerPackage
|
||||
? setConfirmRegenerateOpen(true)
|
||||
: generateCustomerPackage.mutate({
|
||||
quotationId,
|
||||
@@ -2134,13 +2165,24 @@ export function QuotationDetail({
|
||||
customer-deliverable package.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canGenerateCustomerPackage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() =>
|
||||
customerPackage
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canGenerateApprovedPdf && !hasOfficialDocument ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={createApprovedPdf.isPending}
|
||||
onClick={() => createApprovedPdf.mutate()}
|
||||
>
|
||||
<Icons.badgeCheck className="mr-2 h-4 w-4" />
|
||||
Generate Official PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{canGenerateCustomerPackage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hasOfficialDocument}
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() =>
|
||||
customerPackage
|
||||
? setConfirmRegenerateOpen(true)
|
||||
: generateCustomerPackage.mutate({
|
||||
quotationId,
|
||||
|
||||
@@ -1,33 +1,52 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { 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 * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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,
|
||||
CrmNumberInput,
|
||||
CrmPercentageInput,
|
||||
CrmTextarea
|
||||
} from '@/features/crm/components/crm-form-controls';
|
||||
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project';
|
||||
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
||||
import { quotationCustomersOptions } from '../api/queries';
|
||||
CrmTextarea,
|
||||
} from "@/features/crm/components/crm-form-controls";
|
||||
import { isHotProjectSuggested } from "@/features/crm/shared/hot-project";
|
||||
import {
|
||||
createQuotationMutation,
|
||||
updateQuotationMutation,
|
||||
} from "../api/mutations";
|
||||
import { quotationCustomersOptions } from "../api/queries";
|
||||
import type {
|
||||
QuotationMutationPayload,
|
||||
QuotationOpportunityLookup,
|
||||
QuotationRecord,
|
||||
QuotationReferenceData
|
||||
} from '../api/types';
|
||||
import { quotationSchema } from '../schemas/quotation.schema';
|
||||
QuotationReferenceData,
|
||||
} from "../api/types";
|
||||
import { quotationSchema } from "../schemas/quotation.schema";
|
||||
|
||||
type FormState = {
|
||||
opportunityId: string;
|
||||
@@ -78,41 +97,43 @@ export interface QuotationCreatePrefill {
|
||||
}
|
||||
|
||||
type PrefillFieldKey =
|
||||
| 'customerId'
|
||||
| 'contactId'
|
||||
| 'projectName'
|
||||
| 'projectLocation'
|
||||
| 'branchId'
|
||||
| 'quotationType'
|
||||
| 'projectCloseDate'
|
||||
| 'deliveryDate'
|
||||
| 'isHotProject'
|
||||
| 'hotProjectAutoSuggested'
|
||||
| 'hotProjectManuallyOverridden';
|
||||
| "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'
|
||||
"customerId",
|
||||
"contactId",
|
||||
"projectName",
|
||||
"projectLocation",
|
||||
"branchId",
|
||||
"quotationType",
|
||||
"projectCloseDate",
|
||||
"deliveryDate",
|
||||
"isHotProject",
|
||||
"hotProjectAutoSuggested",
|
||||
"hotProjectManuallyOverridden",
|
||||
];
|
||||
|
||||
function toOpportunityPrefill(
|
||||
opportunity: QuotationOpportunityLookup | undefined,
|
||||
referenceData: QuotationReferenceData
|
||||
referenceData: QuotationReferenceData,
|
||||
): QuotationCreatePrefill | null {
|
||||
if (!opportunity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quotationType = referenceData.quotationTypes.some((item) => item.id === opportunity.productType)
|
||||
const quotationType = referenceData.quotationTypes.some(
|
||||
(item) => item.id === opportunity.productType,
|
||||
)
|
||||
? opportunity.productType
|
||||
: null;
|
||||
|
||||
@@ -124,65 +145,95 @@ function toOpportunityPrefill(
|
||||
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,
|
||||
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
|
||||
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden,
|
||||
};
|
||||
}
|
||||
|
||||
function toFormState(
|
||||
quotation: QuotationRecord | undefined,
|
||||
referenceData: QuotationReferenceData,
|
||||
initialPrefill?: QuotationCreatePrefill | null
|
||||
initialPrefill?: QuotationCreatePrefill | null,
|
||||
): FormState {
|
||||
return {
|
||||
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) ?? '',
|
||||
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 ?? initialPrefill?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
|
||||
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? '',
|
||||
projectLocation: quotation?.projectLocation ?? initialPrefill?.projectLocation ?? '',
|
||||
attention: quotation?.attention ?? '',
|
||||
branchId: quotation?.branchId ?? initialPrefill?.branchId ?? '',
|
||||
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '',
|
||||
quotation?.quotationType ??
|
||||
initialPrefill?.quotationType ??
|
||||
referenceData.quotationTypes[0]?.id ??
|
||||
"",
|
||||
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? "",
|
||||
projectLocation:
|
||||
quotation?.projectLocation ?? initialPrefill?.projectLocation ?? "",
|
||||
attention: quotation?.attention ?? "",
|
||||
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) : '',
|
||||
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 ?? '',
|
||||
salesmanId: quotation?.salesmanId ?? '',
|
||||
status: quotation?.status ?? referenceData.statuses[0]?.id ?? "",
|
||||
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 ?? "",
|
||||
salesmanId: quotation?.salesmanId ?? "",
|
||||
discount: String(quotation?.discount ?? 0),
|
||||
discountType: quotation?.discountType ?? '',
|
||||
discountType: quotation?.discountType ?? "",
|
||||
taxRate: String(quotation?.taxRate ?? 0),
|
||||
sentVia: quotation?.sentVia ?? '',
|
||||
revisionRemark: quotation?.revisionRemark ?? '',
|
||||
isHotProject: quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
|
||||
sentVia: quotation?.sentVia ?? "",
|
||||
revisionRemark: quotation?.revisionRemark ?? "",
|
||||
isHotProject:
|
||||
quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
|
||||
hotProjectAutoSuggested:
|
||||
quotation?.hotProjectAutoSuggested ?? initialPrefill?.hotProjectAutoSuggested ?? false,
|
||||
quotation?.hotProjectAutoSuggested ??
|
||||
initialPrefill?.hotProjectAutoSuggested ??
|
||||
false,
|
||||
hotProjectManuallyOverridden:
|
||||
quotation?.hotProjectManuallyOverridden ?? initialPrefill?.hotProjectManuallyOverridden ?? false,
|
||||
isActive: quotation?.isActive ?? true
|
||||
quotation?.hotProjectManuallyOverridden ??
|
||||
initialPrefill?.hotProjectManuallyOverridden ??
|
||||
false,
|
||||
isActive: quotation?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children
|
||||
children,
|
||||
}: React.PropsWithChildren<{ label: string; required?: boolean }>) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? ' *' : ''}
|
||||
{required ? " *" : ""}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
@@ -194,7 +245,7 @@ export function QuotationFormSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData,
|
||||
initialPrefill
|
||||
initialPrefill,
|
||||
}: {
|
||||
quotation?: QuotationRecord;
|
||||
open: boolean;
|
||||
@@ -205,38 +256,44 @@ export function QuotationFormSheet({
|
||||
const isEdit = Boolean(quotation);
|
||||
const defaultState = useMemo(
|
||||
() => toFormState(quotation, referenceData, initialPrefill),
|
||||
[quotation, referenceData, initialPrefill]
|
||||
[quotation, referenceData, initialPrefill],
|
||||
);
|
||||
const [state, setState] = useState<FormState>(defaultState);
|
||||
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||
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 && Boolean(quotation?.id)
|
||||
...quotationCustomersOptions(quotation?.id ?? ""),
|
||||
enabled: open && Boolean(quotation?.id),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createQuotationMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Quotation created successfully');
|
||||
toast.success("Quotation created successfully");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create quotation');
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create quotation",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateQuotationMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Quotation updated successfully');
|
||||
toast.success("Quotation updated successfully");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update quotation');
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update quotation",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -254,14 +311,20 @@ export function QuotationFormSheet({
|
||||
key: item.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark ?? ''
|
||||
}))
|
||||
remark: item.remark ?? "",
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectParties(initialPrefill?.projectParties ?? []);
|
||||
}, [defaultState, initialPrefill, open, projectPartiesQuery.data?.items, quotation]);
|
||||
}, [
|
||||
defaultState,
|
||||
initialPrefill,
|
||||
open,
|
||||
projectPartiesQuery.data?.items,
|
||||
quotation,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -279,21 +342,27 @@ export function QuotationFormSheet({
|
||||
return { ...current, hotProjectAutoSuggested: suggested };
|
||||
}
|
||||
|
||||
if (current.hotProjectAutoSuggested === suggested && current.isHotProject === suggested) {
|
||||
if (
|
||||
current.hotProjectAutoSuggested === suggested &&
|
||||
current.isHotProject === suggested
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
isHotProject: suggested,
|
||||
hotProjectAutoSuggested: suggested
|
||||
hotProjectAutoSuggested: suggested,
|
||||
};
|
||||
});
|
||||
}, [open, state.projectCloseDate, state.hotProjectManuallyOverridden]);
|
||||
|
||||
const contacts = useMemo(
|
||||
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
|
||||
[referenceData.contacts, state.customerId]
|
||||
() =>
|
||||
referenceData.contacts.filter(
|
||||
(item) => item.customerId === state.customerId,
|
||||
),
|
||||
[referenceData.contacts, state.customerId],
|
||||
);
|
||||
|
||||
function markDirty(key: string) {
|
||||
@@ -304,7 +373,11 @@ export function QuotationFormSheet({
|
||||
});
|
||||
}
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K], dirty = true) {
|
||||
function setField<K extends keyof FormState>(
|
||||
key: K,
|
||||
value: FormState[K],
|
||||
dirty = true,
|
||||
) {
|
||||
setState((current) => ({ ...current, [key]: value }));
|
||||
|
||||
if (dirty) {
|
||||
@@ -321,7 +394,7 @@ export function QuotationFormSheet({
|
||||
const next = { ...current };
|
||||
|
||||
if (prefill.opportunityId !== undefined) {
|
||||
next.opportunityId = prefill.opportunityId ?? '';
|
||||
next.opportunityId = prefill.opportunityId ?? "";
|
||||
}
|
||||
|
||||
for (const key of PREFILL_FIELD_KEYS) {
|
||||
@@ -336,7 +409,9 @@ export function QuotationFormSheet({
|
||||
}
|
||||
|
||||
(next[key] as FormState[typeof key]) =
|
||||
typeof next[key] === 'boolean' ? Boolean(value) : ((value ?? '') as FormState[typeof key]);
|
||||
typeof next[key] === "boolean"
|
||||
? Boolean(value)
|
||||
: ((value ?? "") as FormState[typeof key]);
|
||||
}
|
||||
|
||||
return next;
|
||||
@@ -348,12 +423,14 @@ export function QuotationFormSheet({
|
||||
}
|
||||
|
||||
function handleOpportunityChange(value: string) {
|
||||
const nextOpportunityId = value === '__none__' ? '' : value;
|
||||
const opportunity = referenceData.opportunities.find((item) => item.id === nextOpportunityId);
|
||||
markDirty('opportunityId');
|
||||
const nextOpportunityId = value === "__none__" ? "" : value;
|
||||
const opportunity = referenceData.opportunities.find(
|
||||
(item) => item.id === nextOpportunityId,
|
||||
);
|
||||
markDirty("opportunityId");
|
||||
applyPrefill({
|
||||
...toOpportunityPrefill(opportunity, referenceData),
|
||||
opportunityId: nextOpportunityId
|
||||
opportunityId: nextOpportunityId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -396,14 +473,16 @@ export function QuotationFormSheet({
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
isPrimary: false,
|
||||
remark: item.remark || null
|
||||
}))
|
||||
remark: item.remark || null,
|
||||
})),
|
||||
};
|
||||
|
||||
const parsed = quotationSchema.safeParse(payload);
|
||||
|
||||
if (!parsed.success) {
|
||||
toast.error(parsed.error.issues[0]?.message ?? 'Invalid quotation payload');
|
||||
toast.error(
|
||||
parsed.error.issues[0]?.message ?? "Invalid quotation payload",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -419,22 +498,30 @@ export function QuotationFormSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-5xl'>
|
||||
<SheetContent className="flex flex-col sm:max-w-5xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
|
||||
<SheetTitle>{isEdit ? "Edit Quotation" : "New Quotation"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture quotation header data, billing customer, project parties, and commercial context.
|
||||
Capture quotation header data, billing customer, project parties,
|
||||
and commercial context.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<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}>
|
||||
<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' />
|
||||
<SelectValue placeholder="Select opportunity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No linked opportunity</SelectItem>
|
||||
<SelectItem value="__none__">No linked opportunity</SelectItem>
|
||||
{referenceData.opportunities.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.code} - {item.title}
|
||||
@@ -444,16 +531,16 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Customer' required>
|
||||
<Field label="Customer" required>
|
||||
<Select
|
||||
value={state.customerId}
|
||||
onValueChange={(value) => {
|
||||
setField('customerId', value);
|
||||
setField('contactId', '');
|
||||
setField("customerId", value);
|
||||
setField("contactId", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select customer' />
|
||||
<SelectValue placeholder="Select customer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.customers.map((item) => (
|
||||
@@ -465,16 +552,18 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Contact'>
|
||||
<Field label="Contact">
|
||||
<Select
|
||||
value={state.contactId || '__none__'}
|
||||
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
|
||||
value={state.contactId || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setField("contactId", value === "__none__" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select contact' />
|
||||
<SelectValue placeholder="Select contact" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No contact</SelectItem>
|
||||
<SelectItem value="__none__">No contact</SelectItem>
|
||||
{contacts.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -484,10 +573,13 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Quotation Type' required>
|
||||
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
|
||||
<Field label="Quotation Type" required>
|
||||
<Select
|
||||
value={state.quotationType}
|
||||
onValueChange={(value) => setField("quotationType", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select type' />
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.quotationTypes.map((item) => (
|
||||
@@ -499,32 +591,46 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Quotation Date' required>
|
||||
<CrmDateInput value={state.quotationDate} onChange={(value) => setField('quotationDate', value)} />
|
||||
<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 label="Valid Until">
|
||||
<CrmDateInput
|
||||
value={state.validUntil}
|
||||
onChange={(value) => setField("validUntil", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Project Close Date'>
|
||||
<CrmDateInput value={state.projectCloseDate} onChange={(value) => setField('projectCloseDate', value)} />
|
||||
<Field label="Project Close Date">
|
||||
<CrmDateInput
|
||||
value={state.projectCloseDate}
|
||||
onChange={(value) => setField("projectCloseDate", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Delivery Date'>
|
||||
<CrmDateInput value={state.deliveryDate} onChange={(value) => setField('deliveryDate', value)} />
|
||||
<Field label="Delivery Date">
|
||||
<CrmDateInput
|
||||
value={state.deliveryDate}
|
||||
onChange={(value) => setField("deliveryDate", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Branch'>
|
||||
<Field label="Branch">
|
||||
<Select
|
||||
value={state.branchId || '__none__'}
|
||||
onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}
|
||||
value={state.branchId || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setField("branchId", value === "__none__" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select branch' />
|
||||
<SelectValue placeholder="Select branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||
{referenceData.branches.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -534,10 +640,13 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Status'>
|
||||
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
|
||||
<Field label="Status">
|
||||
<Select
|
||||
value={state.status}
|
||||
onValueChange={(value) => setField("status", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select status' />
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.statuses.map((item) => (
|
||||
@@ -549,10 +658,13 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Currency'>
|
||||
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
|
||||
<Field label="Currency">
|
||||
<Select
|
||||
value={state.currency}
|
||||
onValueChange={(value) => setField("currency", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select currency' />
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.currencies.map((item) => (
|
||||
@@ -564,36 +676,58 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Exchange Rate'>
|
||||
<CrmNumberInput value={state.exchangeRate} onChange={(value) => setField('exchangeRate', value)} step='0.0001' />
|
||||
<Field label="Exchange Rate">
|
||||
<CrmNumberInput
|
||||
value={state.exchangeRate}
|
||||
onChange={(value) => setField("exchangeRate", value)}
|
||||
step="0.0001"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Project Name'>
|
||||
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
|
||||
<Field label="Project Name">
|
||||
<Input
|
||||
value={state.projectName}
|
||||
onChange={(e) => setField("projectName", e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Project Location'>
|
||||
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
|
||||
<Field label="Project Location">
|
||||
<Input
|
||||
value={state.projectLocation}
|
||||
onChange={(e) => setField("projectLocation", e.target.value)}
|
||||
placeholder="Project location"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Attention'>
|
||||
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
|
||||
{/* <Field label="Attention">
|
||||
<Input
|
||||
value={state.attention}
|
||||
onChange={(e) => setField("attention", e.target.value)}
|
||||
placeholder="Attention line"
|
||||
/>
|
||||
</Field> */}
|
||||
|
||||
<Field label="Estimate No.">
|
||||
<Input
|
||||
value={state.reference}
|
||||
onChange={(e) => setField("reference", e.target.value)}
|
||||
placeholder="Reference No."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Reference'>
|
||||
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
|
||||
</Field>
|
||||
|
||||
<Field label='Salesman'>
|
||||
<Field label="Salesman">
|
||||
<Select
|
||||
value={state.salesmanId || '__none__'}
|
||||
onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}
|
||||
value={state.salesmanId || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setField("salesmanId", value === "__none__" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select salesman' />
|
||||
<SelectValue placeholder="Select salesman" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||
{referenceData.salesmen.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
@@ -603,24 +737,33 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Chance %'>
|
||||
<CrmPercentageInput value={state.chancePercent} onChange={(value) => setField('chancePercent', value)} />
|
||||
<Field label="Chance %">
|
||||
<CrmPercentageInput
|
||||
value={state.chancePercent}
|
||||
onChange={(value) => setField("chancePercent", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Discount'>
|
||||
<CrmCurrencyInput value={state.discount} onChange={(value) => setField('discount', value)} currencyLabel='THB' />
|
||||
<Field label="Discount">
|
||||
<CrmCurrencyInput
|
||||
value={state.discount}
|
||||
onChange={(value) => setField("discount", value)}
|
||||
currencyLabel="THB"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Discount Type'>
|
||||
<Field label="Discount Type">
|
||||
<Select
|
||||
value={state.discountType || '__none__'}
|
||||
onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}
|
||||
value={state.discountType || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setField("discountType", value === "__none__" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select discount type' />
|
||||
<SelectValue placeholder="Select discount type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>None</SelectItem>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{referenceData.discountTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
@@ -630,20 +773,25 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Tax Rate %'>
|
||||
<CrmPercentageInput value={state.taxRate} onChange={(value) => setField('taxRate', value)} />
|
||||
<Field label="Tax Rate %">
|
||||
<CrmPercentageInput
|
||||
value={state.taxRate}
|
||||
onChange={(value) => setField("taxRate", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label='Sent Via'>
|
||||
<Field label="Sent Via">
|
||||
<Select
|
||||
value={state.sentVia || '__none__'}
|
||||
onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}
|
||||
value={state.sentVia || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setField("sentVia", value === "__none__" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select sent via' />
|
||||
<SelectValue placeholder="Select sent via" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>Not sent yet</SelectItem>
|
||||
<SelectItem value="__none__">Not sent yet</SelectItem>
|
||||
{referenceData.sentVias.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
@@ -653,11 +801,15 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Competitor'>
|
||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||
<Field label="Competitor">
|
||||
<Input
|
||||
value={state.competitor}
|
||||
onChange={(e) => setField("competitor", e.target.value)}
|
||||
placeholder="Competitor"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<div className="md:col-span-2">
|
||||
<ProjectPartiesEditor
|
||||
customers={referenceData.customers}
|
||||
roles={referenceData.projectPartyRoles}
|
||||
@@ -669,27 +821,34 @@ export function QuotationFormSheet({
|
||||
/>
|
||||
</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'>
|
||||
<div className="md:col-span-2">
|
||||
<Field label="Notes">
|
||||
<CrmTextarea
|
||||
value={state.revisionRemark}
|
||||
onChange={(e) => setField('revisionRemark', e.target.value)}
|
||||
placeholder='Revision note or approval context'
|
||||
size='md'
|
||||
value={state.notes}
|
||||
onChange={(e) => setField("notes", e.target.value)}
|
||||
placeholder="Internal notes"
|
||||
size="md"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<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'>Auto-suggested when project close date is within 3 months.</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}
|
||||
@@ -698,29 +857,44 @@ export function QuotationFormSheet({
|
||||
...current,
|
||||
isHotProject: checked,
|
||||
hotProjectManuallyOverridden: true,
|
||||
hotProjectAutoSuggested: isHotProjectSuggested(current.projectCloseDate)
|
||||
hotProjectAutoSuggested: isHotProjectSuggested(
|
||||
current.projectCloseDate,
|
||||
),
|
||||
}));
|
||||
markDirty('isHotProject');
|
||||
markDirty("isHotProject");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<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 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)} />
|
||||
<Switch
|
||||
checked={state.isActive}
|
||||
onCheckedChange={(checked) => setField("isActive", checked)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='quotation-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Quotation' : 'Create Quotation'}
|
||||
<Button
|
||||
type="submit"
|
||||
form="quotation-form-sheet"
|
||||
isLoading={isPending}
|
||||
>
|
||||
<Icons.check className="mr-2 h-4 w-4" />
|
||||
{isEdit ? "Update Quotation" : "Create Quotation"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -729,7 +903,7 @@ export function QuotationFormSheet({
|
||||
}
|
||||
|
||||
export function QuotationFormSheetTrigger({
|
||||
referenceData
|
||||
referenceData,
|
||||
}: {
|
||||
referenceData: QuotationReferenceData;
|
||||
}) {
|
||||
@@ -738,10 +912,14 @@ export function QuotationFormSheetTrigger({
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
<Icons.add className="mr-2 h-4 w-4" />
|
||||
Add Quotation
|
||||
</Button>
|
||||
<QuotationFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
<QuotationFormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user