2078 lines
83 KiB
TypeScript
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>
|
|
);
|
|
}
|