'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { AlertModal } from '@/components/modal/alert-modal';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDate, formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import {
CrmCurrencyInput,
CrmDateInput,
CrmNumberInput,
CrmPercentageInput,
CrmTextarea
} from '@/features/crm/components/crm-form-controls';
import {
createQuotationAttachmentMutation,
createQuotationCustomerMutation,
createQuotationFollowupMutation,
generateQuotationCustomerPackageMutation,
createQuotationItemMutation,
createQuotationRevisionMutation,
createQuotationTopicMutation,
deleteQuotationAttachmentMutation,
deleteQuotationCustomerMutation,
deleteQuotationFollowupMutation,
deleteQuotationItemMutation,
deleteQuotationTopicMutation,
updateQuotationAttachmentMutation,
updateQuotationCustomerMutation,
updateQuotationFollowupMutation,
updateQuotationItemMutation,
updateQuotationTopicMutation
} from '../api/mutations';
import {
quotationAttachmentsOptions,
quotationByIdOptions,
quotationCustomersOptions,
quotationFollowupsOptions,
quotationItemsOptions,
quotationKeys,
quotationRevisionsOptions,
quotationTopicsOptions
} from '../api/queries';
import {
downloadApprovedQuotationPdf,
downloadQuotationCustomerPackage,
generateApprovedQuotationPdf
} from '../api/service';
import type {
QuotationAttachmentMutationPayload,
QuotationAttachmentRecord,
QuotationCustomerListItem,
QuotationCustomerPackageSource,
QuotationCustomerMutationPayload,
QuotationFollowupMutationPayload,
QuotationFollowupRecord,
QuotationItemMutationPayload,
QuotationItemRecord,
QuotationReferenceData,
QuotationTopicMutationPayload,
QuotationTopicRecord
} from '../api/types';
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
import {
getCustomerPackageRoleLabel,
getCustomerPackageUiStatus,
mapCustomerPackageErrorMessage
} from '../customer-package';
import { QuotationDocumentPreview } from './quotation-document-preview';
import { QuotationFormSheet } from './quotation-form-sheet';
import { QuotationApprovalTab } from './quotation-approval-tab';
import { QuotationStatusBadge } from './quotation-status-badge';
import { getQueryClient } from '@/lib/query-client';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
);
}
function EmptyState({ message }: { message: string }) {
return (
{message}
);
}
function downloadBlobFile(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function CustomerPackageSourceItem({ item }: { item: QuotationCustomerPackageSource }) {
return (
{getCustomerPackageRoleLabel(item.role)}
{item.sourceType === 'generated' ? 'Generated' : 'Library'}
{item.fileName}
{item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'}
{item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ''}
{item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ''}
);
}
function ItemDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
item
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationItemMutationPayload) => Promise;
pending: boolean;
referenceData: QuotationReferenceData;
item?: QuotationItemRecord;
}) {
const [values, setValues] = useState({
productType: item?.productType ?? referenceData.productTypes[0]?.id ?? '',
description: item?.description ?? '',
quantity: String(item?.quantity ?? 1),
unit: item?.unit ?? '',
unitPrice: String(item?.unitPrice ?? 0),
discount: String(item?.discount ?? 0),
discountType: item?.discountType ?? '',
taxRate: String(item?.taxRate ?? 0),
notes: item?.notes ?? ''
});
return (
{item ? 'Edit Item' : 'Add Item'}
Line items drive quotation totals from the server.
setValues((s) => ({ ...s, productType: value }))}
>
{referenceData.productTypes.map((option) => (
{option.label}
))}
setValues((s) => ({ ...s, description: e.target.value }))}
placeholder='Description'
/>
setValues((s) => ({ ...s, quantity: value }))}
placeholder='Quantity'
/>
setValues((s) => ({
...s,
unit: value === '__none__' ? '' : value
}))
}
>
No unit
{referenceData.units.map((option) => (
{option.label}
))}
setValues((s) => ({ ...s, unitPrice: value }))}
placeholder='Unit price'
currencyLabel='THB'
/>
setValues((s) => ({ ...s, discount: value }))}
placeholder='Discount'
currencyLabel='THB'
/>
setValues((s) => ({
...s,
discountType: value === '__none__' ? '' : value
}))
}
>
No discount type
{referenceData.discountTypes.map((option) => (
{option.label}
))}
setValues((s) => ({ ...s, taxRate: value }))}
placeholder='Tax rate %'
/>
setValues((s) => ({ ...s, notes: e.target.value }))}
placeholder='Notes'
size='md'
/>
onOpenChange(false)}>
Cancel
{
await onSubmit({
productType: values.productType,
description: values.description,
quantity: Number(values.quantity),
unit: values.unit,
unitPrice: Number(values.unitPrice),
discount: Number(values.discount),
discountType: values.discountType || null,
taxRate: Number(values.taxRate),
notes: values.notes
});
onOpenChange(false);
}}
>
Save
);
}
function CustomerDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
relation
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationCustomerMutationPayload) => Promise;
pending: boolean;
referenceData: QuotationReferenceData;
relation?: QuotationCustomerListItem;
}) {
const [customerId, setCustomerId] = useState(
relation?.customerId ?? referenceData.customers[0]?.id ?? ''
);
const [role, setRole] = useState(relation?.role ?? referenceData.projectPartyRoles[0]?.id ?? '');
const [remark, setRemark] = useState(relation?.remark ?? '');
return (
{relation ? 'Edit Project Party' : 'Add Project Party'}
Keep related companies visible with their role in this quotation.
{referenceData.customers.map((item) => (
{item.name}
))}
{referenceData.projectPartyRoles.map((item) => (
{item.label}
))}
setRemark(event.target.value)}
placeholder='Remark'
/>
onOpenChange(false)}>
Cancel
{
await onSubmit({ customerId, role, remark: remark || null });
onOpenChange(false);
}}
>
Save
);
}
function TopicDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
topic
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationTopicMutationPayload) => Promise;
pending: boolean;
referenceData: QuotationReferenceData;
topic?: QuotationTopicRecord;
}) {
const [topicType, setTopicType] = useState(
topic?.topicType ?? referenceData.topicTypes[0]?.id ?? ''
);
const [title, setTitle] = useState(topic?.title ?? '');
const [itemsText, setItemsText] = useState(
topic?.items.map((item) => item.content).join('\n') ?? ''
);
return (
{topic ? 'Edit Topic' : 'Add Topic'}
Each line becomes a topic item in the generated quotation document.
{referenceData.topicTypes.map((item) => (
{item.label}
))}
setTitle(e.target.value)}
placeholder='Topic title'
/>
setItemsText(e.target.value)}
placeholder='One line per topic item'
size='lg'
/>
onOpenChange(false)}>
Cancel
{
await onSubmit({
id: topic?.id,
topicType,
title,
items: itemsText
.split('\n')
.map((item, index) => ({
content: item.trim(),
sortOrder: index
}))
.filter((item) => item.content.length > 0)
});
onOpenChange(false);
}}
>
Save
);
}
function FollowupDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
customerId,
followup
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationFollowupMutationPayload) => Promise;
pending: boolean;
referenceData: QuotationReferenceData;
customerId: string;
followup?: QuotationFollowupRecord;
}) {
const contacts = referenceData.contacts.filter((item) => item.customerId === customerId);
const [values, setValues] = useState({
followupDate: followup?.followupDate.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
followupType: followup?.followupType ?? referenceData.followupTypes[0]?.id ?? '',
contactId: followup?.contactId ?? '',
outcome: followup?.outcome ?? '',
notes: followup?.notes ?? '',
nextFollowupDate: followup?.nextFollowupDate?.slice(0, 10) ?? '',
nextAction: followup?.nextAction ?? ''
});
return (
{followup ? 'Edit Follow-up' : 'Add Follow-up'}
Keep quotation chasing and commercial communication visible.
setValues((s) => ({ ...s, followupDate: e }))}
className='w-full'
/>
setValues((s) => ({ ...s, followupType: value }))}
>
{referenceData.followupTypes.map((item) => (
{item.label}
))}
setValues((s) => ({
...s,
contactId: value === '__none__' ? '' : value
}))
}
>
No contact
{contacts.map((item) => (
{item.name}
))}
setValues((s) => ({ ...s, outcome: e.target.value }))}
placeholder='Outcome'
/>
setValues((s) => ({ ...s, nextFollowupDate: e }))}
className='w-full'
/>
setValues((s) => ({ ...s, nextAction: e.target.value }))}
placeholder='Next action'
/>
setValues((s) => ({ ...s, notes: e.target.value }))}
placeholder='Notes'
size='md'
/>
onOpenChange(false)}>
Cancel
{
await onSubmit({
id: followup?.id,
followupDate: values.followupDate,
followupType: values.followupType,
contactId: values.contactId || null,
outcome: values.outcome,
notes: values.notes,
nextFollowupDate: values.nextFollowupDate || null,
nextAction: values.nextAction
});
onOpenChange(false);
}}
>
Save
);
}
function AttachmentDialog({
open,
onOpenChange,
onSubmit,
pending,
attachment
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationAttachmentMutationPayload) => Promise;
pending: boolean;
attachment?: QuotationAttachmentRecord;
}) {
const [values, setValues] = useState({
fileName: attachment?.fileName ?? '',
originalFileName: attachment?.originalFileName ?? '',
filePath: attachment?.filePath ?? '',
fileSize: String(attachment?.fileSize ?? 0),
fileType: attachment?.fileType ?? '',
description: attachment?.description ?? ''
});
return (
{attachment ? 'Edit Attachment Metadata' : 'Add Attachment Metadata'}
Task E stores attachment metadata only, without file upload plumbing.
onOpenChange(false)}>
Cancel
{
await onSubmit({
id: attachment?.id,
fileName: values.fileName,
originalFileName: values.originalFileName,
filePath: values.filePath,
fileSize: Number(values.fileSize),
fileType: values.fileType || null,
description: values.description
});
onOpenChange(false);
}}
>
Save
);
}
export function QuotationDetail({
quotationId,
referenceData,
canUpdate,
canManageItems,
canManageCustomers,
canManageTopics,
canManageFollowups,
canManageAttachments,
canCreateRevision,
canSubmitApproval,
canApproveApproval,
canRejectApproval,
canReturnApproval,
activeBusinessRole,
isOrgAdmin,
currentUserId,
canPreviewDocument,
canPreviewPdf,
canPreviewCustomerPackage,
canDownloadPdf,
canGenerateCustomerPackage,
canDownloadCustomerPackage,
canGenerateApprovedPdf
}: {
quotationId: string;
referenceData: QuotationReferenceData;
canUpdate: boolean;
canManageItems: boolean;
canManageCustomers: boolean;
canManageTopics: boolean;
canManageFollowups: boolean;
canManageAttachments: boolean;
canCreateRevision: boolean;
canSubmitApproval: boolean;
canApproveApproval: boolean;
canRejectApproval: boolean;
canReturnApproval: boolean;
activeBusinessRole: string | null;
isOrgAdmin: boolean;
currentUserId: string;
canPreviewDocument: boolean;
canPreviewPdf: boolean;
canPreviewCustomerPackage: boolean;
canDownloadPdf: boolean;
canGenerateCustomerPackage: boolean;
canDownloadCustomerPackage: boolean;
canGenerateApprovedPdf: boolean;
}) {
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
const { data: customersData } = useSuspenseQuery(quotationCustomersOptions(quotationId));
const { data: topicsData } = useSuspenseQuery(quotationTopicsOptions(quotationId));
const { data: followupsData } = useSuspenseQuery(quotationFollowupsOptions(quotationId));
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
const quotation = data.quotation;
const [customerPackageError, setCustomerPackageError] = useState(null);
const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false);
const customerPackageStatus = getCustomerPackageUiStatus({
customerPackage: quotation.customerPackage,
lastErrorMessage: customerPackageError
});
const [editOpen, setEditOpen] = useState(false);
const [itemOpen, setItemOpen] = useState(false);
const [editingItem, setEditingItem] = useState();
const [deleteItemId, setDeleteItemId] = useState(null);
const createItem = useMutation({
...createQuotationItemMutation,
onSuccess: () => toast.success('Quotation item saved'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save item')
});
const updateItem = useMutation({
...updateQuotationItemMutation,
onSuccess: () => toast.success('Quotation item updated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update item')
});
const deleteItem = useMutation({
...deleteQuotationItemMutation,
onSuccess: () => {
toast.success('Quotation item deleted');
setDeleteItemId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete item')
});
const [customerOpen, setCustomerOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState();
const [deleteCustomerId, setDeleteCustomerId] = useState(null);
const createCustomer = useMutation({
...createQuotationCustomerMutation,
onSuccess: () => toast.success('Project party saved'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to save project party')
});
const updateCustomer = useMutation({
...updateQuotationCustomerMutation,
onSuccess: () => toast.success('Project party updated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update project party')
});
const deleteCustomer = useMutation({
...deleteQuotationCustomerMutation,
onSuccess: () => {
toast.success('Project party deleted');
setDeleteCustomerId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete project party')
});
const [topicOpen, setTopicOpen] = useState(false);
const [editingTopic, setEditingTopic] = useState();
const [deleteTopicId, setDeleteTopicId] = useState(null);
const createTopic = useMutation({
...createQuotationTopicMutation,
onSuccess: () => toast.success('Topic saved'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save topic')
});
const updateTopic = useMutation({
...updateQuotationTopicMutation,
onSuccess: () => toast.success('Topic updated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update topic')
});
const deleteTopic = useMutation({
...deleteQuotationTopicMutation,
onSuccess: () => {
toast.success('Topic deleted');
setDeleteTopicId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete topic')
});
const [followupOpen, setFollowupOpen] = useState(false);
const [editingFollowup, setEditingFollowup] = useState();
const [deleteFollowupId, setDeleteFollowupId] = useState(null);
const createFollowup = useMutation({
...createQuotationFollowupMutation,
onSuccess: () => toast.success('Follow-up saved'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to save follow-up')
});
const updateFollowup = useMutation({
...updateQuotationFollowupMutation,
onSuccess: () => toast.success('Follow-up updated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update follow-up')
});
const deleteFollowup = useMutation({
...deleteQuotationFollowupMutation,
onSuccess: () => {
toast.success('Follow-up deleted');
setDeleteFollowupId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete follow-up')
});
const [attachmentOpen, setAttachmentOpen] = useState(false);
const [editingAttachment, setEditingAttachment] = useState<
QuotationAttachmentRecord | undefined
>();
const [deleteAttachmentId, setDeleteAttachmentId] = useState(null);
const createAttachment = useMutation({
...createQuotationAttachmentMutation,
onSuccess: () => toast.success('Attachment metadata saved'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to save attachment')
});
const updateAttachment = useMutation({
...updateQuotationAttachmentMutation,
onSuccess: () => toast.success('Attachment metadata updated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update attachment')
});
const deleteAttachment = useMutation({
...deleteQuotationAttachmentMutation,
onSuccess: () => {
toast.success('Attachment metadata deleted');
setDeleteAttachmentId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete attachment')
});
const createRevision = useMutation({
...createQuotationRevisionMutation,
onSuccess: () => toast.success('Revision created successfully'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create revision')
});
const submitForApproval = useMutation({
...submitQuotationApprovalMutation,
onSuccess: () => toast.success('Quotation moved to pending approval'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
});
const generateCustomerPackage = useMutation({
...generateQuotationCustomerPackageMutation,
onSuccess: () => {
setCustomerPackageError(null);
setConfirmRegenerateOpen(false);
toast.success('Customer package generated successfully');
},
onError: (error) => {
const message = mapCustomerPackageErrorMessage(error instanceof Error ? error.message : null);
setCustomerPackageError(message);
setConfirmRegenerateOpen(false);
toast.error(message);
}
});
const downloadCustomerPackage = useMutation({
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
onSuccess: async ({ blob, fileName }) => {
setCustomerPackageError(null);
downloadBlobFile(blob, fileName);
toast.success('Customer package downloaded successfully');
},
onError: (error) => {
const message = mapCustomerPackageErrorMessage(error instanceof Error ? error.message : null);
setCustomerPackageError(message);
toast.error(message);
}
});
const downloadApprovedPdf = useMutation({
mutationFn: async () => downloadApprovedQuotationPdf(quotationId),
onSuccess: ({ blob, fileName }) => {
downloadBlobFile(blob, fileName);
toast.success('Approved PDF downloaded successfully');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to download approved PDF')
});
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])),
[referenceData.branches]
);
const customerMap = useMemo(
() => new Map(referenceData.customers.map((item) => [item.id, item])),
[referenceData.customers]
);
const contactMap = useMemo(
() => new Map(referenceData.contacts.map((item) => [item.id, item])),
[referenceData.contacts]
);
const statusMap = useMemo(
() => new Map(referenceData.statuses.map((item) => [item.id, item])),
[referenceData.statuses]
);
const typeMap = useMemo(
() => new Map(referenceData.quotationTypes.map((item) => [item.id, item])),
[referenceData.quotationTypes]
);
const currencyMap = useMemo(
() => new Map(referenceData.currencies.map((item) => [item.id, item])),
[referenceData.currencies]
);
const topicTypeMap = useMemo(
() => new Map(referenceData.topicTypes.map((item) => [item.id, item])),
[referenceData.topicTypes]
);
const followupTypeMap = useMemo(
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
[referenceData.followupTypes]
);
const customer = customerMap.get(quotation.customerId);
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
const status = statusMap.get(quotation.status);
const customerPackage = quotation.customerPackage;
const hasOfficialDocument = Boolean(quotation.approvedPdfUrl);
const officialDocumentPreviewHref = `/api/crm/quotations/${quotationId}/approved-pdf`;
const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Package';
const customerPackageStatusLabel =
customerPackageStatus === 'generated'
? 'Generated'
: customerPackageStatus === 'failed'
? 'Failed'
: 'Not Generated';
const customerPackageStatusDescription =
customerPackageStatus === 'generated'
? 'Customer-facing assembled PDF is ready.'
: customerPackageStatus === 'failed'
? 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. Generate the official PDF first.';
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
status?.code ?? ''
);
const canSubmitCurrentQuotation =
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
return (
setDeleteItemId(null)}
onConfirm={() => deleteItemId && deleteItem.mutate({ quotationId, itemId: deleteItemId })}
loading={deleteItem.isPending}
/>
setDeleteCustomerId(null)}
onConfirm={() =>
deleteCustomerId && deleteCustomer.mutate({ quotationId, relationId: deleteCustomerId })
}
loading={deleteCustomer.isPending}
/>
setDeleteTopicId(null)}
onConfirm={() =>
deleteTopicId && deleteTopic.mutate({ quotationId, topicId: deleteTopicId })
}
loading={deleteTopic.isPending}
/>
setDeleteFollowupId(null)}
onConfirm={() =>
deleteFollowupId && deleteFollowup.mutate({ quotationId, followupId: deleteFollowupId })
}
loading={deleteFollowup.isPending}
/>
setDeleteAttachmentId(null)}
onConfirm={() =>
deleteAttachmentId &&
deleteAttachment.mutate({
quotationId,
attachmentId: deleteAttachmentId
})
}
loading={deleteAttachment.isPending}
/>
{
if (editingItem) {
await updateItem.mutateAsync({
quotationId,
itemId: editingItem.id,
values
});
return;
}
await createItem.mutateAsync({ quotationId, values });
}}
/>
{
if (editingCustomer) {
await updateCustomer.mutateAsync({
quotationId,
relationId: editingCustomer.id,
values
});
return;
}
await createCustomer.mutateAsync({ quotationId, values });
}}
/>
{
if (editingTopic) {
await updateTopic.mutateAsync({
quotationId,
values: { ...values, id: editingTopic.id }
});
return;
}
await createTopic.mutateAsync({ quotationId, values });
}}
/>
{
if (editingFollowup) {
await updateFollowup.mutateAsync({
quotationId,
values: { ...values, id: editingFollowup.id }
});
return;
}
await createFollowup.mutateAsync({ quotationId, values });
}}
/>
{
if (editingAttachment) {
await updateAttachment.mutateAsync({
quotationId,
values: { ...values, id: editingAttachment.id }
});
return;
}
await createAttachment.mutateAsync({ quotationId, values });
}}
/>
Regenerate Customer Package?
A new assembled package artifact will replace the current active package for this
quotation.
setConfirmRegenerateOpen(false)}
disabled={generateCustomerPackage.isPending}
>
Cancel
generateCustomerPackage.mutate({ quotationId })}
>
Continue
{quotation.code}
{quotation.isHotProject ? Hot : null}
{quotation.projectName || 'Untitled quotation'}
{customer?.name ?? 'Unknown customer'}
{contact ? ` - ${contact.name}` : ''}
{canSubmitCurrentQuotation ? (
submitForApproval.mutate({ id: quotationId })}
>
Submit for approval
) : null}
{canCreateRevision && canReviseByStatus ? (
createRevision.mutate({ quotationId })}
>
Create Revision
) : null}
{canUpdate ? (
setEditOpen(true)}>
Edit Quotation
) : null}
{/* */}
Overview
Documents
Customer Package
Items
ผู้เกี่ยวข้องในโครงการ
Topics
Follow-ups
Attachments
Audit Log
Approval
Overview
Commercial header data and quotation summary.
ผู้เกี่ยวข้องในโครงการ
{!customersData.items.length ? (
No project parties have been added yet.
) : (
{customersData.items.map((item) => (
{item.customerName}
Role: {item.roleLabel ?? item.roleCode}
{item.remark ?
{item.remark}
: null}
))}
)}
Items
Server-calculated line items and pricing inputs.
{canManageItems ? (
{
setEditingItem(undefined);
setItemOpen(true);
}}
>
Add Item
) : null}
{!itemsData.items.length ? (
) : (
itemsData.items.map((item) => (
{item.description}
Qty {item.quantity} {item.unit || ''} x{' '}
{formatNumber(item.unitPrice)}
Total {formatNumber(item.totalPrice)}
{canManageItems ? (
{
setEditingItem(item);
setItemOpen(true);
}}
>
Edit
setDeleteItemId(item.id)}
>
Delete
) : null}
))
)}
ผู้เกี่ยวข้องในโครงการ
Related companies for this quotation, each with a visible role.
{canManageCustomers ? (
{
setEditingCustomer(undefined);
setCustomerOpen(true);
}}
>
Add Party
) : null}
{!customersData.items.length ? (
) : (
customersData.items.map((item) => (
{item.customerName}
Role: {item.roleLabel ?? item.roleCode}
{item.remark ? (
{item.remark}
) : null}
{canManageCustomers ? (
{
setEditingCustomer(item);
setCustomerOpen(true);
}}
>
Edit
setDeleteCustomerId(item.id)}
>
Delete
) : null}
))
)}
Topics
Scope, exclusions, payment terms, and other document sections.
{canManageTopics ? (
{
setEditingTopic(undefined);
setTopicOpen(true);
}}
>
Add Topic
) : null}
{!topicsData.items.length ? (
) : (
topicsData.items.map((topic) => (
{topic.title}
{topicTypeMap.get(topic.topicType)?.label ?? topic.topicType}
{canManageTopics ? (
{
setEditingTopic(topic);
setTopicOpen(true);
}}
>
Edit
setDeleteTopicId(topic.id)}
>
Delete
) : null}
{topic.items.map((item) => (
{item.content}
))}
))
)}
Follow-ups
Commercial chasing and response history.
{canManageFollowups ? (
{
setEditingFollowup(undefined);
setFollowupOpen(true);
}}
>
Add Follow-up
) : null}
{!followupsData.items.length ? (
) : (
followupsData.items.map((item) => (
{followupTypeMap.get(item.followupType)?.label ??
item.followupType}
{formatDate(item.followupDate)}
{item.outcome ? ` - ${item.outcome}` : ''}
{item.notes || '-'}
{canManageFollowups ? (
{
setEditingFollowup(item);
setFollowupOpen(true);
}}
>
Edit
setDeleteFollowupId(item.id)}
>
Delete
) : null}
))
)}
Attachments
Metadata only in Task E, ready for storage integration later.
{canManageAttachments ? (
{
setEditingAttachment(undefined);
setAttachmentOpen(true);
}}
>
Add Attachment
) : null}
{!attachmentsData.items.length ? (
) : (
attachmentsData.items.map((item) => (
{item.originalFileName}
{item.fileType || 'Unknown type'}
{item.fileSize ? ` - ${formatNumber(item.fileSize)} bytes` : ''}
{canManageAttachments ? (
{
setEditingAttachment(item);
setAttachmentOpen(true);
}}
>
Edit
setDeleteAttachmentId(item.id)}
>
Delete
) : null}
))
)}
Documents
Working quotation previews generated from live data.
Quotation PDF Preview
Working preview generated from current quotation data. Approval and
customer package generation are not required.
{canPreviewPdf ? (
Preview
) : (
Preview
)}
{canPreviewPdf ? (
Preview Item
Version
) : (
Preview Item Version
)}
{canPreviewDocument ? (
Preview Data
) : null}
{canPreviewDocument ? (
) : (
Working Document Preview
Preview permission is required to load working document data.
)}
Customer Package
Operational detail for the assembled customer-deliverable package.
{canGenerateApprovedPdf && !hasOfficialDocument ? (
createApprovedPdf.mutate()}
>
Generate Official PDF
) : null}
{canGenerateCustomerPackage ? (
customerPackage
? setConfirmRegenerateOpen(true)
: generateCustomerPackage.mutate({
quotationId
})
}
>
{customerPackageActionLabel}
) : null}
{canPreviewCustomerPackage && customerPackage ? (
Preview Package
) : (
Preview Package
)}
{canDownloadCustomerPackage ? (
downloadCustomerPackage.mutate()}
>
Download Package
) : null}
{!canGenerateCustomerPackage &&
!canDownloadCustomerPackage &&
!canPreviewCustomerPackage ? (
) : (
<>
{customerPackageStatusDescription}
{customerPackage?.warnings.length ? (
Warnings
{customerPackage.warnings.map((warning) => (
{warning.message}
))}
) : null}
Included Documents
{!customerPackage?.includedDocuments.length ? (
) : (
{customerPackage.includedDocuments.map((item) => (
))}
)}
>
)}
Revision Chain
Family of quotations copied from the same parent record.
{!revisionsData.items.length ? (
) : (
revisionsData.items.map((item) => (
{item.code}
{formatNumber(item.totalAmount)}
))
)}
Record Snapshot
Operational metadata for this quotation row.
Artifact Status
{quotation.approvedArtifact ? (
{quotation.approvedArtifact.status}
) : (
-
)}
{quotation.approvedArtifact?.lockedAt ? (
Locked {formatDateTime(quotation.approvedArtifact.lockedAt)}
) : null}
{quotation.hasLegacyApprovedPdf ? (
Legacy approved PDF detected
This quotation still points to the old `public/generated` storage path and has
not been migrated into the artifact storage model yet.
) : null}
);
}