preview pdf

This commit is contained in:
phaichayon
2026-07-14 18:37:09 +07:00
parent a9dcc11ea9
commit b51bf3e02a
6 changed files with 557 additions and 249 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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