Files
alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx
phaichayon df1821982d commit
2026-07-15 05:46:59 +07:00

2078 lines
83 KiB
TypeScript

'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 (
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='text-sm'>{value ?? '-'}</div>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
{message}
</div>
);
}
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 (
<div className='rounded-lg border p-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{getCustomerPackageRoleLabel(item.role)}</span>
<Badge variant='outline'>{item.sourceType === 'generated' ? 'Generated' : 'Library'}</Badge>
</div>
<div className='text-muted-foreground mt-2 text-sm'>{item.fileName}</div>
<div className='text-muted-foreground mt-1 text-xs'>
{item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'}
{item.fileSize ? `${formatNumber(item.fileSize)} bytes` : ''}
{item.checksum ? `${item.checksum.slice(0, 16)}` : ''}
</div>
</div>
);
}
function ItemDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
item
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationItemMutationPayload) => Promise<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{item ? 'Edit Item' : 'Add Item'}</DialogTitle>
<DialogDescription>Line items drive quotation totals from the server.</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<Select
value={values.productType}
onValueChange={(value) => setValues((s) => ({ ...s, productType: value }))}
>
<SelectTrigger>
<SelectValue placeholder='Product type' />
</SelectTrigger>
<SelectContent>
{referenceData.productTypes.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={values.description}
onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))}
placeholder='Description'
/>
<div className='grid gap-4 md:grid-cols-2'>
<CrmNumberInput
value={values.quantity}
onChange={(value) => setValues((s) => ({ ...s, quantity: value }))}
placeholder='Quantity'
/>
<Select
value={values.unit || '__none__'}
onValueChange={(value) =>
setValues((s) => ({
...s,
unit: value === '__none__' ? '' : value
}))
}
>
<SelectTrigger>
<SelectValue placeholder='Unit' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No unit</SelectItem>
{referenceData.units.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<CrmCurrencyInput
value={values.unitPrice}
onChange={(value) => setValues((s) => ({ ...s, unitPrice: value }))}
placeholder='Unit price'
currencyLabel='THB'
/>
<CrmCurrencyInput
value={values.discount}
onChange={(value) => setValues((s) => ({ ...s, discount: value }))}
placeholder='Discount'
currencyLabel='THB'
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<Select
value={values.discountType || '__none__'}
onValueChange={(value) =>
setValues((s) => ({
...s,
discountType: value === '__none__' ? '' : value
}))
}
>
<SelectTrigger>
<SelectValue placeholder='Discount type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No discount type</SelectItem>
{referenceData.discountTypes.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<CrmPercentageInput
value={values.taxRate}
onChange={(value) => setValues((s) => ({ ...s, taxRate: value }))}
placeholder='Tax rate %'
/>
</div>
<CrmTextarea
value={values.notes}
onChange={(e) => setValues((s) => ({ ...s, notes: e.target.value }))}
placeholder='Notes'
size='md'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
isLoading={pending}
onClick={async () => {
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
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CustomerDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
relation
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationCustomerMutationPayload) => Promise<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{relation ? 'Edit Project Party' : 'Add Project Party'}</DialogTitle>
<DialogDescription>
Keep related companies visible with their role in this quotation.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger>
<SelectValue placeholder='Customer' />
</SelectTrigger>
<SelectContent>
{referenceData.customers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder='Role' />
</SelectTrigger>
<SelectContent>
{referenceData.projectPartyRoles.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder='Remark'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
isLoading={pending}
onClick={async () => {
await onSubmit({ customerId, role, remark: remark || null });
onOpenChange(false);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TopicDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
topic
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationTopicMutationPayload) => Promise<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{topic ? 'Edit Topic' : 'Add Topic'}</DialogTitle>
<DialogDescription>
Each line becomes a topic item in the generated quotation document.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<Select value={topicType} onValueChange={setTopicType}>
<SelectTrigger>
<SelectValue placeholder='Topic type' />
</SelectTrigger>
<SelectContent>
{referenceData.topicTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder='Topic title'
/>
<CrmTextarea
value={itemsText}
onChange={(e) => setItemsText(e.target.value)}
placeholder='One line per topic item'
size='lg'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
isLoading={pending}
onClick={async () => {
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
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FollowupDialog({
open,
onOpenChange,
onSubmit,
pending,
referenceData,
customerId,
followup
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationFollowupMutationPayload) => Promise<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{followup ? 'Edit Follow-up' : 'Add Follow-up'}</DialogTitle>
<DialogDescription>
Keep quotation chasing and commercial communication visible.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<CrmDateInput
value={values.followupDate}
onChange={(e) => setValues((s) => ({ ...s, followupDate: e }))}
className='w-full'
/>
<Select
value={values.followupType}
onValueChange={(value) => setValues((s) => ({ ...s, followupType: value }))}
>
<SelectTrigger>
<SelectValue placeholder='Follow-up type' />
</SelectTrigger>
<SelectContent>
{referenceData.followupTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.contactId || '__none__'}
onValueChange={(value) =>
setValues((s) => ({
...s,
contactId: value === '__none__' ? '' : value
}))
}
>
<SelectTrigger>
<SelectValue placeholder='Contact' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No contact</SelectItem>
{contacts.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={values.outcome}
onChange={(e) => setValues((s) => ({ ...s, outcome: e.target.value }))}
placeholder='Outcome'
/>
<CrmDateInput
value={values.nextFollowupDate}
onChange={(e) => setValues((s) => ({ ...s, nextFollowupDate: e }))}
className='w-full'
/>
<Input
value={values.nextAction}
onChange={(e) => setValues((s) => ({ ...s, nextAction: e.target.value }))}
placeholder='Next action'
/>
<CrmTextarea
value={values.notes}
onChange={(e) => setValues((s) => ({ ...s, notes: e.target.value }))}
placeholder='Notes'
size='md'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
isLoading={pending}
onClick={async () => {
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
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AttachmentDialog({
open,
onOpenChange,
onSubmit,
pending,
attachment
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: QuotationAttachmentMutationPayload) => Promise<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{attachment ? 'Edit Attachment Metadata' : 'Add Attachment Metadata'}
</DialogTitle>
<DialogDescription>
Task E stores attachment metadata only, without file upload plumbing.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<Input
value={values.fileName}
onChange={(e) => setValues((s) => ({ ...s, fileName: e.target.value }))}
placeholder='Stored file name'
/>
<Input
value={values.originalFileName}
onChange={(e) => setValues((s) => ({ ...s, originalFileName: e.target.value }))}
placeholder='Original file name'
/>
<Input
value={values.filePath}
onChange={(e) => setValues((s) => ({ ...s, filePath: e.target.value }))}
placeholder='/crm/quotations/...'
/>
<div className='grid gap-4 md:grid-cols-2'>
<Input
value={values.fileType}
onChange={(e) => setValues((s) => ({ ...s, fileType: e.target.value }))}
placeholder='application/pdf'
/>
<CrmNumberInput
value={values.fileSize}
onChange={(value) => setValues((s) => ({ ...s, fileSize: value }))}
placeholder='File size bytes'
/>
</div>
<CrmTextarea
value={values.description}
onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))}
placeholder='Description'
size='md'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
isLoading={pending}
onClick={async () => {
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
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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<string | null>(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<QuotationItemRecord | undefined>();
const [deleteItemId, setDeleteItemId] = useState<string | null>(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<QuotationCustomerListItem | undefined>();
const [deleteCustomerId, setDeleteCustomerId] = useState<string | null>(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<QuotationTopicRecord | undefined>();
const [deleteTopicId, setDeleteTopicId] = useState<string | null>(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<QuotationFollowupRecord | undefined>();
const [deleteFollowupId, setDeleteFollowupId] = useState<string | null>(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<string | null>(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 (
<div className='space-y-6'>
<AlertModal
isOpen={!!deleteItemId}
onClose={() => setDeleteItemId(null)}
onConfirm={() => deleteItemId && deleteItem.mutate({ quotationId, itemId: deleteItemId })}
loading={deleteItem.isPending}
/>
<AlertModal
isOpen={!!deleteCustomerId}
onClose={() => setDeleteCustomerId(null)}
onConfirm={() =>
deleteCustomerId && deleteCustomer.mutate({ quotationId, relationId: deleteCustomerId })
}
loading={deleteCustomer.isPending}
/>
<AlertModal
isOpen={!!deleteTopicId}
onClose={() => setDeleteTopicId(null)}
onConfirm={() =>
deleteTopicId && deleteTopic.mutate({ quotationId, topicId: deleteTopicId })
}
loading={deleteTopic.isPending}
/>
<AlertModal
isOpen={!!deleteFollowupId}
onClose={() => setDeleteFollowupId(null)}
onConfirm={() =>
deleteFollowupId && deleteFollowup.mutate({ quotationId, followupId: deleteFollowupId })
}
loading={deleteFollowup.isPending}
/>
<AlertModal
isOpen={!!deleteAttachmentId}
onClose={() => setDeleteAttachmentId(null)}
onConfirm={() =>
deleteAttachmentId &&
deleteAttachment.mutate({
quotationId,
attachmentId: deleteAttachmentId
})
}
loading={deleteAttachment.isPending}
/>
<ItemDialog
open={itemOpen}
onOpenChange={setItemOpen}
item={editingItem}
referenceData={referenceData}
pending={createItem.isPending || updateItem.isPending}
onSubmit={async (values) => {
if (editingItem) {
await updateItem.mutateAsync({
quotationId,
itemId: editingItem.id,
values
});
return;
}
await createItem.mutateAsync({ quotationId, values });
}}
/>
<CustomerDialog
open={customerOpen}
onOpenChange={setCustomerOpen}
relation={editingCustomer}
referenceData={referenceData}
pending={createCustomer.isPending || updateCustomer.isPending}
onSubmit={async (values) => {
if (editingCustomer) {
await updateCustomer.mutateAsync({
quotationId,
relationId: editingCustomer.id,
values
});
return;
}
await createCustomer.mutateAsync({ quotationId, values });
}}
/>
<TopicDialog
open={topicOpen}
onOpenChange={setTopicOpen}
topic={editingTopic}
referenceData={referenceData}
pending={createTopic.isPending || updateTopic.isPending}
onSubmit={async (values) => {
if (editingTopic) {
await updateTopic.mutateAsync({
quotationId,
values: { ...values, id: editingTopic.id }
});
return;
}
await createTopic.mutateAsync({ quotationId, values });
}}
/>
<FollowupDialog
open={followupOpen}
onOpenChange={setFollowupOpen}
followup={editingFollowup}
customerId={quotation.customerId}
referenceData={referenceData}
pending={createFollowup.isPending || updateFollowup.isPending}
onSubmit={async (values) => {
if (editingFollowup) {
await updateFollowup.mutateAsync({
quotationId,
values: { ...values, id: editingFollowup.id }
});
return;
}
await createFollowup.mutateAsync({ quotationId, values });
}}
/>
<AttachmentDialog
open={attachmentOpen}
onOpenChange={setAttachmentOpen}
attachment={editingAttachment}
pending={createAttachment.isPending || updateAttachment.isPending}
onSubmit={async (values) => {
if (editingAttachment) {
await updateAttachment.mutateAsync({
quotationId,
values: { ...values, id: editingAttachment.id }
});
return;
}
await createAttachment.mutateAsync({ quotationId, values });
}}
/>
<QuotationFormSheet
quotation={quotation}
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
/>
<Dialog open={confirmRegenerateOpen} onOpenChange={setConfirmRegenerateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Customer Package?</DialogTitle>
<DialogDescription>
A new assembled package artifact will replace the current active package for this
quotation.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant='outline'
onClick={() => setConfirmRegenerateOpen(false)}
disabled={generateCustomerPackage.isPending}
>
Cancel
</Button>
<Button
isLoading={generateCustomerPackage.isPending}
onClick={() => generateCustomerPackage.mutate({ quotationId })}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-3'>
<div className='flex items-center gap-2'>
<Button asChild variant='ghost' size='icon'>
<Link href='/dashboard/crm/quotations'>
<Icons.chevronLeft className='h-4 w-4' />
</Link>
</Button>
<span className='text-muted-foreground font-mono text-sm'>{quotation.code}</span>
<QuotationStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
{quotation.isHotProject ? <Badge>Hot</Badge> : null}
</div>
<div>
<h2 className='text-2xl font-semibold'>
{quotation.projectName || 'Untitled quotation'}
</h2>
<p className='text-muted-foreground text-sm'>
{customer?.name ?? 'Unknown customer'}
{contact ? ` - ${contact.name}` : ''}
</p>
</div>
</div>
<div className='flex flex-wrap gap-2'>
{canSubmitCurrentQuotation ? (
<Button
variant='outline'
isLoading={submitForApproval.isPending}
onClick={() => submitForApproval.mutate({ id: quotationId })}
>
<Icons.send className='mr-2 h-4 w-4' /> Submit for approval
</Button>
) : null}
{canCreateRevision && canReviseByStatus ? (
<Button
variant='outline'
isLoading={createRevision.isPending}
onClick={() => createRevision.mutate({ quotationId })}
>
<Icons.add className='mr-2 h-4 w-4' /> Create Revision
</Button>
) : null}
{canUpdate ? (
<Button variant='outline' onClick={() => setEditOpen(true)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit Quotation
</Button>
) : null}
</div>
</div>
<div className='grid gap-6 lg:grid-cols-3'>
<div className='space-y-6 lg:col-span-2'>
<Card>
<CardContent className='pt-6'>
{/* <RecentActivitiesPanel
items={data.recentActivities}
description="New activity records and legacy quotation follow-ups shown together."
emptyMessage="No recent activity or quotation follow-up yet."
/> */}
<Tabs defaultValue='overview' className='gap-4'>
<TabsList className='flex flex-wrap'>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='documents'>Documents</TabsTrigger>
<TabsTrigger value='customer-package'>Customer Package</TabsTrigger>
<TabsTrigger value='items'>Items</TabsTrigger>
<TabsTrigger value='customers'></TabsTrigger>
<TabsTrigger value='topics'>Topics</TabsTrigger>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
<TabsTrigger value='approval'>Approval</TabsTrigger>
</TabsList>
<TabsContent value='overview'>
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>
Commercial header data and quotation summary.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='ลูกค้าผู้รับใบเสนอราคา' value={customer?.name} />
<FieldItem label='Contact' value={contact?.name} />
<FieldItem
label='Branch'
value={quotation.branchId ? branchMap.get(quotation.branchId) : null}
/>
<FieldItem label='Type' value={typeMap.get(quotation.quotationType)?.label} />
<FieldItem
label='Currency'
value={currencyMap.get(quotation.currency)?.label}
/>
<FieldItem label='Exchange Rate' value={quotation.exchangeRate.toString()} />
<FieldItem
label='Quotation Date'
value={formatDate(quotation.quotationDate)}
/>
<FieldItem
label='Valid Until'
value={quotation.validUntil ? formatDate(quotation.validUntil) : null}
/>
<FieldItem label='Subtotal' value={formatNumber(quotation.subtotal)} />
<FieldItem label='Tax' value={formatNumber(quotation.taxAmount)} />
<FieldItem label='Total' value={formatNumber(quotation.totalAmount)} />
<FieldItem
label='Chance %'
value={
quotation.chancePercent !== null ? `${quotation.chancePercent}%` : null
}
/>
<div className='md:col-span-2 xl:col-span-4'>
<div className='space-y-3'>
<div className='text-muted-foreground text-xs'></div>
{!customersData.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No project parties have been added yet.
</div>
) : (
<div className='space-y-3'>
{customersData.items.map((item) => (
<div key={item.id} className='rounded-lg border p-4 text-sm'>
<div className='font-medium'>{item.customerName}</div>
<div className='text-muted-foreground'>
Role: {item.roleLabel ?? item.roleCode}
</div>
{item.remark ? <div className='mt-1'>{item.remark}</div> : null}
</div>
))}
</div>
)}
</div>
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem
label='Project'
value={
[quotation.projectName, quotation.projectLocation]
.filter(Boolean)
.join(' - ') || null
}
/>
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem
label='Attention / Reference'
value={
[quotation.attention, quotation.reference]
.filter(Boolean)
.join(' - ') || null
}
/>
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Notes' value={quotation.notes} />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='items'>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle>Items</CardTitle>
<CardDescription>
Server-calculated line items and pricing inputs.
</CardDescription>
</div>
{canManageItems ? (
<Button
onClick={() => {
setEditingItem(undefined);
setItemOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Item
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-3'>
{!itemsData.items.length ? (
<EmptyState message='No quotation items yet.' />
) : (
itemsData.items.map((item) => (
<div key={item.id} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-4'>
<div className='space-y-1'>
<div className='font-medium'>{item.description}</div>
<div className='text-muted-foreground text-sm'>
Qty {item.quantity} {item.unit || ''} x{' '}
{formatNumber(item.unitPrice)}
</div>
<div className='text-muted-foreground text-xs'>
Total {formatNumber(item.totalPrice)}
</div>
</div>
{canManageItems ? (
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setEditingItem(item);
setItemOpen(true);
}}
>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setDeleteItemId(item.id)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</Button>
</div>
) : null}
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='customers'>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle></CardTitle>
<CardDescription>
Related companies for this quotation, each with a visible role.
</CardDescription>
</div>
{canManageCustomers ? (
<Button
onClick={() => {
setEditingCustomer(undefined);
setCustomerOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Party
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-3'>
{!customersData.items.length ? (
<EmptyState message='No project parties have been added yet.' />
) : (
customersData.items.map((item) => (
<div
key={item.id}
className='flex items-center justify-between rounded-lg border p-4'
>
<div>
<div className='font-medium'>{item.customerName}</div>
<div className='text-muted-foreground text-sm'>
Role: {item.roleLabel ?? item.roleCode}
</div>
{item.remark ? (
<div className='text-muted-foreground mt-1 text-sm'>
{item.remark}
</div>
) : null}
</div>
{canManageCustomers ? (
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setEditingCustomer(item);
setCustomerOpen(true);
}}
>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setDeleteCustomerId(item.id)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</Button>
</div>
) : null}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='topics'>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle>Topics</CardTitle>
<CardDescription>
Scope, exclusions, payment terms, and other document sections.
</CardDescription>
</div>
{canManageTopics ? (
<Button
onClick={() => {
setEditingTopic(undefined);
setTopicOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Topic
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-4'>
{!topicsData.items.length ? (
<EmptyState message='No quotation topics yet.' />
) : (
topicsData.items.map((topic) => (
<div key={topic.id} className='rounded-lg border p-4'>
<div className='mb-3 flex items-center justify-between gap-4'>
<div>
<div className='font-medium'>{topic.title}</div>
<div className='text-muted-foreground text-sm'>
{topicTypeMap.get(topic.topicType)?.label ?? topic.topicType}
</div>
</div>
{canManageTopics ? (
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setEditingTopic(topic);
setTopicOpen(true);
}}
>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setDeleteTopicId(topic.id)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</Button>
</div>
) : null}
</div>
<ul className='list-disc space-y-2 pl-5 text-sm'>
{topic.items.map((item) => (
<li key={item.id}>{item.content}</li>
))}
</ul>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='followups'>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle>Follow-ups</CardTitle>
<CardDescription>Commercial chasing and response history.</CardDescription>
</div>
{canManageFollowups ? (
<Button
onClick={() => {
setEditingFollowup(undefined);
setFollowupOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Follow-up
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-3'>
{!followupsData.items.length ? (
<EmptyState message='No quotation follow-ups yet.' />
) : (
followupsData.items.map((item) => (
<div key={item.id} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-4'>
<div className='space-y-1'>
<div className='font-medium'>
{followupTypeMap.get(item.followupType)?.label ??
item.followupType}
</div>
<div className='text-muted-foreground text-sm'>
{formatDate(item.followupDate)}
{item.outcome ? ` - ${item.outcome}` : ''}
</div>
<div className='text-sm'>{item.notes || '-'}</div>
</div>
{canManageFollowups ? (
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setEditingFollowup(item);
setFollowupOpen(true);
}}
>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setDeleteFollowupId(item.id)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</Button>
</div>
) : null}
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='attachments'>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle>Attachments</CardTitle>
<CardDescription>
Metadata only in Task E, ready for storage integration later.
</CardDescription>
</div>
{canManageAttachments ? (
<Button
onClick={() => {
setEditingAttachment(undefined);
setAttachmentOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Attachment
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-3'>
{!attachmentsData.items.length ? (
<EmptyState message='No attachment metadata yet.' />
) : (
attachmentsData.items.map((item) => (
<div
key={item.id}
className='flex items-center justify-between rounded-lg border p-4'
>
<div>
<div className='font-medium'>{item.originalFileName}</div>
<div className='text-muted-foreground text-sm'>
{item.fileType || 'Unknown type'}
{item.fileSize ? ` - ${formatNumber(item.fileSize)} bytes` : ''}
</div>
</div>
{canManageAttachments ? (
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setEditingAttachment(item);
setAttachmentOpen(true);
}}
>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setDeleteAttachmentId(item.id)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</Button>
</div>
) : null}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='activity'>
<AuditLogTab
items={data.activity}
emptyMessage='No audit log recorded yet.'
description='System-generated immutable history for this quotation and its child records.'
/>
</TabsContent>
<TabsContent value='approval'>
<QuotationApprovalTab
quotationId={quotationId}
quotationStatusCode={status?.code ?? null}
canSubmitApproval={canSubmitApproval}
canApproveApproval={canApproveApproval}
canRejectApproval={canRejectApproval}
canReturnApproval={canReturnApproval}
activeBusinessRole={activeBusinessRole}
isOrgAdmin={isOrgAdmin}
currentUserId={currentUserId}
/>
</TabsContent>
<TabsContent value='documents'>
<div className='flex flex-col gap-6'>
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>
Working quotation previews generated from live data.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 xl:grid-cols-2'>
<Card>
<CardHeader>
<CardTitle>Quotation PDF Preview</CardTitle>
<CardDescription>
Working preview generated from current quotation data. Approval and
customer package generation are not required.
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<div className='grid gap-4 md:grid-cols-3'>
<FieldItem label='Template' value='Active PDFMe template' />
<FieldItem label='Item Version' value='product-v1' />
<FieldItem label='Approval Required' value='No' />
</div>
<div className='flex flex-wrap gap-2'>
{canPreviewPdf ? (
<Button asChild variant='outline'>
<Link
href={`/api/crm/quotations/${quotationId}/pdf-preview`}
target='_blank'
>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview
</Button>
)}
{canPreviewPdf ? (
<Button asChild variant='outline'>
<Link
href={`/api/crm/quotations/${quotationId}/pdf-preview?templateVariant=product-v1`}
target='_blank'
>
<Icons.badgeCheck className='mr-2 h-4 w-4' /> Preview Item
Version
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.badgeCheck className='mr-2 h-4 w-4' /> Preview Item Version
</Button>
)}
{canPreviewDocument ? (
<Button asChild variant='outline'>
<Link href='#working-document-preview'>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Data
</Link>
</Button>
) : null}
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{canPreviewDocument ? (
<div id='working-document-preview' className='scroll-mt-24'>
<QuotationDocumentPreview quotationId={quotationId} />
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Working Document Preview</CardTitle>
<CardDescription>
Preview permission is required to load working document data.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState message='You do not have access to quotation document preview.' />
</CardContent>
</Card>
)}
</div>
</TabsContent>
<TabsContent value='customer-package'>
<Card>
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<CardTitle>Customer Package</CardTitle>
<CardDescription>
Operational detail for the assembled customer-deliverable package.
</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
? setConfirmRegenerateOpen(true)
: generateCustomerPackage.mutate({
quotationId
})
}
>
<Icons.badgeCheck className='mr-2 h-4 w-4' />
{customerPackageActionLabel}
</Button>
) : null}
{canPreviewCustomerPackage && customerPackage ? (
<Button asChild variant='outline'>
<Link
href={`/api/crm/quotations/${quotationId}/assembled-pdf`}
target='_blank'
>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Package
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Package
</Button>
)}
{canDownloadCustomerPackage ? (
<Button
variant='outline'
disabled={!customerPackage}
isLoading={downloadCustomerPackage.isPending}
onClick={() => downloadCustomerPackage.mutate()}
>
<Icons.download className='mr-2 h-4 w-4' /> Download Package
</Button>
) : null}
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
{!canGenerateCustomerPackage &&
!canDownloadCustomerPackage &&
!canPreviewCustomerPackage ? (
<EmptyState message='You do not have permission to generate or download customer package.' />
) : (
<>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Status' value={customerPackageStatusLabel} />
<FieldItem
label='Generated At'
value={
customerPackage?.generatedAt
? formatDateTime(customerPackage.generatedAt)
: null
}
/>
<FieldItem
label='Generated By'
value={
customerPackage?.generatedByName ??
customerPackage?.generatedBy ??
null
}
/>
<FieldItem
label='Page Count'
value={
customerPackage?.pageCount !== null &&
customerPackage?.pageCount !== undefined
? String(customerPackage.pageCount)
: null
}
/>
<FieldItem
label='File Size'
value={
customerPackage?.fileSize
? `${formatNumber(customerPackage.fileSize)} bytes`
: null
}
/>
<FieldItem
label='Checksum'
value={
customerPackage?.checksum
? customerPackage.checksum.slice(0, 16)
: null
}
/>
<FieldItem
label='File Name'
value={customerPackage?.fileName ?? null}
/>
<FieldItem
label='Included Documents'
value={
customerPackage?.includedDocuments.length
? String(customerPackage.includedDocuments.length)
: null
}
/>
</div>
<div className='rounded-lg border border-dashed p-4 text-sm'>
{customerPackageStatusDescription}
</div>
{customerPackage?.warnings.length ? (
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium'>Warnings</div>
{customerPackage.warnings.map((warning) => (
<div
key={`${warning.code}-${warning.role}`}
className='rounded-lg border p-3 text-sm'
>
{warning.message}
</div>
))}
</div>
) : null}
<div className='flex flex-col gap-3'>
<div className='text-sm font-medium'>Included Documents</div>
{!customerPackage?.includedDocuments.length ? (
<EmptyState message='No assembled package has been generated yet.' />
) : (
<div className='grid gap-3 md:grid-cols-2'>
{customerPackage.includedDocuments.map((item) => (
<CustomerPackageSourceItem
key={`${item.role}-${item.fileName}-${item.checksum ?? 'no-checksum'}`}
item={item}
/>
))}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Revision Chain</CardTitle>
<CardDescription>
Family of quotations copied from the same parent record.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{!revisionsData.items.length ? (
<EmptyState message='No revisions yet.' />
) : (
revisionsData.items.map((item) => (
<Link
key={item.id}
href={`/dashboard/crm/quotations/${item.id}`}
className='block rounded-lg border p-3 transition-colors hover:bg-muted/40'
>
<div className='flex items-center justify-between gap-3'>
<div>
<div className='font-medium'>{item.code}</div>
<div className='text-muted-foreground text-xs'>
{formatNumber(item.totalAmount)}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
</div>
</Link>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Record Snapshot</CardTitle>
<CardDescription>Operational metadata for this quotation row.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem
label='Created By'
value={quotation.createdByName ?? quotation.createdBy}
/>
<FieldItem
label='Updated By'
value={quotation.updatedByName ?? quotation.updatedBy}
/>
<FieldItem label='Created At' value={formatDateTime(quotation.createdAt)} />
<FieldItem label='Updated At' value={formatDateTime(quotation.updatedAt)} />
<FieldItem
label='Revision'
value={`R${String(quotation.revision).padStart(2, '0')}`}
/>
<FieldItem
label='Approved At'
value={quotation.approvedAt ? formatDateTime(quotation.approvedAt) : null}
/>
<FieldItem
label='Approved Template Version'
value={quotation.approvedTemplateVersionId}
/>
<FieldItem
label='Approved Artifact'
value={quotation.approvedArtifact?.fileName ?? null}
/>
<FieldItem
label='Artifact Generated By'
value={
quotation.approvedArtifact?.generatedByName ??
quotation.approvedArtifact?.generatedBy ??
null
}
/>
<FieldItem
label='Artifact Generated At'
value={
quotation.approvedArtifact?.generatedAt
? formatDateTime(quotation.approvedArtifact.generatedAt)
: null
}
/>
<FieldItem
label='Artifact Checksum'
value={quotation.approvedArtifact?.checksum?.slice(0, 16) ?? null}
/>
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>Artifact Status</div>
<div className='flex flex-wrap items-center gap-2'>
{quotation.approvedArtifact ? (
<Badge
variant={
quotation.approvedArtifact.status === 'locked' ? 'default' : 'secondary'
}
>
{quotation.approvedArtifact.status}
</Badge>
) : (
<span className='text-sm'>-</span>
)}
{quotation.approvedArtifact?.lockedAt ? (
<span className='text-muted-foreground text-xs'>
Locked {formatDateTime(quotation.approvedArtifact.lockedAt)}
</span>
) : null}
</div>
</div>
{quotation.hasLegacyApprovedPdf ? (
<div className='rounded-lg border border-dashed p-3 text-sm'>
<div className='font-medium'>Legacy approved PDF detected</div>
<div className='text-muted-foreground mt-1'>
This quotation still points to the old `public/generated` storage path and has
not been migrated into the artifact storage model yet.
</div>
</div>
) : null}
</CardContent>
</Card>
</div>
</div>
</div>
);
}