'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 (
{label}
{value ?? '-'}
); } 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, description: e.target.value }))} placeholder='Description' />
setValues((s) => ({ ...s, quantity: value }))} placeholder='Quantity' />
setValues((s) => ({ ...s, unitPrice: value }))} placeholder='Unit price' currencyLabel='THB' /> setValues((s) => ({ ...s, discount: value }))} placeholder='Discount' currencyLabel='THB' />
setValues((s) => ({ ...s, taxRate: value }))} placeholder='Tax rate %' />
setValues((s) => ({ ...s, notes: e.target.value }))} placeholder='Notes' size='md' />
); } 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.
setRemark(event.target.value)} placeholder='Remark' />
); } 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.
setTitle(e.target.value)} placeholder='Topic title' /> setItemsText(e.target.value)} placeholder='One line per topic item' size='lg' />
); } 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, 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' />
); } 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.
setValues((s) => ({ ...s, fileName: e.target.value }))} placeholder='Stored file name' /> setValues((s) => ({ ...s, originalFileName: e.target.value }))} placeholder='Original file name' /> setValues((s) => ({ ...s, filePath: e.target.value }))} placeholder='/crm/quotations/...' />
setValues((s) => ({ ...s, fileType: e.target.value }))} placeholder='application/pdf' /> setValues((s) => ({ ...s, fileSize: value }))} placeholder='File size bytes' />
setValues((s) => ({ ...s, description: e.target.value }))} placeholder='Description' size='md' />
); } 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.
{quotation.code} {quotation.isHotProject ? Hot : null}

{quotation.projectName || 'Untitled quotation'}

{customer?.name ?? 'Unknown customer'} {contact ? ` - ${contact.name}` : ''}

{canSubmitCurrentQuotation ? ( ) : null} {canCreateRevision && canReviseByStatus ? ( ) : null} {canUpdate ? ( ) : 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 ? ( ) : null}
{!itemsData.items.length ? ( ) : ( itemsData.items.map((item) => (
{item.description}
Qty {item.quantity} {item.unit || ''} x{' '} {formatNumber(item.unitPrice)}
Total {formatNumber(item.totalPrice)}
{canManageItems ? (
) : null}
)) )}
ผู้เกี่ยวข้องในโครงการ Related companies for this quotation, each with a visible role.
{canManageCustomers ? ( ) : null}
{!customersData.items.length ? ( ) : ( customersData.items.map((item) => (
{item.customerName}
Role: {item.roleLabel ?? item.roleCode}
{item.remark ? (
{item.remark}
) : null}
{canManageCustomers ? (
) : null}
)) )}
Topics Scope, exclusions, payment terms, and other document sections.
{canManageTopics ? ( ) : null}
{!topicsData.items.length ? ( ) : ( topicsData.items.map((topic) => (
{topic.title}
{topicTypeMap.get(topic.topicType)?.label ?? topic.topicType}
{canManageTopics ? (
) : null}
    {topic.items.map((item) => (
  • {item.content}
  • ))}
)) )}
Follow-ups Commercial chasing and response history.
{canManageFollowups ? ( ) : 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 ? (
) : null}
)) )}
Attachments Metadata only in Task E, ready for storage integration later.
{canManageAttachments ? ( ) : null}
{!attachmentsData.items.length ? ( ) : ( attachmentsData.items.map((item) => (
{item.originalFileName}
{item.fileType || 'Unknown type'} {item.fileSize ? ` - ${formatNumber(item.fileSize)} bytes` : ''}
{canManageAttachments ? (
) : 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 ? ( ) : ( )} {canPreviewPdf ? ( ) : ( )} {canPreviewDocument ? ( ) : 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 ? ( ) : null} {canGenerateCustomerPackage ? ( ) : null} {canPreviewCustomerPackage && customerPackage ? ( ) : ( )} {canDownloadCustomerPackage ? ( ) : 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}
); }