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';
const ENQUIRY_OPTION_CATEGORIES = {
status: 'crm_opportunity_stage',
status: 'crm_opportunity_status',
stage: 'crm_opportunity_stage',
productType: 'crm_product_type',
priority: 'crm_priority',
leadChannel: 'crm_lead_channel',
@@ -693,6 +694,7 @@ export async function getOpportunityReferenceData(
): Promise<OpportunityReferenceData> {
const [
branches,
statuses,
stages,
outcomeStatuses,
productTypes,
@@ -709,6 +711,7 @@ export async function getOpportunityReferenceData(
] = await Promise.all([
getUserBranches(),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.stage, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
@@ -752,7 +755,7 @@ export async function getOpportunityReferenceData(
return {
branches: branches.map(mapBranch),
statuses: stages.map(mapOption),
statuses: statuses.map(mapOption),
stages: stages.map(mapOption),
outcomeStatuses: outcomeStatuses.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<{
blob: Blob;
fileName: string;

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertModal } from "@/components/modal/alert-modal";
import { Icons } from "@/components/icons";
@@ -76,6 +76,7 @@ import {
import {
downloadApprovedQuotationPdf,
downloadQuotationCustomerPackage,
generateApprovedQuotationPdf,
} from "../api/service";
import type {
QuotationAttachmentMutationPayload,
@@ -814,6 +815,7 @@ export function QuotationDetail({
canDownloadCustomerPackage: boolean;
canGenerateApprovedPdf: boolean;
}) {
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
const { data: itemsData } = useSuspenseQuery(
quotationItemsOptions(quotationId),
@@ -1077,6 +1079,24 @@ export function QuotationDetail({
: "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(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
@@ -1134,7 +1154,7 @@ export function QuotationDetail({
? customerPackageError
: hasOfficialDocument
? "Generate the package from the approved PDF and active document library files."
: "Customer package requires an approved PDF before it can be generated.";
: "Customer package requires an approved PDF before it can be generated. Generate the official PDF first.";
const canReviseByStatus = [
"accepted",
"rejected",
@@ -2045,9 +2065,20 @@ export function QuotationDetail({
)}
</div>
<div className="flex flex-wrap gap-2">
{canGenerateApprovedPdf && !hasOfficialDocument ? (
<Button
variant="outline"
isLoading={createApprovedPdf.isPending}
onClick={() => createApprovedPdf.mutate()}
>
<Icons.badgeCheck className="mr-2 h-4 w-4" />
Generate Official PDF
</Button>
) : null}
{canGenerateCustomerPackage ? (
<Button
variant="outline"
disabled={!hasOfficialDocument}
isLoading={generateCustomerPackage.isPending}
onClick={() =>
customerPackage
@@ -2135,9 +2166,20 @@ export function QuotationDetail({
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{canGenerateApprovedPdf && !hasOfficialDocument ? (
<Button
variant="outline"
isLoading={createApprovedPdf.isPending}
onClick={() => createApprovedPdf.mutate()}
>
<Icons.badgeCheck className="mr-2 h-4 w-4" />
Generate Official PDF
</Button>
) : null}
{canGenerateCustomerPackage ? (
<Button
variant="outline"
disabled={!hasOfficialDocument}
isLoading={generateCustomerPackage.isPending}
onClick={() =>
customerPackage

View File

@@ -1,33 +1,52 @@
'use client';
"use client";
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { ProjectPartiesEditor, type ProjectPartyEditorItem } from '@/features/crm/components/project-parties-editor';
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import {
ProjectPartiesEditor,
type ProjectPartyEditorItem,
} from "@/features/crm/components/project-parties-editor";
import {
CrmCurrencyInput,
CrmDateInput,
CrmNumberInput,
CrmPercentageInput,
CrmTextarea
} from '@/features/crm/components/crm-form-controls';
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project';
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
import { quotationCustomersOptions } from '../api/queries';
CrmTextarea,
} from "@/features/crm/components/crm-form-controls";
import { isHotProjectSuggested } from "@/features/crm/shared/hot-project";
import {
createQuotationMutation,
updateQuotationMutation,
} from "../api/mutations";
import { quotationCustomersOptions } from "../api/queries";
import type {
QuotationMutationPayload,
QuotationOpportunityLookup,
QuotationRecord,
QuotationReferenceData
} from '../api/types';
import { quotationSchema } from '../schemas/quotation.schema';
QuotationReferenceData,
} from "../api/types";
import { quotationSchema } from "../schemas/quotation.schema";
type FormState = {
opportunityId: string;
@@ -78,41 +97,43 @@ export interface QuotationCreatePrefill {
}
type PrefillFieldKey =
| 'customerId'
| 'contactId'
| 'projectName'
| 'projectLocation'
| 'branchId'
| 'quotationType'
| 'projectCloseDate'
| 'deliveryDate'
| 'isHotProject'
| 'hotProjectAutoSuggested'
| 'hotProjectManuallyOverridden';
| "customerId"
| "contactId"
| "projectName"
| "projectLocation"
| "branchId"
| "quotationType"
| "projectCloseDate"
| "deliveryDate"
| "isHotProject"
| "hotProjectAutoSuggested"
| "hotProjectManuallyOverridden";
const PREFILL_FIELD_KEYS: PrefillFieldKey[] = [
'customerId',
'contactId',
'projectName',
'projectLocation',
'branchId',
'quotationType',
'projectCloseDate',
'deliveryDate',
'isHotProject',
'hotProjectAutoSuggested',
'hotProjectManuallyOverridden'
"customerId",
"contactId",
"projectName",
"projectLocation",
"branchId",
"quotationType",
"projectCloseDate",
"deliveryDate",
"isHotProject",
"hotProjectAutoSuggested",
"hotProjectManuallyOverridden",
];
function toOpportunityPrefill(
opportunity: QuotationOpportunityLookup | undefined,
referenceData: QuotationReferenceData
referenceData: QuotationReferenceData,
): QuotationCreatePrefill | null {
if (!opportunity) {
return null;
}
const quotationType = referenceData.quotationTypes.some((item) => item.id === opportunity.productType)
const quotationType = referenceData.quotationTypes.some(
(item) => item.id === opportunity.productType,
)
? opportunity.productType
: null;
@@ -124,65 +145,95 @@ function toOpportunityPrefill(
projectLocation: opportunity.projectLocation,
branchId: opportunity.branchId,
quotationType,
projectCloseDate: opportunity.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : null,
deliveryDate: opportunity.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : null,
projectCloseDate: opportunity.projectCloseDate
? opportunity.projectCloseDate.slice(0, 10)
: null,
deliveryDate: opportunity.deliveryDate
? opportunity.deliveryDate.slice(0, 10)
: null,
isHotProject: opportunity.isHotProject,
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden,
};
}
function toFormState(
quotation: QuotationRecord | undefined,
referenceData: QuotationReferenceData,
initialPrefill?: QuotationCreatePrefill | null
initialPrefill?: QuotationCreatePrefill | null,
): FormState {
return {
opportunityId: quotation?.opportunityId ?? initialPrefill?.opportunityId ?? '',
customerId: quotation?.customerId ?? initialPrefill?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: quotation?.contactId ?? initialPrefill?.contactId ?? '',
quotationDate: quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
validUntil: quotation?.validUntil?.slice(0, 10) ?? '',
opportunityId:
quotation?.opportunityId ?? initialPrefill?.opportunityId ?? "",
customerId:
quotation?.customerId ??
initialPrefill?.customerId ??
referenceData.customers[0]?.id ??
"",
contactId: quotation?.contactId ?? initialPrefill?.contactId ?? "",
quotationDate:
quotation?.quotationDate?.slice(0, 10) ??
new Date().toISOString().slice(0, 10),
validUntil: quotation?.validUntil?.slice(0, 10) ?? "",
quotationType:
quotation?.quotationType ?? initialPrefill?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? '',
projectLocation: quotation?.projectLocation ?? initialPrefill?.projectLocation ?? '',
attention: quotation?.attention ?? '',
branchId: quotation?.branchId ?? initialPrefill?.branchId ?? '',
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '',
quotation?.quotationType ??
initialPrefill?.quotationType ??
referenceData.quotationTypes[0]?.id ??
"",
projectName: quotation?.projectName ?? initialPrefill?.projectName ?? "",
projectLocation:
quotation?.projectLocation ?? initialPrefill?.projectLocation ?? "",
attention: quotation?.attention ?? "",
branchId: quotation?.branchId ?? initialPrefill?.branchId ?? "",
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? "",
exchangeRate: String(quotation?.exchangeRate ?? 1),
status: quotation?.status ?? referenceData.statuses[0]?.id ?? '',
chancePercent: quotation?.chancePercent !== null && quotation?.chancePercent !== undefined ? String(quotation.chancePercent) : '',
projectCloseDate: quotation?.projectCloseDate?.slice(0, 10) ?? initialPrefill?.projectCloseDate ?? '',
deliveryDate: quotation?.deliveryDate?.slice(0, 10) ?? initialPrefill?.deliveryDate ?? '',
competitor: quotation?.competitor ?? '',
reference: quotation?.reference ?? '',
notes: quotation?.notes ?? '',
salesmanId: quotation?.salesmanId ?? '',
status: quotation?.status ?? referenceData.statuses[0]?.id ?? "",
chancePercent:
quotation?.chancePercent !== null &&
quotation?.chancePercent !== undefined
? String(quotation.chancePercent)
: "",
projectCloseDate:
quotation?.projectCloseDate?.slice(0, 10) ??
initialPrefill?.projectCloseDate ??
"",
deliveryDate:
quotation?.deliveryDate?.slice(0, 10) ??
initialPrefill?.deliveryDate ??
"",
competitor: quotation?.competitor ?? "",
reference: quotation?.reference ?? "",
notes: quotation?.notes ?? "",
salesmanId: quotation?.salesmanId ?? "",
discount: String(quotation?.discount ?? 0),
discountType: quotation?.discountType ?? '',
discountType: quotation?.discountType ?? "",
taxRate: String(quotation?.taxRate ?? 0),
sentVia: quotation?.sentVia ?? '',
revisionRemark: quotation?.revisionRemark ?? '',
isHotProject: quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
sentVia: quotation?.sentVia ?? "",
revisionRemark: quotation?.revisionRemark ?? "",
isHotProject:
quotation?.isHotProject ?? initialPrefill?.isHotProject ?? false,
hotProjectAutoSuggested:
quotation?.hotProjectAutoSuggested ?? initialPrefill?.hotProjectAutoSuggested ?? false,
quotation?.hotProjectAutoSuggested ??
initialPrefill?.hotProjectAutoSuggested ??
false,
hotProjectManuallyOverridden:
quotation?.hotProjectManuallyOverridden ?? initialPrefill?.hotProjectManuallyOverridden ?? false,
isActive: quotation?.isActive ?? true
quotation?.hotProjectManuallyOverridden ??
initialPrefill?.hotProjectManuallyOverridden ??
false,
isActive: quotation?.isActive ?? true,
};
}
function Field({
label,
required,
children
children,
}: React.PropsWithChildren<{ label: string; required?: boolean }>) {
return (
<div className='space-y-2'>
<div className='text-sm font-medium'>
<div className="space-y-2">
<div className="text-sm font-medium">
{label}
{required ? ' *' : ''}
{required ? " *" : ""}
</div>
{children}
</div>
@@ -194,7 +245,7 @@ export function QuotationFormSheet({
open,
onOpenChange,
referenceData,
initialPrefill
initialPrefill,
}: {
quotation?: QuotationRecord;
open: boolean;
@@ -205,38 +256,44 @@ export function QuotationFormSheet({
const isEdit = Boolean(quotation);
const defaultState = useMemo(
() => toFormState(quotation, referenceData, initialPrefill),
[quotation, referenceData, initialPrefill]
[quotation, referenceData, initialPrefill],
);
const [state, setState] = useState<FormState>(defaultState);
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
const [projectParties, setProjectParties] = useState<
ProjectPartyEditorItem[]
>([]);
const [dirtyFields, setDirtyFields] = useState<Set<string>>(new Set());
const [projectPartiesDirty, setProjectPartiesDirty] = useState(false);
const projectPartiesQuery = useQuery({
...quotationCustomersOptions(quotation?.id ?? ''),
enabled: open && Boolean(quotation?.id)
...quotationCustomersOptions(quotation?.id ?? ""),
enabled: open && Boolean(quotation?.id),
});
const createMutation = useMutation({
...createQuotationMutation,
onSuccess: () => {
toast.success('Quotation created successfully');
toast.success("Quotation created successfully");
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Failed to create quotation');
}
toast.error(
error instanceof Error ? error.message : "Failed to create quotation",
);
},
});
const updateMutation = useMutation({
...updateQuotationMutation,
onSuccess: () => {
toast.success('Quotation updated successfully');
toast.success("Quotation updated successfully");
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Failed to update quotation');
}
toast.error(
error instanceof Error ? error.message : "Failed to update quotation",
);
},
});
useEffect(() => {
@@ -254,14 +311,20 @@ export function QuotationFormSheet({
key: item.id,
customerId: item.customerId,
role: item.role,
remark: item.remark ?? ''
}))
remark: item.remark ?? "",
})),
);
return;
}
setProjectParties(initialPrefill?.projectParties ?? []);
}, [defaultState, initialPrefill, open, projectPartiesQuery.data?.items, quotation]);
}, [
defaultState,
initialPrefill,
open,
projectPartiesQuery.data?.items,
quotation,
]);
useEffect(() => {
if (!open) {
@@ -279,21 +342,27 @@ export function QuotationFormSheet({
return { ...current, hotProjectAutoSuggested: suggested };
}
if (current.hotProjectAutoSuggested === suggested && current.isHotProject === suggested) {
if (
current.hotProjectAutoSuggested === suggested &&
current.isHotProject === suggested
) {
return current;
}
return {
...current,
isHotProject: suggested,
hotProjectAutoSuggested: suggested
hotProjectAutoSuggested: suggested,
};
});
}, [open, state.projectCloseDate, state.hotProjectManuallyOverridden]);
const contacts = useMemo(
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
[referenceData.contacts, state.customerId]
() =>
referenceData.contacts.filter(
(item) => item.customerId === state.customerId,
),
[referenceData.contacts, state.customerId],
);
function markDirty(key: string) {
@@ -304,7 +373,11 @@ export function QuotationFormSheet({
});
}
function setField<K extends keyof FormState>(key: K, value: FormState[K], dirty = true) {
function setField<K extends keyof FormState>(
key: K,
value: FormState[K],
dirty = true,
) {
setState((current) => ({ ...current, [key]: value }));
if (dirty) {
@@ -321,7 +394,7 @@ export function QuotationFormSheet({
const next = { ...current };
if (prefill.opportunityId !== undefined) {
next.opportunityId = prefill.opportunityId ?? '';
next.opportunityId = prefill.opportunityId ?? "";
}
for (const key of PREFILL_FIELD_KEYS) {
@@ -336,7 +409,9 @@ export function QuotationFormSheet({
}
(next[key] as FormState[typeof key]) =
typeof next[key] === 'boolean' ? Boolean(value) : ((value ?? '') as FormState[typeof key]);
typeof next[key] === "boolean"
? Boolean(value)
: ((value ?? "") as FormState[typeof key]);
}
return next;
@@ -348,12 +423,14 @@ export function QuotationFormSheet({
}
function handleOpportunityChange(value: string) {
const nextOpportunityId = value === '__none__' ? '' : value;
const opportunity = referenceData.opportunities.find((item) => item.id === nextOpportunityId);
markDirty('opportunityId');
const nextOpportunityId = value === "__none__" ? "" : value;
const opportunity = referenceData.opportunities.find(
(item) => item.id === nextOpportunityId,
);
markDirty("opportunityId");
applyPrefill({
...toOpportunityPrefill(opportunity, referenceData),
opportunityId: nextOpportunityId
opportunityId: nextOpportunityId,
});
}
@@ -396,14 +473,16 @@ export function QuotationFormSheet({
customerId: item.customerId,
role: item.role,
isPrimary: false,
remark: item.remark || null
}))
remark: item.remark || null,
})),
};
const parsed = quotationSchema.safeParse(payload);
if (!parsed.success) {
toast.error(parsed.error.issues[0]?.message ?? 'Invalid quotation payload');
toast.error(
parsed.error.issues[0]?.message ?? "Invalid quotation payload",
);
return;
}
@@ -419,22 +498,30 @@ export function QuotationFormSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-5xl'>
<SheetContent className="flex flex-col sm:max-w-5xl">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
<SheetTitle>{isEdit ? "Edit Quotation" : "New Quotation"}</SheetTitle>
<SheetDescription>
Capture quotation header data, billing customer, project parties, and commercial context.
Capture quotation header data, billing customer, project parties,
and commercial context.
</SheetDescription>
</SheetHeader>
<form id='quotation-form-sheet' onSubmit={onSubmit} className='grid flex-1 gap-4 overflow-auto py-2 md:grid-cols-2'>
<Field label='Opportunity'>
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
<form
id="quotation-form-sheet"
onSubmit={onSubmit}
className="grid flex-1 gap-4 overflow-auto py-2 md:grid-cols-2"
>
<Field label="Opportunity">
<Select
value={state.opportunityId || "__none__"}
onValueChange={handleOpportunityChange}
>
<SelectTrigger>
<SelectValue placeholder='Select opportunity' />
<SelectValue placeholder="Select opportunity" />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No linked opportunity</SelectItem>
<SelectItem value="__none__">No linked opportunity</SelectItem>
{referenceData.opportunities.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.code} - {item.title}
@@ -444,16 +531,16 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Customer' required>
<Field label="Customer" required>
<Select
value={state.customerId}
onValueChange={(value) => {
setField('customerId', value);
setField('contactId', '');
setField("customerId", value);
setField("contactId", "");
}}
>
<SelectTrigger>
<SelectValue placeholder='Select customer' />
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
{referenceData.customers.map((item) => (
@@ -465,16 +552,18 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Contact'>
<Field label="Contact">
<Select
value={state.contactId || '__none__'}
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
value={state.contactId || "__none__"}
onValueChange={(value) =>
setField("contactId", value === "__none__" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Select contact' />
<SelectValue placeholder="Select contact" />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No contact</SelectItem>
<SelectItem value="__none__">No contact</SelectItem>
{contacts.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
@@ -484,10 +573,13 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Quotation Type' required>
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
<Field label="Quotation Type" required>
<Select
value={state.quotationType}
onValueChange={(value) => setField("quotationType", value)}
>
<SelectTrigger>
<SelectValue placeholder='Select type' />
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{referenceData.quotationTypes.map((item) => (
@@ -499,32 +591,46 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Quotation Date' required>
<CrmDateInput value={state.quotationDate} onChange={(value) => setField('quotationDate', value)} />
<Field label="Quotation Date" required>
<CrmDateInput
value={state.quotationDate}
onChange={(value) => setField("quotationDate", value)}
/>
</Field>
<Field label='Valid Until'>
<CrmDateInput value={state.validUntil} onChange={(value) => setField('validUntil', value)} />
<Field label="Valid Until">
<CrmDateInput
value={state.validUntil}
onChange={(value) => setField("validUntil", value)}
/>
</Field>
<Field label='Project Close Date'>
<CrmDateInput value={state.projectCloseDate} onChange={(value) => setField('projectCloseDate', value)} />
<Field label="Project Close Date">
<CrmDateInput
value={state.projectCloseDate}
onChange={(value) => setField("projectCloseDate", value)}
/>
</Field>
<Field label='Delivery Date'>
<CrmDateInput value={state.deliveryDate} onChange={(value) => setField('deliveryDate', value)} />
<Field label="Delivery Date">
<CrmDateInput
value={state.deliveryDate}
onChange={(value) => setField("deliveryDate", value)}
/>
</Field>
<Field label='Branch'>
<Field label="Branch">
<Select
value={state.branchId || '__none__'}
onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}
value={state.branchId || "__none__"}
onValueChange={(value) =>
setField("branchId", value === "__none__" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Select branch' />
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Unassigned</SelectItem>
<SelectItem value="__none__">Unassigned</SelectItem>
{referenceData.branches.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
@@ -534,10 +640,13 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Status'>
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
<Field label="Status">
<Select
value={state.status}
onValueChange={(value) => setField("status", value)}
>
<SelectTrigger>
<SelectValue placeholder='Select status' />
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{referenceData.statuses.map((item) => (
@@ -549,10 +658,13 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Currency'>
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
<Field label="Currency">
<Select
value={state.currency}
onValueChange={(value) => setField("currency", value)}
>
<SelectTrigger>
<SelectValue placeholder='Select currency' />
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{referenceData.currencies.map((item) => (
@@ -564,36 +676,58 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Exchange Rate'>
<CrmNumberInput value={state.exchangeRate} onChange={(value) => setField('exchangeRate', value)} step='0.0001' />
<Field label="Exchange Rate">
<CrmNumberInput
value={state.exchangeRate}
onChange={(value) => setField("exchangeRate", value)}
step="0.0001"
/>
</Field>
<Field label='Project Name'>
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
<Field label="Project Name">
<Input
value={state.projectName}
onChange={(e) => setField("projectName", e.target.value)}
placeholder="Project name"
/>
</Field>
<Field label='Project Location'>
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
<Field label="Project Location">
<Input
value={state.projectLocation}
onChange={(e) => setField("projectLocation", e.target.value)}
placeholder="Project location"
/>
</Field>
<Field label='Attention'>
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
{/* <Field label="Attention">
<Input
value={state.attention}
onChange={(e) => setField("attention", e.target.value)}
placeholder="Attention line"
/>
</Field> */}
<Field label="Estimate No.">
<Input
value={state.reference}
onChange={(e) => setField("reference", e.target.value)}
placeholder="Reference No."
/>
</Field>
<Field label='Reference'>
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
</Field>
<Field label='Salesman'>
<Field label="Salesman">
<Select
value={state.salesmanId || '__none__'}
onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}
value={state.salesmanId || "__none__"}
onValueChange={(value) =>
setField("salesmanId", value === "__none__" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Select salesman' />
<SelectValue placeholder="Select salesman" />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Unassigned</SelectItem>
<SelectItem value="__none__">Unassigned</SelectItem>
{referenceData.salesmen.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
@@ -603,24 +737,33 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Chance %'>
<CrmPercentageInput value={state.chancePercent} onChange={(value) => setField('chancePercent', value)} />
<Field label="Chance %">
<CrmPercentageInput
value={state.chancePercent}
onChange={(value) => setField("chancePercent", value)}
/>
</Field>
<Field label='Discount'>
<CrmCurrencyInput value={state.discount} onChange={(value) => setField('discount', value)} currencyLabel='THB' />
<Field label="Discount">
<CrmCurrencyInput
value={state.discount}
onChange={(value) => setField("discount", value)}
currencyLabel="THB"
/>
</Field>
<Field label='Discount Type'>
<Field label="Discount Type">
<Select
value={state.discountType || '__none__'}
onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}
value={state.discountType || "__none__"}
onValueChange={(value) =>
setField("discountType", value === "__none__" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Select discount type' />
<SelectValue placeholder="Select discount type" />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>None</SelectItem>
<SelectItem value="__none__">None</SelectItem>
{referenceData.discountTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
@@ -630,20 +773,25 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Tax Rate %'>
<CrmPercentageInput value={state.taxRate} onChange={(value) => setField('taxRate', value)} />
<Field label="Tax Rate %">
<CrmPercentageInput
value={state.taxRate}
onChange={(value) => setField("taxRate", value)}
/>
</Field>
<Field label='Sent Via'>
<Field label="Sent Via">
<Select
value={state.sentVia || '__none__'}
onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}
value={state.sentVia || "__none__"}
onValueChange={(value) =>
setField("sentVia", value === "__none__" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Select sent via' />
<SelectValue placeholder="Select sent via" />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Not sent yet</SelectItem>
<SelectItem value="__none__">Not sent yet</SelectItem>
{referenceData.sentVias.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
@@ -653,11 +801,15 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Competitor'>
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
<Field label="Competitor">
<Input
value={state.competitor}
onChange={(e) => setField("competitor", e.target.value)}
placeholder="Competitor"
/>
</Field>
<div className='md:col-span-2'>
<div className="md:col-span-2">
<ProjectPartiesEditor
customers={referenceData.customers}
roles={referenceData.projectPartyRoles}
@@ -669,27 +821,34 @@ export function QuotationFormSheet({
/>
</div>
<div className='md:col-span-2'>
<Field label='Notes'>
<CrmTextarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' size='md' />
</Field>
</div>
<div className='md:col-span-2'>
<Field label='Revision Remark'>
<div className="md:col-span-2">
<Field label="Notes">
<CrmTextarea
value={state.revisionRemark}
onChange={(e) => setField('revisionRemark', e.target.value)}
placeholder='Revision note or approval context'
size='md'
value={state.notes}
onChange={(e) => setField("notes", e.target.value)}
placeholder="Internal notes"
size="md"
/>
</Field>
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div className="md:col-span-2">
<Field label="Revision Remark">
<CrmTextarea
value={state.revisionRemark}
onChange={(e) => setField("revisionRemark", e.target.value)}
placeholder="Revision note or approval context"
size="md"
/>
</Field>
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className='font-medium'>Hot Project</div>
<div className='text-muted-foreground text-sm'>Auto-suggested when project close date is within 3 months.</div>
<div className="font-medium">Hot Project</div>
<div className="text-muted-foreground text-sm">
Auto-suggested when project close date is within 3 months.
</div>
</div>
<Switch
checked={state.isHotProject}
@@ -698,29 +857,44 @@ export function QuotationFormSheet({
...current,
isHotProject: checked,
hotProjectManuallyOverridden: true,
hotProjectAutoSuggested: isHotProjectSuggested(current.projectCloseDate)
hotProjectAutoSuggested: isHotProjectSuggested(
current.projectCloseDate,
),
}));
markDirty('isHotProject');
markDirty("isHotProject");
}}
/>
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className='font-medium'>Active Record</div>
<div className='text-muted-foreground text-sm'>Inactive quotations stay as history only.</div>
<div className="font-medium">Active Record</div>
<div className="text-muted-foreground text-sm">
Inactive quotations stay as history only.
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setField('isActive', checked)} />
</div>
<Switch
checked={state.isActive}
onCheckedChange={(checked) => setField("isActive", checked)}
/>
</div>
</form>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type='submit' form='quotation-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Quotation' : 'Create Quotation'}
<Button
type="submit"
form="quotation-form-sheet"
isLoading={isPending}
>
<Icons.check className="mr-2 h-4 w-4" />
{isEdit ? "Update Quotation" : "Create Quotation"}
</Button>
</SheetFooter>
</SheetContent>
@@ -729,7 +903,7 @@ export function QuotationFormSheet({
}
export function QuotationFormSheetTrigger({
referenceData
referenceData,
}: {
referenceData: QuotationReferenceData;
}) {
@@ -738,10 +912,14 @@ export function QuotationFormSheetTrigger({
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
<Icons.add className="mr-2 h-4 w-4" />
Add Quotation
</Button>
<QuotationFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
<QuotationFormSheet
open={open}
onOpenChange={setOpen}
referenceData={referenceData}
/>
</>
);
}

View File

@@ -66,10 +66,11 @@ export async function uploadDocumentLibraryVersion(
payload: DocumentLibraryVersionUploadPayload
) {
const formData = new FormData();
formData.set('libraryId', libraryId);
formData.set('version', payload.version);
formData.set('file', payload.file);
return uploadFormData<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/${libraryId}/versions`,
`${BASE_PATH}/versions`,
formData
);
}