preview pdf
This commit is contained in:
62
src/app/api/crm/settings/document-library/versions/route.ts
Normal file
62
src/app/api/crm/settings/document-library/versions/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { uploadDocumentLibraryVersion } from '@/features/foundation/document-library/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentLibraryUpload
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const libraryId = formData.get('libraryId');
|
||||||
|
const version = formData.get('version');
|
||||||
|
const file = formData.get('file');
|
||||||
|
|
||||||
|
if (typeof libraryId !== 'string' || !libraryId.trim()) {
|
||||||
|
return NextResponse.json({ message: 'Library ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof version !== 'string' || !version.trim()) {
|
||||||
|
return NextResponse.json({ message: 'Version is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ message: 'PDF file required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await uploadDocumentLibraryVersion({
|
||||||
|
libraryId,
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
version,
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
buffer: Buffer.from(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Document library version uploaded successfully',
|
||||||
|
version: created
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to upload document library version' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,8 @@ import {
|
|||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
const ENQUIRY_OPTION_CATEGORIES = {
|
const ENQUIRY_OPTION_CATEGORIES = {
|
||||||
status: 'crm_opportunity_stage',
|
status: 'crm_opportunity_status',
|
||||||
|
stage: 'crm_opportunity_stage',
|
||||||
productType: 'crm_product_type',
|
productType: 'crm_product_type',
|
||||||
priority: 'crm_priority',
|
priority: 'crm_priority',
|
||||||
leadChannel: 'crm_lead_channel',
|
leadChannel: 'crm_lead_channel',
|
||||||
@@ -693,6 +694,7 @@ export async function getOpportunityReferenceData(
|
|||||||
): Promise<OpportunityReferenceData> {
|
): Promise<OpportunityReferenceData> {
|
||||||
const [
|
const [
|
||||||
branches,
|
branches,
|
||||||
|
statuses,
|
||||||
stages,
|
stages,
|
||||||
outcomeStatuses,
|
outcomeStatuses,
|
||||||
productTypes,
|
productTypes,
|
||||||
@@ -709,6 +711,7 @@ export async function getOpportunityReferenceData(
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getUserBranches(),
|
getUserBranches(),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.stage, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||||
@@ -752,7 +755,7 @@ export async function getOpportunityReferenceData(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
branches: branches.map(mapBranch),
|
branches: branches.map(mapBranch),
|
||||||
statuses: stages.map(mapOption),
|
statuses: statuses.map(mapOption),
|
||||||
stages: stages.map(mapOption),
|
stages: stages.map(mapOption),
|
||||||
outcomeStatuses: outcomeStatuses.map(mapOption),
|
outcomeStatuses: outcomeStatuses.map(mapOption),
|
||||||
productTypes: productTypes.map(mapOption),
|
productTypes: productTypes.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<{
|
export async function downloadApprovedQuotationPdf(quotationId: string): Promise<{
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
import { AlertModal } from "@/components/modal/alert-modal";
|
import { AlertModal } from "@/components/modal/alert-modal";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
downloadApprovedQuotationPdf,
|
downloadApprovedQuotationPdf,
|
||||||
downloadQuotationCustomerPackage,
|
downloadQuotationCustomerPackage,
|
||||||
|
generateApprovedQuotationPdf,
|
||||||
} from "../api/service";
|
} from "../api/service";
|
||||||
import type {
|
import type {
|
||||||
QuotationAttachmentMutationPayload,
|
QuotationAttachmentMutationPayload,
|
||||||
@@ -814,6 +815,7 @@ export function QuotationDetail({
|
|||||||
canDownloadCustomerPackage: boolean;
|
canDownloadCustomerPackage: boolean;
|
||||||
canGenerateApprovedPdf: boolean;
|
canGenerateApprovedPdf: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||||
const { data: itemsData } = useSuspenseQuery(
|
const { data: itemsData } = useSuspenseQuery(
|
||||||
quotationItemsOptions(quotationId),
|
quotationItemsOptions(quotationId),
|
||||||
@@ -1077,6 +1079,24 @@ export function QuotationDetail({
|
|||||||
: "Failed to download approved PDF",
|
: "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])),
|
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||||
@@ -1134,7 +1154,7 @@ export function QuotationDetail({
|
|||||||
? customerPackageError
|
? customerPackageError
|
||||||
: hasOfficialDocument
|
: hasOfficialDocument
|
||||||
? "Generate the package from the approved PDF and active document library files."
|
? "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 = [
|
const canReviseByStatus = [
|
||||||
"accepted",
|
"accepted",
|
||||||
"rejected",
|
"rejected",
|
||||||
@@ -2045,9 +2065,20 @@ export function QuotationDetail({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 ? (
|
{canGenerateCustomerPackage ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={!hasOfficialDocument}
|
||||||
isLoading={generateCustomerPackage.isPending}
|
isLoading={generateCustomerPackage.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
customerPackage
|
customerPackage
|
||||||
@@ -2135,9 +2166,20 @@ export function QuotationDetail({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 ? (
|
{canGenerateCustomerPackage ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={!hasOfficialDocument}
|
||||||
isLoading={generateCustomerPackage.isPending}
|
isLoading={generateCustomerPackage.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
customerPackage
|
customerPackage
|
||||||
|
|||||||
@@ -1,33 +1,52 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from "@/components/icons";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import {
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
Select,
|
||||||
import { Switch } from '@/components/ui/switch';
|
SelectContent,
|
||||||
import { ProjectPartiesEditor, type ProjectPartyEditorItem } from '@/features/crm/components/project-parties-editor';
|
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 {
|
import {
|
||||||
CrmCurrencyInput,
|
CrmCurrencyInput,
|
||||||
CrmDateInput,
|
CrmDateInput,
|
||||||
CrmNumberInput,
|
CrmNumberInput,
|
||||||
CrmPercentageInput,
|
CrmPercentageInput,
|
||||||
CrmTextarea
|
CrmTextarea,
|
||||||
} from '@/features/crm/components/crm-form-controls';
|
} from "@/features/crm/components/crm-form-controls";
|
||||||
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project';
|
import { isHotProjectSuggested } from "@/features/crm/shared/hot-project";
|
||||||
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
import {
|
||||||
import { quotationCustomersOptions } from '../api/queries';
|
createQuotationMutation,
|
||||||
|
updateQuotationMutation,
|
||||||
|
} from "../api/mutations";
|
||||||
|
import { quotationCustomersOptions } from "../api/queries";
|
||||||
import type {
|
import type {
|
||||||
QuotationMutationPayload,
|
QuotationMutationPayload,
|
||||||
QuotationOpportunityLookup,
|
QuotationOpportunityLookup,
|
||||||
QuotationRecord,
|
QuotationRecord,
|
||||||
QuotationReferenceData
|
QuotationReferenceData,
|
||||||
} from '../api/types';
|
} from "../api/types";
|
||||||
import { quotationSchema } from '../schemas/quotation.schema';
|
import { quotationSchema } from "../schemas/quotation.schema";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
opportunityId: string;
|
opportunityId: string;
|
||||||
@@ -78,41 +97,43 @@ export interface QuotationCreatePrefill {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrefillFieldKey =
|
type PrefillFieldKey =
|
||||||
| 'customerId'
|
| "customerId"
|
||||||
| 'contactId'
|
| "contactId"
|
||||||
| 'projectName'
|
| "projectName"
|
||||||
| 'projectLocation'
|
| "projectLocation"
|
||||||
| 'branchId'
|
| "branchId"
|
||||||
| 'quotationType'
|
| "quotationType"
|
||||||
| 'projectCloseDate'
|
| "projectCloseDate"
|
||||||
| 'deliveryDate'
|
| "deliveryDate"
|
||||||
| 'isHotProject'
|
| "isHotProject"
|
||||||
| 'hotProjectAutoSuggested'
|
| "hotProjectAutoSuggested"
|
||||||
| 'hotProjectManuallyOverridden';
|
| "hotProjectManuallyOverridden";
|
||||||
|
|
||||||
const PREFILL_FIELD_KEYS: PrefillFieldKey[] = [
|
const PREFILL_FIELD_KEYS: PrefillFieldKey[] = [
|
||||||
'customerId',
|
"customerId",
|
||||||
'contactId',
|
"contactId",
|
||||||
'projectName',
|
"projectName",
|
||||||
'projectLocation',
|
"projectLocation",
|
||||||
'branchId',
|
"branchId",
|
||||||
'quotationType',
|
"quotationType",
|
||||||
'projectCloseDate',
|
"projectCloseDate",
|
||||||
'deliveryDate',
|
"deliveryDate",
|
||||||
'isHotProject',
|
"isHotProject",
|
||||||
'hotProjectAutoSuggested',
|
"hotProjectAutoSuggested",
|
||||||
'hotProjectManuallyOverridden'
|
"hotProjectManuallyOverridden",
|
||||||
];
|
];
|
||||||
|
|
||||||
function toOpportunityPrefill(
|
function toOpportunityPrefill(
|
||||||
opportunity: QuotationOpportunityLookup | undefined,
|
opportunity: QuotationOpportunityLookup | undefined,
|
||||||
referenceData: QuotationReferenceData
|
referenceData: QuotationReferenceData,
|
||||||
): QuotationCreatePrefill | null {
|
): QuotationCreatePrefill | null {
|
||||||
if (!opportunity) {
|
if (!opportunity) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotationType = referenceData.quotationTypes.some((item) => item.id === opportunity.productType)
|
const quotationType = referenceData.quotationTypes.some(
|
||||||
|
(item) => item.id === opportunity.productType,
|
||||||
|
)
|
||||||
? opportunity.productType
|
? opportunity.productType
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -124,65 +145,95 @@ function toOpportunityPrefill(
|
|||||||
projectLocation: opportunity.projectLocation,
|
projectLocation: opportunity.projectLocation,
|
||||||
branchId: opportunity.branchId,
|
branchId: opportunity.branchId,
|
||||||
quotationType,
|
quotationType,
|
||||||
projectCloseDate: opportunity.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : null,
|
projectCloseDate: opportunity.projectCloseDate
|
||||||
deliveryDate: opportunity.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : null,
|
? opportunity.projectCloseDate.slice(0, 10)
|
||||||
|
: null,
|
||||||
|
deliveryDate: opportunity.deliveryDate
|
||||||
|
? opportunity.deliveryDate.slice(0, 10)
|
||||||
|
: null,
|
||||||
isHotProject: opportunity.isHotProject,
|
isHotProject: opportunity.isHotProject,
|
||||||
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
|
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
|
||||||
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden
|
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFormState(
|
function toFormState(
|
||||||
quotation: QuotationRecord | undefined,
|
quotation: QuotationRecord | undefined,
|
||||||
referenceData: QuotationReferenceData,
|
referenceData: QuotationReferenceData,
|
||||||
initialPrefill?: QuotationCreatePrefill | null
|
initialPrefill?: QuotationCreatePrefill | null,
|
||||||
): FormState {
|
): FormState {
|
||||||
return {
|
return {
|
||||||
opportunityId: quotation?.opportunityId ?? initialPrefill?.opportunityId ?? '',
|
opportunityId:
|
||||||
customerId: quotation?.customerId ?? initialPrefill?.customerId ?? referenceData.customers[0]?.id ?? '',
|
quotation?.opportunityId ?? initialPrefill?.opportunityId ?? "",
|
||||||
contactId: quotation?.contactId ?? initialPrefill?.contactId ?? '',
|
customerId:
|
||||||
quotationDate: quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
|
quotation?.customerId ??
|
||||||
validUntil: quotation?.validUntil?.slice(0, 10) ?? '',
|
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:
|
quotationType:
|
||||||
quotation?.quotationType ?? initialPrefill?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
|
quotation?.quotationType ??
|
||||||
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? '',
|
initialPrefill?.quotationType ??
|
||||||
projectLocation: quotation?.projectLocation ?? initialPrefill?.projectLocation ?? '',
|
referenceData.quotationTypes[0]?.id ??
|
||||||
attention: quotation?.attention ?? '',
|
"",
|
||||||
branchId: quotation?.branchId ?? initialPrefill?.branchId ?? '',
|
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? "",
|
||||||
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '',
|
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),
|
exchangeRate: String(quotation?.exchangeRate ?? 1),
|
||||||
status: quotation?.status ?? referenceData.statuses[0]?.id ?? '',
|
status: quotation?.status ?? referenceData.statuses[0]?.id ?? "",
|
||||||
chancePercent: quotation?.chancePercent !== null && quotation?.chancePercent !== undefined ? String(quotation.chancePercent) : '',
|
chancePercent:
|
||||||
projectCloseDate: quotation?.projectCloseDate?.slice(0, 10) ?? initialPrefill?.projectCloseDate ?? '',
|
quotation?.chancePercent !== null &&
|
||||||
deliveryDate: quotation?.deliveryDate?.slice(0, 10) ?? initialPrefill?.deliveryDate ?? '',
|
quotation?.chancePercent !== undefined
|
||||||
competitor: quotation?.competitor ?? '',
|
? String(quotation.chancePercent)
|
||||||
reference: quotation?.reference ?? '',
|
: "",
|
||||||
notes: quotation?.notes ?? '',
|
projectCloseDate:
|
||||||
salesmanId: quotation?.salesmanId ?? '',
|
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),
|
discount: String(quotation?.discount ?? 0),
|
||||||
discountType: quotation?.discountType ?? '',
|
discountType: quotation?.discountType ?? "",
|
||||||
taxRate: String(quotation?.taxRate ?? 0),
|
taxRate: String(quotation?.taxRate ?? 0),
|
||||||
sentVia: quotation?.sentVia ?? '',
|
sentVia: quotation?.sentVia ?? "",
|
||||||
revisionRemark: quotation?.revisionRemark ?? '',
|
revisionRemark: quotation?.revisionRemark ?? "",
|
||||||
isHotProject: quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
|
isHotProject:
|
||||||
|
quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
|
||||||
hotProjectAutoSuggested:
|
hotProjectAutoSuggested:
|
||||||
quotation?.hotProjectAutoSuggested ?? initialPrefill?.hotProjectAutoSuggested ?? false,
|
quotation?.hotProjectAutoSuggested ??
|
||||||
|
initialPrefill?.hotProjectAutoSuggested ??
|
||||||
|
false,
|
||||||
hotProjectManuallyOverridden:
|
hotProjectManuallyOverridden:
|
||||||
quotation?.hotProjectManuallyOverridden ?? initialPrefill?.hotProjectManuallyOverridden ?? false,
|
quotation?.hotProjectManuallyOverridden ??
|
||||||
isActive: quotation?.isActive ?? true
|
initialPrefill?.hotProjectManuallyOverridden ??
|
||||||
|
false,
|
||||||
|
isActive: quotation?.isActive ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
label,
|
label,
|
||||||
required,
|
required,
|
||||||
children
|
children,
|
||||||
}: React.PropsWithChildren<{ label: string; required?: boolean }>) {
|
}: React.PropsWithChildren<{ label: string; required?: boolean }>) {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<div className='text-sm font-medium'>
|
<div className="text-sm font-medium">
|
||||||
{label}
|
{label}
|
||||||
{required ? ' *' : ''}
|
{required ? " *" : ""}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +245,7 @@ export function QuotationFormSheet({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
referenceData,
|
referenceData,
|
||||||
initialPrefill
|
initialPrefill,
|
||||||
}: {
|
}: {
|
||||||
quotation?: QuotationRecord;
|
quotation?: QuotationRecord;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -205,38 +256,44 @@ export function QuotationFormSheet({
|
|||||||
const isEdit = Boolean(quotation);
|
const isEdit = Boolean(quotation);
|
||||||
const defaultState = useMemo(
|
const defaultState = useMemo(
|
||||||
() => toFormState(quotation, referenceData, initialPrefill),
|
() => toFormState(quotation, referenceData, initialPrefill),
|
||||||
[quotation, referenceData, initialPrefill]
|
[quotation, referenceData, initialPrefill],
|
||||||
);
|
);
|
||||||
const [state, setState] = useState<FormState>(defaultState);
|
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 [dirtyFields, setDirtyFields] = useState<Set<string>>(new Set());
|
||||||
const [projectPartiesDirty, setProjectPartiesDirty] = useState(false);
|
const [projectPartiesDirty, setProjectPartiesDirty] = useState(false);
|
||||||
|
|
||||||
const projectPartiesQuery = useQuery({
|
const projectPartiesQuery = useQuery({
|
||||||
...quotationCustomersOptions(quotation?.id ?? ''),
|
...quotationCustomersOptions(quotation?.id ?? ""),
|
||||||
enabled: open && Boolean(quotation?.id)
|
enabled: open && Boolean(quotation?.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createQuotationMutation,
|
...createQuotationMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Quotation created successfully');
|
toast.success("Quotation created successfully");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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({
|
const updateMutation = useMutation({
|
||||||
...updateQuotationMutation,
|
...updateQuotationMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Quotation updated successfully');
|
toast.success("Quotation updated successfully");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -254,14 +311,20 @@ export function QuotationFormSheet({
|
|||||||
key: item.id,
|
key: item.id,
|
||||||
customerId: item.customerId,
|
customerId: item.customerId,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
remark: item.remark ?? ''
|
remark: item.remark ?? "",
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProjectParties(initialPrefill?.projectParties ?? []);
|
setProjectParties(initialPrefill?.projectParties ?? []);
|
||||||
}, [defaultState, initialPrefill, open, projectPartiesQuery.data?.items, quotation]);
|
}, [
|
||||||
|
defaultState,
|
||||||
|
initialPrefill,
|
||||||
|
open,
|
||||||
|
projectPartiesQuery.data?.items,
|
||||||
|
quotation,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -279,21 +342,27 @@ export function QuotationFormSheet({
|
|||||||
return { ...current, hotProjectAutoSuggested: suggested };
|
return { ...current, hotProjectAutoSuggested: suggested };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.hotProjectAutoSuggested === suggested && current.isHotProject === suggested) {
|
if (
|
||||||
|
current.hotProjectAutoSuggested === suggested &&
|
||||||
|
current.isHotProject === suggested
|
||||||
|
) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
isHotProject: suggested,
|
isHotProject: suggested,
|
||||||
hotProjectAutoSuggested: suggested
|
hotProjectAutoSuggested: suggested,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [open, state.projectCloseDate, state.hotProjectManuallyOverridden]);
|
}, [open, state.projectCloseDate, state.hotProjectManuallyOverridden]);
|
||||||
|
|
||||||
const contacts = useMemo(
|
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) {
|
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 }));
|
setState((current) => ({ ...current, [key]: value }));
|
||||||
|
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
@@ -321,7 +394,7 @@ export function QuotationFormSheet({
|
|||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
|
|
||||||
if (prefill.opportunityId !== undefined) {
|
if (prefill.opportunityId !== undefined) {
|
||||||
next.opportunityId = prefill.opportunityId ?? '';
|
next.opportunityId = prefill.opportunityId ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of PREFILL_FIELD_KEYS) {
|
for (const key of PREFILL_FIELD_KEYS) {
|
||||||
@@ -336,7 +409,9 @@ export function QuotationFormSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
(next[key] as FormState[typeof key]) =
|
(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;
|
return next;
|
||||||
@@ -348,12 +423,14 @@ export function QuotationFormSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOpportunityChange(value: string) {
|
function handleOpportunityChange(value: string) {
|
||||||
const nextOpportunityId = value === '__none__' ? '' : value;
|
const nextOpportunityId = value === "__none__" ? "" : value;
|
||||||
const opportunity = referenceData.opportunities.find((item) => item.id === nextOpportunityId);
|
const opportunity = referenceData.opportunities.find(
|
||||||
markDirty('opportunityId');
|
(item) => item.id === nextOpportunityId,
|
||||||
|
);
|
||||||
|
markDirty("opportunityId");
|
||||||
applyPrefill({
|
applyPrefill({
|
||||||
...toOpportunityPrefill(opportunity, referenceData),
|
...toOpportunityPrefill(opportunity, referenceData),
|
||||||
opportunityId: nextOpportunityId
|
opportunityId: nextOpportunityId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,14 +473,16 @@ export function QuotationFormSheet({
|
|||||||
customerId: item.customerId,
|
customerId: item.customerId,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
remark: item.remark || null
|
remark: item.remark || null,
|
||||||
}))
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = quotationSchema.safeParse(payload);
|
const parsed = quotationSchema.safeParse(payload);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
toast.error(parsed.error.issues[0]?.message ?? 'Invalid quotation payload');
|
toast.error(
|
||||||
|
parsed.error.issues[0]?.message ?? "Invalid quotation payload",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,22 +498,30 @@ export function QuotationFormSheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className='flex flex-col sm:max-w-5xl'>
|
<SheetContent className="flex flex-col sm:max-w-5xl">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
|
<SheetTitle>{isEdit ? "Edit Quotation" : "New Quotation"}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Capture quotation header data, billing customer, project parties, and commercial context.
|
Capture quotation header data, billing customer, project parties,
|
||||||
|
and commercial context.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form id='quotation-form-sheet' onSubmit={onSubmit} className='grid flex-1 gap-4 overflow-auto py-2 md:grid-cols-2'>
|
<form
|
||||||
<Field label='Opportunity'>
|
id="quotation-form-sheet"
|
||||||
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
|
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>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select opportunity' />
|
<SelectValue placeholder="Select opportunity" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>No linked opportunity</SelectItem>
|
<SelectItem value="__none__">No linked opportunity</SelectItem>
|
||||||
{referenceData.opportunities.map((item) => (
|
{referenceData.opportunities.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.code} - {item.title}
|
{item.code} - {item.title}
|
||||||
@@ -444,16 +531,16 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Customer' required>
|
<Field label="Customer" required>
|
||||||
<Select
|
<Select
|
||||||
value={state.customerId}
|
value={state.customerId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setField('customerId', value);
|
setField("customerId", value);
|
||||||
setField('contactId', '');
|
setField("contactId", "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select customer' />
|
<SelectValue placeholder="Select customer" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceData.customers.map((item) => (
|
{referenceData.customers.map((item) => (
|
||||||
@@ -465,16 +552,18 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Contact'>
|
<Field label="Contact">
|
||||||
<Select
|
<Select
|
||||||
value={state.contactId || '__none__'}
|
value={state.contactId || "__none__"}
|
||||||
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
|
onValueChange={(value) =>
|
||||||
|
setField("contactId", value === "__none__" ? "" : value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select contact' />
|
<SelectValue placeholder="Select contact" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>No contact</SelectItem>
|
<SelectItem value="__none__">No contact</SelectItem>
|
||||||
{contacts.map((item) => (
|
{contacts.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -484,10 +573,13 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Quotation Type' required>
|
<Field label="Quotation Type" required>
|
||||||
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
|
<Select
|
||||||
|
value={state.quotationType}
|
||||||
|
onValueChange={(value) => setField("quotationType", value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select type' />
|
<SelectValue placeholder="Select type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceData.quotationTypes.map((item) => (
|
{referenceData.quotationTypes.map((item) => (
|
||||||
@@ -499,32 +591,46 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Quotation Date' required>
|
<Field label="Quotation Date" required>
|
||||||
<CrmDateInput value={state.quotationDate} onChange={(value) => setField('quotationDate', value)} />
|
<CrmDateInput
|
||||||
|
value={state.quotationDate}
|
||||||
|
onChange={(value) => setField("quotationDate", value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Valid Until'>
|
<Field label="Valid Until">
|
||||||
<CrmDateInput value={state.validUntil} onChange={(value) => setField('validUntil', value)} />
|
<CrmDateInput
|
||||||
|
value={state.validUntil}
|
||||||
|
onChange={(value) => setField("validUntil", value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Project Close Date'>
|
<Field label="Project Close Date">
|
||||||
<CrmDateInput value={state.projectCloseDate} onChange={(value) => setField('projectCloseDate', value)} />
|
<CrmDateInput
|
||||||
|
value={state.projectCloseDate}
|
||||||
|
onChange={(value) => setField("projectCloseDate", value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Delivery Date'>
|
<Field label="Delivery Date">
|
||||||
<CrmDateInput value={state.deliveryDate} onChange={(value) => setField('deliveryDate', value)} />
|
<CrmDateInput
|
||||||
|
value={state.deliveryDate}
|
||||||
|
onChange={(value) => setField("deliveryDate", value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Branch'>
|
<Field label="Branch">
|
||||||
<Select
|
<Select
|
||||||
value={state.branchId || '__none__'}
|
value={state.branchId || "__none__"}
|
||||||
onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}
|
onValueChange={(value) =>
|
||||||
|
setField("branchId", value === "__none__" ? "" : value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select branch' />
|
<SelectValue placeholder="Select branch" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||||
{referenceData.branches.map((item) => (
|
{referenceData.branches.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -534,10 +640,13 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Status'>
|
<Field label="Status">
|
||||||
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
|
<Select
|
||||||
|
value={state.status}
|
||||||
|
onValueChange={(value) => setField("status", value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select status' />
|
<SelectValue placeholder="Select status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceData.statuses.map((item) => (
|
{referenceData.statuses.map((item) => (
|
||||||
@@ -549,10 +658,13 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Currency'>
|
<Field label="Currency">
|
||||||
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
|
<Select
|
||||||
|
value={state.currency}
|
||||||
|
onValueChange={(value) => setField("currency", value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select currency' />
|
<SelectValue placeholder="Select currency" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceData.currencies.map((item) => (
|
{referenceData.currencies.map((item) => (
|
||||||
@@ -564,36 +676,58 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Exchange Rate'>
|
<Field label="Exchange Rate">
|
||||||
<CrmNumberInput value={state.exchangeRate} onChange={(value) => setField('exchangeRate', value)} step='0.0001' />
|
<CrmNumberInput
|
||||||
|
value={state.exchangeRate}
|
||||||
|
onChange={(value) => setField("exchangeRate", value)}
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Project Name'>
|
<Field label="Project Name">
|
||||||
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
|
<Input
|
||||||
|
value={state.projectName}
|
||||||
|
onChange={(e) => setField("projectName", e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Project Location'>
|
<Field label="Project Location">
|
||||||
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
|
<Input
|
||||||
|
value={state.projectLocation}
|
||||||
|
onChange={(e) => setField("projectLocation", e.target.value)}
|
||||||
|
placeholder="Project location"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Attention'>
|
{/* <Field label="Attention">
|
||||||
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
|
<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>
|
||||||
|
|
||||||
<Field label='Reference'>
|
<Field label="Salesman">
|
||||||
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label='Salesman'>
|
|
||||||
<Select
|
<Select
|
||||||
value={state.salesmanId || '__none__'}
|
value={state.salesmanId || "__none__"}
|
||||||
onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}
|
onValueChange={(value) =>
|
||||||
|
setField("salesmanId", value === "__none__" ? "" : value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select salesman' />
|
<SelectValue placeholder="Select salesman" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>Unassigned</SelectItem>
|
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||||
{referenceData.salesmen.map((item) => (
|
{referenceData.salesmen.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -603,24 +737,33 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Chance %'>
|
<Field label="Chance %">
|
||||||
<CrmPercentageInput value={state.chancePercent} onChange={(value) => setField('chancePercent', value)} />
|
<CrmPercentageInput
|
||||||
|
value={state.chancePercent}
|
||||||
|
onChange={(value) => setField("chancePercent", value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Discount'>
|
<Field label="Discount">
|
||||||
<CrmCurrencyInput value={state.discount} onChange={(value) => setField('discount', value)} currencyLabel='THB' />
|
<CrmCurrencyInput
|
||||||
|
value={state.discount}
|
||||||
|
onChange={(value) => setField("discount", value)}
|
||||||
|
currencyLabel="THB"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Discount Type'>
|
<Field label="Discount Type">
|
||||||
<Select
|
<Select
|
||||||
value={state.discountType || '__none__'}
|
value={state.discountType || "__none__"}
|
||||||
onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}
|
onValueChange={(value) =>
|
||||||
|
setField("discountType", value === "__none__" ? "" : value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select discount type' />
|
<SelectValue placeholder="Select discount type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>None</SelectItem>
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
{referenceData.discountTypes.map((item) => (
|
{referenceData.discountTypes.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -630,20 +773,25 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Tax Rate %'>
|
<Field label="Tax Rate %">
|
||||||
<CrmPercentageInput value={state.taxRate} onChange={(value) => setField('taxRate', value)} />
|
<CrmPercentageInput
|
||||||
|
value={state.taxRate}
|
||||||
|
onChange={(value) => setField("taxRate", value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Sent Via'>
|
<Field label="Sent Via">
|
||||||
<Select
|
<Select
|
||||||
value={state.sentVia || '__none__'}
|
value={state.sentVia || "__none__"}
|
||||||
onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}
|
onValueChange={(value) =>
|
||||||
|
setField("sentVia", value === "__none__" ? "" : value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select sent via' />
|
<SelectValue placeholder="Select sent via" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>Not sent yet</SelectItem>
|
<SelectItem value="__none__">Not sent yet</SelectItem>
|
||||||
{referenceData.sentVias.map((item) => (
|
{referenceData.sentVias.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -653,11 +801,15 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Competitor'>
|
<Field label="Competitor">
|
||||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
<Input
|
||||||
|
value={state.competitor}
|
||||||
|
onChange={(e) => setField("competitor", e.target.value)}
|
||||||
|
placeholder="Competitor"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className='md:col-span-2'>
|
<div className="md:col-span-2">
|
||||||
<ProjectPartiesEditor
|
<ProjectPartiesEditor
|
||||||
customers={referenceData.customers}
|
customers={referenceData.customers}
|
||||||
roles={referenceData.projectPartyRoles}
|
roles={referenceData.projectPartyRoles}
|
||||||
@@ -669,27 +821,34 @@ export function QuotationFormSheet({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='md:col-span-2'>
|
<div className="md:col-span-2">
|
||||||
<Field label='Notes'>
|
<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
|
<CrmTextarea
|
||||||
value={state.revisionRemark}
|
value={state.notes}
|
||||||
onChange={(e) => setField('revisionRemark', e.target.value)}
|
onChange={(e) => setField("notes", e.target.value)}
|
||||||
placeholder='Revision note or approval context'
|
placeholder="Internal notes"
|
||||||
size='md'
|
size="md"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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>
|
||||||
<div className='font-medium'>Hot Project</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="text-muted-foreground text-sm">
|
||||||
|
Auto-suggested when project close date is within 3 months.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={state.isHotProject}
|
checked={state.isHotProject}
|
||||||
@@ -698,29 +857,44 @@ export function QuotationFormSheet({
|
|||||||
...current,
|
...current,
|
||||||
isHotProject: checked,
|
isHotProject: checked,
|
||||||
hotProjectManuallyOverridden: true,
|
hotProjectManuallyOverridden: true,
|
||||||
hotProjectAutoSuggested: isHotProjectSuggested(current.projectCloseDate)
|
hotProjectAutoSuggested: isHotProjectSuggested(
|
||||||
|
current.projectCloseDate,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
markDirty('isHotProject');
|
markDirty("isHotProject");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div className='font-medium'>Active Record</div>
|
<div className="font-medium">Active Record</div>
|
||||||
<div className='text-muted-foreground text-sm'>Inactive quotations stay as history only.</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>
|
||||||
|
<Switch
|
||||||
|
checked={state.isActive}
|
||||||
|
onCheckedChange={(checked) => setField("isActive", checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' form='quotation-form-sheet' isLoading={isPending}>
|
<Button
|
||||||
<Icons.check className='mr-2 h-4 w-4' />
|
type="submit"
|
||||||
{isEdit ? 'Update Quotation' : 'Create Quotation'}
|
form="quotation-form-sheet"
|
||||||
|
isLoading={isPending}
|
||||||
|
>
|
||||||
|
<Icons.check className="mr-2 h-4 w-4" />
|
||||||
|
{isEdit ? "Update Quotation" : "Create Quotation"}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -729,7 +903,7 @@ export function QuotationFormSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QuotationFormSheetTrigger({
|
export function QuotationFormSheetTrigger({
|
||||||
referenceData
|
referenceData,
|
||||||
}: {
|
}: {
|
||||||
referenceData: QuotationReferenceData;
|
referenceData: QuotationReferenceData;
|
||||||
}) {
|
}) {
|
||||||
@@ -738,10 +912,14 @@ export function QuotationFormSheetTrigger({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={() => setOpen(true)}>
|
||||||
<Icons.add className='mr-2 h-4 w-4' />
|
<Icons.add className="mr-2 h-4 w-4" />
|
||||||
Add Quotation
|
Add Quotation
|
||||||
</Button>
|
</Button>
|
||||||
<QuotationFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
<QuotationFormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ export async function uploadDocumentLibraryVersion(
|
|||||||
payload: DocumentLibraryVersionUploadPayload
|
payload: DocumentLibraryVersionUploadPayload
|
||||||
) {
|
) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
formData.set('libraryId', libraryId);
|
||||||
formData.set('version', payload.version);
|
formData.set('version', payload.version);
|
||||||
formData.set('file', payload.file);
|
formData.set('file', payload.file);
|
||||||
return uploadFormData<DocumentLibraryVersionActionResponse>(
|
return uploadFormData<DocumentLibraryVersionActionResponse>(
|
||||||
`${BASE_PATH}/${libraryId}/versions`,
|
`${BASE_PATH}/versions`,
|
||||||
formData
|
formData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user