task-p.7.1.1
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
createQuotation,
|
||||
createQuotationAttachment,
|
||||
createQuotationCustomer,
|
||||
generateQuotationCustomerPackage,
|
||||
createQuotationFollowup,
|
||||
createQuotationItem,
|
||||
createQuotationRevision,
|
||||
@@ -374,3 +375,13 @@ export const createQuotationRevisionMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const generateQuotationCustomerPackageMutation = mutationOptions({
|
||||
mutationFn: ({ quotationId }: { quotationId: string }) =>
|
||||
generateQuotationCustomerPackage(quotationId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateQuotationHeaderQueries(variables.quotationId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MutationSuccessResponse,
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentsResponse,
|
||||
QuotationCustomerPackageGenerateResponse,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationCustomersResponse,
|
||||
QuotationDetailResponse,
|
||||
@@ -41,6 +42,37 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
||||
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
|
||||
}
|
||||
|
||||
export async function generateQuotationCustomerPackage(
|
||||
quotationId: string
|
||||
): Promise<QuotationCustomerPackageGenerateResponse> {
|
||||
return apiClient<QuotationCustomerPackageGenerateResponse>(
|
||||
`/crm/quotations/${quotationId}/assembled-pdf`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadQuotationCustomerPackage(quotationId: string): Promise<{
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
}> {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/assembled-pdf/download`);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? 'Failed to download customer package');
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('content-disposition') ?? '';
|
||||
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: fileNameMatch?.[1] ?? `${quotationId}-customer-package.pdf`
|
||||
};
|
||||
}
|
||||
|
||||
export async function createQuotation(data: QuotationMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
||||
method: 'POST',
|
||||
@@ -212,4 +244,3 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
|
||||
body: JSON.stringify({ revisionRemark })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,37 @@ export interface QuotationApprovedArtifactSummary {
|
||||
lockedByName: string | null;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageSource {
|
||||
role: string;
|
||||
sourceType: 'generated' | 'document_library';
|
||||
fileName: string;
|
||||
checksum: string | null;
|
||||
fileSize: number | null;
|
||||
pageCount: number | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageWarning {
|
||||
code: string;
|
||||
role: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageSummary {
|
||||
artifactId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
fileSize: number | null;
|
||||
checksum: string | null;
|
||||
status: string;
|
||||
pageCount: number | null;
|
||||
generatedAt: string;
|
||||
generatedBy: string;
|
||||
generatedByName: string | null;
|
||||
includedDocuments: QuotationCustomerPackageSource[];
|
||||
warnings: QuotationCustomerPackageWarning[];
|
||||
}
|
||||
|
||||
export interface QuotationRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -103,6 +134,7 @@ export interface QuotationRecord {
|
||||
approvedSnapshot: unknown | null;
|
||||
approvedTemplateVersionId: string | null;
|
||||
approvedArtifact: QuotationApprovedArtifactSummary | null;
|
||||
customerPackage: QuotationCustomerPackageSummary | null;
|
||||
hasLegacyApprovedPdf: boolean;
|
||||
acceptedAt: string | null;
|
||||
rejectedAt: string | null;
|
||||
@@ -432,3 +464,20 @@ export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageGenerateResponse {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
warnings: QuotationCustomerPackageWarning[];
|
||||
artifact: {
|
||||
artifactId: string;
|
||||
storageKey: string;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
createQuotationAttachmentMutation,
|
||||
createQuotationCustomerMutation,
|
||||
createQuotationFollowupMutation,
|
||||
generateQuotationCustomerPackageMutation,
|
||||
createQuotationItemMutation,
|
||||
createQuotationRevisionMutation,
|
||||
createQuotationTopicMutation,
|
||||
@@ -64,10 +65,12 @@ import {
|
||||
quotationRevisionsOptions,
|
||||
quotationTopicsOptions
|
||||
} from '../api/queries';
|
||||
import { downloadQuotationCustomerPackage } from '../api/service';
|
||||
import type {
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentRecord,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationFollowupMutationPayload,
|
||||
QuotationFollowupRecord,
|
||||
@@ -79,6 +82,11 @@ import type {
|
||||
} 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';
|
||||
@@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -673,7 +702,10 @@ export function QuotationDetail({
|
||||
currentUserId,
|
||||
canPreviewDocument,
|
||||
canPreviewPdf,
|
||||
canPreviewCustomerPackage,
|
||||
canDownloadPdf,
|
||||
canGenerateCustomerPackage,
|
||||
canDownloadCustomerPackage,
|
||||
canGenerateApprovedPdf
|
||||
}: {
|
||||
quotationId: string;
|
||||
@@ -694,7 +726,10 @@ export function QuotationDetail({
|
||||
currentUserId: string;
|
||||
canPreviewDocument: boolean;
|
||||
canPreviewPdf: boolean;
|
||||
canPreviewCustomerPackage: boolean;
|
||||
canDownloadPdf: boolean;
|
||||
canGenerateCustomerPackage: boolean;
|
||||
canDownloadCustomerPackage: boolean;
|
||||
canGenerateApprovedPdf: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||
@@ -705,6 +740,12 @@ export function QuotationDetail({
|
||||
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);
|
||||
@@ -844,6 +885,44 @@ export function QuotationDetail({
|
||||
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);
|
||||
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);
|
||||
toast.success('Customer package downloaded successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const generateApprovedPdf = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||
@@ -918,16 +997,30 @@ export function QuotationDetail({
|
||||
() => 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 canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
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 customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Customer Package';
|
||||
const customerPackageStatusLabel =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Generated'
|
||||
: customerPackageStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Not Generated';
|
||||
const customerPackageStatusDescription =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Customer-facing assembled PDF is ready.'
|
||||
: customerPackageStatus === 'failed'
|
||||
? customerPackageError
|
||||
: 'Generate the package before downloading or previewing it.';
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
const canSubmitCurrentQuotation =
|
||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
||||
@@ -1075,6 +1168,31 @@ export function QuotationDetail({
|
||||
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'>
|
||||
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<CardTitle>Customer Package</CardTitle>
|
||||
<CardDescription>
|
||||
Generate and download the assembled customer-facing quotation package.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canGenerateCustomerPackage ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() =>
|
||||
customerPackage
|
||||
? setConfirmRegenerateOpen(true)
|
||||
: generateCustomerPackage.mutate({ quotationId })
|
||||
}
|
||||
>
|
||||
<Icons.badgeCheck className='mr-2 h-4 w-4' />
|
||||
{customerPackageActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{canPreviewCustomerPackage ? (
|
||||
<Button asChild variant='outline' disabled={!customerPackage}>
|
||||
<Link
|
||||
href={`/api/crm/quotations/${quotationId}/assembled-pdf`}
|
||||
target='_blank'
|
||||
aria-disabled={!customerPackage}
|
||||
onClick={(event) => {
|
||||
if (!customerPackage) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.eyeOff className='mr-2 h-4 w-4' /> View Customer Package
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canDownloadCustomerPackage ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={!customerPackage}
|
||||
isLoading={downloadCustomerPackage.isPending}
|
||||
onClick={() => downloadCustomerPackage.mutate()}
|
||||
>
|
||||
<Icons.download className='mr-2 h-4 w-4' /> Download Customer Package
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-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='space-y-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='space-y-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>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<Card>
|
||||
|
||||
72
src/features/crm/quotations/customer-package.test.ts
Normal file
72
src/features/crm/quotations/customer-package.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getCustomerPackageRoleLabel,
|
||||
getCustomerPackageUiStatus,
|
||||
mapCustomerPackageErrorMessage
|
||||
} from './customer-package';
|
||||
|
||||
test('mapCustomerPackageErrorMessage rewrites not-generated error', () => {
|
||||
assert.equal(
|
||||
mapCustomerPackageErrorMessage('Assembled quotation PDF has not been generated yet.'),
|
||||
'Customer package has not been generated yet. Please generate it first.'
|
||||
);
|
||||
});
|
||||
|
||||
test('mapCustomerPackageErrorMessage rewrites missing document error', () => {
|
||||
assert.equal(
|
||||
mapCustomerPackageErrorMessage(
|
||||
'Missing SLA document (SLA, brand generic, language th, product type crane).'
|
||||
),
|
||||
'Cannot generate package because required SLA document is missing.'
|
||||
);
|
||||
});
|
||||
|
||||
test('mapCustomerPackageErrorMessage rewrites forbidden error', () => {
|
||||
assert.equal(
|
||||
mapCustomerPackageErrorMessage('Forbidden'),
|
||||
'You do not have permission to generate or download customer package.'
|
||||
);
|
||||
});
|
||||
|
||||
test('getCustomerPackageRoleLabel falls back to humanized labels', () => {
|
||||
assert.equal(getCustomerPackageRoleLabel('main'), 'Quotation');
|
||||
assert.equal(getCustomerPackageRoleLabel('custom_appendix'), 'Custom Appendix');
|
||||
});
|
||||
|
||||
test('getCustomerPackageUiStatus reflects generated, failed, and not-generated states', () => {
|
||||
assert.equal(
|
||||
getCustomerPackageUiStatus({
|
||||
customerPackage: {
|
||||
artifactId: 'artifact-1',
|
||||
fileName: 'pkg.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 100,
|
||||
checksum: 'abc',
|
||||
status: 'active',
|
||||
pageCount: 2,
|
||||
generatedAt: '2026-06-30T00:00:00.000Z',
|
||||
generatedBy: 'user-1',
|
||||
generatedByName: 'User',
|
||||
includedDocuments: [],
|
||||
warnings: []
|
||||
},
|
||||
lastErrorMessage: 'Something failed before'
|
||||
}),
|
||||
'generated'
|
||||
);
|
||||
assert.equal(
|
||||
getCustomerPackageUiStatus({
|
||||
customerPackage: null,
|
||||
lastErrorMessage: 'Customer package generation failed.'
|
||||
}),
|
||||
'failed'
|
||||
);
|
||||
assert.equal(
|
||||
getCustomerPackageUiStatus({
|
||||
customerPackage: null,
|
||||
lastErrorMessage: null
|
||||
}),
|
||||
'not_generated'
|
||||
);
|
||||
});
|
||||
65
src/features/crm/quotations/customer-package.ts
Normal file
65
src/features/crm/quotations/customer-package.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { QuotationCustomerPackageSummary } from './api/types';
|
||||
|
||||
const CUSTOMER_PACKAGE_ROLE_LABELS: Record<string, string> = {
|
||||
main: 'Quotation',
|
||||
sla: 'SLA',
|
||||
warranty: 'Warranty',
|
||||
datasheet: 'Datasheet',
|
||||
drawing: 'Drawing'
|
||||
};
|
||||
|
||||
export type CustomerPackageUiStatus = 'not_generated' | 'generated' | 'failed';
|
||||
|
||||
export function getCustomerPackageRoleLabel(role: string): string {
|
||||
return (
|
||||
CUSTOMER_PACKAGE_ROLE_LABELS[role] ??
|
||||
role
|
||||
.split(/[_\s-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
export function mapCustomerPackageErrorMessage(message?: string | null): string {
|
||||
const normalized = message?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return 'Customer package generation failed. Please check document library configuration.';
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === 'Forbidden' ||
|
||||
normalized.toLowerCase().includes('permission') ||
|
||||
normalized.toLowerCase().includes('forbidden')
|
||||
) {
|
||||
return 'You do not have permission to generate or download customer package.';
|
||||
}
|
||||
|
||||
if (normalized === 'Assembled quotation PDF has not been generated yet.') {
|
||||
return 'Customer package has not been generated yet. Please generate it first.';
|
||||
}
|
||||
|
||||
const missingRequiredDocument = normalized.match(/^Missing ([A-Z_ ]+) document/i);
|
||||
if (missingRequiredDocument) {
|
||||
const role = getCustomerPackageRoleLabel(missingRequiredDocument[1].toLowerCase());
|
||||
return `Cannot generate package because required ${role} document is missing.`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getCustomerPackageUiStatus(input: {
|
||||
customerPackage: QuotationCustomerPackageSummary | null;
|
||||
lastErrorMessage?: string | null;
|
||||
}): CustomerPackageUiStatus {
|
||||
if (input.customerPackage) {
|
||||
return 'generated';
|
||||
}
|
||||
|
||||
if (input.lastErrorMessage) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
return 'not_generated';
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import { AuthError } from '@/lib/auth/session';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import {
|
||||
getActiveArtifactForEntity,
|
||||
type DocumentArtifactRecord
|
||||
} from '@/features/foundation/document-artifact/server/service';
|
||||
import {
|
||||
type CrmSecurityContext,
|
||||
canAccessScopedCrmRecord
|
||||
@@ -47,6 +51,9 @@ import type {
|
||||
QuotationApprovedArtifactSummary,
|
||||
QuotationBranchOption,
|
||||
QuotationContactLookup,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerPackageSummary,
|
||||
QuotationCustomerPackageWarning,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerLookup,
|
||||
QuotationCustomerMutationPayload,
|
||||
@@ -133,10 +140,99 @@ function mapApprovedArtifactSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toCustomerPackageSource(
|
||||
value: unknown
|
||||
): QuotationCustomerPackageSource | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
if (typeof source.role !== 'string' || typeof source.fileName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source.sourceType !== 'generated' && source.sourceType !== 'document_library') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
role: source.role,
|
||||
sourceType: source.sourceType,
|
||||
fileName: source.fileName,
|
||||
checksum: typeof source.checksum === 'string' ? source.checksum : null,
|
||||
fileSize: typeof source.fileSize === 'number' ? source.fileSize : null,
|
||||
pageCount: typeof source.pageCount === 'number' ? source.pageCount : null,
|
||||
metadata:
|
||||
source.metadata && typeof source.metadata === 'object' && !Array.isArray(source.metadata)
|
||||
? (source.metadata as Record<string, unknown>)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function toCustomerPackageWarning(
|
||||
value: unknown
|
||||
): QuotationCustomerPackageWarning | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const warning = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof warning.code !== 'string' ||
|
||||
typeof warning.role !== 'string' ||
|
||||
typeof warning.message !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: warning.code,
|
||||
role: warning.role,
|
||||
message: warning.message
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomerPackageSummary(
|
||||
artifact: DocumentArtifactRecord
|
||||
): QuotationCustomerPackageSummary {
|
||||
const metadata =
|
||||
artifact.metadata && typeof artifact.metadata === 'object' && !Array.isArray(artifact.metadata)
|
||||
? (artifact.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const includedDocuments = Array.isArray(metadata?.sourceArtifacts)
|
||||
? metadata.sourceArtifacts
|
||||
.map((item) => toCustomerPackageSource(item))
|
||||
.filter((item): item is QuotationCustomerPackageSource => item !== null)
|
||||
: [];
|
||||
const warnings = Array.isArray(metadata?.warnings)
|
||||
? metadata.warnings
|
||||
.map((item) => toCustomerPackageWarning(item))
|
||||
.filter((item): item is QuotationCustomerPackageWarning => item !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
artifactId: artifact.id,
|
||||
fileName: artifact.fileName,
|
||||
contentType: artifact.contentType,
|
||||
fileSize: artifact.fileSize,
|
||||
checksum: artifact.checksum,
|
||||
status: artifact.status,
|
||||
pageCount: typeof metadata?.pageCount === 'number' ? metadata.pageCount : null,
|
||||
generatedAt: artifact.generatedAt,
|
||||
generatedBy: artifact.generatedBy,
|
||||
generatedByName: artifact.generatedByName,
|
||||
includedDocuments,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
function mapQuotationRecord(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
options?: {
|
||||
approvedArtifact?: QuotationApprovedArtifactSummary | null;
|
||||
customerPackage?: QuotationCustomerPackageSummary | null;
|
||||
branchName?: string | null;
|
||||
branchCode?: string | null;
|
||||
salesmanName?: string | null;
|
||||
@@ -188,6 +284,7 @@ function mapQuotationRecord(
|
||||
approvedSnapshot: row.approvedSnapshot ?? null,
|
||||
approvedTemplateVersionId: row.approvedTemplateVersionId,
|
||||
approvedArtifact: options?.approvedArtifact ?? null,
|
||||
customerPackage: options?.customerPackage ?? null,
|
||||
hasLegacyApprovedPdf:
|
||||
!row.approvedArtifactId &&
|
||||
typeof row.approvedPdfUrl === 'string' &&
|
||||
@@ -1240,6 +1337,12 @@ export async function getQuotationDetail(
|
||||
})
|
||||
]);
|
||||
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
|
||||
const customerPackageArtifactPromise = getActiveArtifactForEntity({
|
||||
organizationId,
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
artifactType: 'assembled_pdf'
|
||||
});
|
||||
const baseDisplayOptions = {
|
||||
branchName: branchDisplay?.name ?? null,
|
||||
branchCode: branchDisplay?.code ?? null,
|
||||
@@ -1256,7 +1359,13 @@ export async function getQuotationDetail(
|
||||
: null);
|
||||
|
||||
if (!approvedArtifactId) {
|
||||
return mapQuotationRecord(quotation, baseDisplayOptions);
|
||||
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
const [artifact] = await db
|
||||
@@ -1272,10 +1381,19 @@ export async function getQuotationDetail(
|
||||
.limit(1);
|
||||
|
||||
if (!artifact) {
|
||||
return mapQuotationRecord(quotation);
|
||||
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]);
|
||||
const [artifactActorMap, customerPackageArtifact] = await Promise.all([
|
||||
resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]),
|
||||
customerPackageArtifactPromise
|
||||
]);
|
||||
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
@@ -1284,7 +1402,10 @@ export async function getQuotationDetail(
|
||||
lockedByName: artifact.lockedBy
|
||||
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
|
||||
: null
|
||||
})
|
||||
}),
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user