task-h.4
This commit is contained in:
@@ -32,7 +32,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<div className='space-y-1'>
|
||||
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
|
||||
<CardDescription>
|
||||
ตัวอย่างใบเสนอราคา จากเทมเพลต {template.template.templateName} v{template.version.version}
|
||||
ตัวอย่างใบเสนอราคา จากเทมเพลต {template.template.templateName} v
|
||||
{template.version.version}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
@@ -91,8 +92,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลโครงการ</CardTitle>
|
||||
<CardDescription>สรุปข้อมูลอ้างอิงของโครงการสำหรับลูกค้า</CardDescription>
|
||||
<CardTitle>ข้อมูลโครงการ</CardTitle>
|
||||
<CardDescription>สรุปข้อมูลอ้างอิงของโครงการสำหรับลูกค้า</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<Field label='ชื่อโครงการ' value={documentData.quotation.projectName} />
|
||||
@@ -104,8 +105,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ตารางรายการ</CardTitle>
|
||||
<CardDescription>รายการที่จัดรูปแบบแล้วเพื่อเตรียมใช้กับ template mapping</CardDescription>
|
||||
<CardTitle>ตารางรายการ</CardTitle>
|
||||
<CardDescription>รายการที่จัดรูปแบบแล้วเพื่อเตรียมใช้กับ template mapping</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<div className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border bg-muted/20 px-4 py-3 text-xs font-medium uppercase tracking-wide'>
|
||||
@@ -124,7 +125,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<div>{item.itemNumber}</div>
|
||||
<div className='space-y-1'>
|
||||
<div>{item.description}</div>
|
||||
<div className='text-muted-foreground text-xs'>{item.productTypeLabel || '-'}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{item.productTypeLabel || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>{item.quantity}</div>
|
||||
<div>{item.unitLabel || '-'}</div>
|
||||
@@ -138,8 +141,10 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<div className='grid gap-6 lg:grid-cols-[2fr_1fr]'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>หัวข้อเอกสาร</CardTitle>
|
||||
<CardDescription>ขอบเขตงาน ข้อยกเว้น และเงื่อนไขการชำระเงินจากหัวข้อในระบบ</CardDescription>
|
||||
<CardTitle>หัวข้อเอกสาร</CardTitle>
|
||||
<CardDescription>
|
||||
ขอบเขตงาน ข้อยกเว้น และเงื่อนไขการชำระเงินจากหัวข้อในระบบ
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{topicGroups.map((group) => {
|
||||
@@ -151,7 +156,10 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<div className='text-muted-foreground text-sm'>ไม่มีข้อมูล</div>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={`${group.key}-${entry.title}`} className='rounded-lg border p-4'>
|
||||
<div
|
||||
key={`${group.key}-${entry.title}`}
|
||||
className='rounded-lg border p-4'
|
||||
>
|
||||
<div className='mb-2 text-sm font-medium'>{entry.title}</div>
|
||||
<ul className='list-disc space-y-1 pl-5 text-sm'>
|
||||
{entry.items.map((item) => (
|
||||
@@ -170,7 +178,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>สรุปมูลค่า</CardTitle>
|
||||
<CardTitle>สรุปมูลค่า</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -183,15 +191,17 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>รวมทั้งสิ้น</span>
|
||||
<span className='font-semibold'>{documentData.quotation.totalAmount.toLocaleString()}</span>
|
||||
<span className='font-semibold'>
|
||||
{documentData.quotation.totalAmount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลอนุมัติเอกสาร</CardTitle>
|
||||
<CardDescription>ข้อมูลที่ใช้ประกอบ watermark และ approved snapshot</CardDescription>
|
||||
<CardTitle>ข้อมูลอนุมัติเอกสาร</CardTitle>
|
||||
<CardDescription>ข้อมูลที่ใช้ประกอบ watermark และ approved snapshot</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<Field label='สถานะ' value={documentData.approval.status} />
|
||||
@@ -202,12 +212,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<div className='text-muted-foreground text-sm'>ยังไม่มีประวัติการอนุมัติ</div>
|
||||
) : (
|
||||
documentData.approval.approvers.map((item) => (
|
||||
<div key={`${item.stepNumber}-${item.roleCode}`} className='rounded-lg border p-3 text-sm'>
|
||||
<div
|
||||
key={`${item.stepNumber}-${item.roleCode}`}
|
||||
className='rounded-lg border p-3 text-sm'
|
||||
>
|
||||
<div className='font-medium'>
|
||||
ขั้น {item.stepNumber} - {item.roleName}
|
||||
</div>
|
||||
<div className='text-muted-foreground'>
|
||||
{item.actorName || '-'} {item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
|
||||
{item.actorName || '-'}{' '}
|
||||
{item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -217,17 +231,20 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ตำแหน่งลายเซ็น</CardTitle>
|
||||
<CardTitle>Approval Signatures</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
<Field label='ผู้จัดทำ' value={documentData.signatures.preparedBy} />
|
||||
<Field label='ผู้ขออนุมัติ' value={documentData.signatures.requestedBy} />
|
||||
<Field label='ผู้จัดการฝ่ายขาย' value={documentData.signatures.salesManager} />
|
||||
<Field
|
||||
label='ผู้จัดการฝ่าย'
|
||||
value={documentData.signatures.departmentManager}
|
||||
/>
|
||||
<Field label='ผู้บริหารสูงสุด' value={documentData.signatures.topManager} />
|
||||
{[
|
||||
{ label: 'Prepared By', value: documentData.signatures.preparedBy },
|
||||
{ label: 'Approved By', value: documentData.signatures.approvedBy },
|
||||
{ label: 'Authorized By', value: documentData.signatures.authorizedBy }
|
||||
].map((item) => (
|
||||
<div key={item.label} className='rounded-lg border p-3'>
|
||||
<Field label={item.label} value={item.value.name} />
|
||||
<Field label='Position' value={item.value.position} />
|
||||
<Field label='Timestamp' value={item.value.actedAt} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
crmQuotations,
|
||||
organizations,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { listApprovalRequests, getApprovalRequest } from '@/features/foundation/approval/server/service';
|
||||
import {
|
||||
listApprovalRequests,
|
||||
getApprovalRequest
|
||||
} from '@/features/foundation/approval/server/service';
|
||||
import {
|
||||
mapDocumentDataToTemplateInput,
|
||||
resolveTemplateForDocument,
|
||||
@@ -27,12 +24,9 @@ import type {
|
||||
QuotationDocumentData
|
||||
} from '../types';
|
||||
import type { PdfTopic } from './pdf-topic.type';
|
||||
import {
|
||||
formatPdfCurrency,
|
||||
formatPdfDate,
|
||||
formatTopicItems
|
||||
} from './pdfme-transforms';
|
||||
import { formatPdfCurrency, formatPdfDate, formatTopicItems } from './pdfme-transforms';
|
||||
import { buildPdfTopicTemplate } from './pdf-topic-engine';
|
||||
import { resolveQuotationSignatures } from './signature-resolver';
|
||||
import { resolveQuotationTopicTypeMapping } from './topic-mapping';
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
@@ -76,14 +70,14 @@ function findOptionLabel(
|
||||
options: Array<{ id: string; code: string; label: string }>,
|
||||
id: string | null | undefined
|
||||
) {
|
||||
return id ? options.find((option) => option.id === id)?.label ?? null : null;
|
||||
return id ? (options.find((option) => option.id === id)?.label ?? null) : null;
|
||||
}
|
||||
|
||||
function findOptionCode(
|
||||
options: Array<{ id: string; code: string; label: string }>,
|
||||
id: string | null | undefined
|
||||
) {
|
||||
return id ? options.find((option) => option.id === id)?.code ?? null : null;
|
||||
return id ? (options.find((option) => option.id === id)?.code ?? null) : null;
|
||||
}
|
||||
|
||||
function extractSinglePlaceholder(value: string) {
|
||||
@@ -94,7 +88,12 @@ function extractSinglePlaceholder(value: string) {
|
||||
}
|
||||
|
||||
function normalizeTopicKey(value: string | null | undefined) {
|
||||
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
|
||||
return (
|
||||
value
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[\s_-]+/g, '') ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
|
||||
@@ -127,14 +126,13 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode:
|
||||
['warranty', 'solarcellwarranty']
|
||||
];
|
||||
|
||||
return aliasGroups.some(
|
||||
(group) => group.includes(actual) && group.includes(expected)
|
||||
);
|
||||
return aliasGroups.some((group) => group.includes(actual) && group.includes(expected));
|
||||
}
|
||||
|
||||
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const delta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||
const delta =
|
||||
(left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||
|
||||
if (delta !== 0) {
|
||||
return delta;
|
||||
@@ -144,10 +142,7 @@ function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePdfmeTemplateInput(
|
||||
schemaJson: unknown,
|
||||
templateInput: Record<string, unknown>
|
||||
) {
|
||||
function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<string, unknown>) {
|
||||
const pages =
|
||||
schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson
|
||||
? (schemaJson as { schemas?: unknown }).schemas
|
||||
@@ -243,14 +238,18 @@ export async function buildQuotationDocumentData(
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
|
||||
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
|
||||
listApprovalRequests(organizationId, {
|
||||
entityType: 'quotation',
|
||||
entityId: quotationId,
|
||||
limit: 20
|
||||
})
|
||||
]);
|
||||
|
||||
if (!company) {
|
||||
throw new AuthError('Organization not found', 404);
|
||||
}
|
||||
|
||||
const [customer, contact, relatedCustomers, preparer] = await Promise.all([
|
||||
const [customer, contact, relatedCustomers, signatures] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
@@ -290,18 +289,17 @@ export async function buildQuotationDocumentData(
|
||||
)
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, quotation.createdBy))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
resolveQuotationSignatures(quotationId, organizationId)
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
throw new AuthError('Customer not found', 404);
|
||||
}
|
||||
|
||||
const approvalRequest = approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items[0] ?? null;
|
||||
const approvalRequest =
|
||||
approvalRequests.items.find((item) => item.status === 'approved') ??
|
||||
approvalRequests.items[0] ??
|
||||
null;
|
||||
const approvalDetail = approvalRequest
|
||||
? await getApprovalRequest(approvalRequest.id, organizationId)
|
||||
: null;
|
||||
@@ -364,9 +362,13 @@ export async function buildQuotationDocumentData(
|
||||
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
|
||||
);
|
||||
const otherTopics = resolvedTopics.filter((item) => {
|
||||
return ![topicTypeMapping.scope, topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, topicTypeMapping.delivery].some(
|
||||
(expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)
|
||||
);
|
||||
return ![
|
||||
topicTypeMapping.scope,
|
||||
topicTypeMapping.exclusion,
|
||||
topicTypeMapping.payment,
|
||||
topicTypeMapping.warranty,
|
||||
topicTypeMapping.delivery
|
||||
].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode));
|
||||
});
|
||||
const exclusionTopic = exclusionTopics[0];
|
||||
const paymentTopic = paymentTopics[0];
|
||||
@@ -376,7 +378,9 @@ export async function buildQuotationDocumentData(
|
||||
approvalDetail?.timeline
|
||||
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
||||
.map((item) => {
|
||||
const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber);
|
||||
const step = approvalDetail.steps.find(
|
||||
(candidate) => candidate.stepNumber === item.stepNumber
|
||||
);
|
||||
return {
|
||||
stepNumber: item.stepNumber,
|
||||
roleCode: step?.roleCode ?? '',
|
||||
@@ -527,27 +531,12 @@ export async function buildQuotationDocumentData(
|
||||
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
|
||||
approvers: approvalApprovers
|
||||
},
|
||||
signatures: {
|
||||
preparedBy: preparer?.name ?? null,
|
||||
preparedByPosition: null,
|
||||
requestedBy: contact?.name ?? customer.name,
|
||||
salesManager:
|
||||
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
|
||||
salesManagerPosition: null,
|
||||
departmentManager:
|
||||
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
|
||||
departmentManagerPosition: null,
|
||||
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null,
|
||||
topManagerPosition: null
|
||||
},
|
||||
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
|
||||
signatures,
|
||||
watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null
|
||||
};
|
||||
}
|
||||
|
||||
export async function getQuotationDocumentPreviewData(
|
||||
quotationId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
export async function getQuotationDocumentPreviewData(quotationId: string, organizationId: string) {
|
||||
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
|
||||
const template = await resolveTemplateForDocument(organizationId, {
|
||||
documentType: 'quotation',
|
||||
@@ -557,7 +546,10 @@ export async function getQuotationDocumentPreviewData(
|
||||
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
|
||||
|
||||
if (!mappings.length) {
|
||||
throw new AuthError('Document template mappings are not configured for this template version', 409);
|
||||
throw new AuthError(
|
||||
'Document template mappings are not configured for this template version',
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const templateInput = normalizePdfmeTemplateInput(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { crmQuotations, memberships, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import {
|
||||
getApprovalRequest,
|
||||
listApprovalRequests
|
||||
} from '@/features/foundation/approval/server/service';
|
||||
import type {
|
||||
ApprovalDetailRecord,
|
||||
ApprovalTimelineItem
|
||||
} from '@/features/foundation/approval/types';
|
||||
import {
|
||||
getSignaturePositionLookup,
|
||||
resolveSignaturePositionLabel
|
||||
} from '@/features/crm/quotations/document/signature-position';
|
||||
import type {
|
||||
SignatureBlock,
|
||||
SignatureParty
|
||||
} from '@/features/crm/quotations/document/signature.types';
|
||||
|
||||
const EMPTY_SIGNATURE_PARTY: SignatureParty = {
|
||||
name: '-',
|
||||
position: '-',
|
||||
actedAt: null,
|
||||
userId: null,
|
||||
roleCode: null
|
||||
};
|
||||
|
||||
const APPROVED_BY_ROLE_CODES = new Set(['sales_manager', 'department_manager']);
|
||||
|
||||
type ApprovalActionWithRole = ApprovalTimelineItem & {
|
||||
roleCode: string | null;
|
||||
};
|
||||
|
||||
function toSignatureParty(input?: Partial<SignatureParty> | null): SignatureParty {
|
||||
return {
|
||||
name: input?.name?.trim() || '-',
|
||||
position: input?.position?.trim() || '-',
|
||||
actedAt: input?.actedAt ?? null,
|
||||
userId: input?.userId ?? null,
|
||||
roleCode: input?.roleCode ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapApprovalActions(detail: ApprovalDetailRecord | null): ApprovalActionWithRole[] {
|
||||
if (!detail) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return detail.timeline
|
||||
.filter((item) => item.action === 'approve')
|
||||
.map((item) => ({
|
||||
...item,
|
||||
roleCode: detail.steps.find((step) => step.stepNumber === item.stepNumber)?.roleCode ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveApprovedByAction(actions: ApprovalActionWithRole[], finalRoleCode: string | null) {
|
||||
const candidates = actions.filter(
|
||||
(item) =>
|
||||
item.roleCode !== finalRoleCode &&
|
||||
item.roleCode !== null &&
|
||||
APPROVED_BY_ROLE_CODES.has(item.roleCode)
|
||||
);
|
||||
|
||||
return candidates.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function resolveAuthorizedByAction(
|
||||
actions: ApprovalActionWithRole[],
|
||||
finalRoleCode: string | null
|
||||
) {
|
||||
if (!finalRoleCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return actions.filter((item) => item.roleCode === finalRoleCode).at(-1) ?? null;
|
||||
}
|
||||
|
||||
export async function resolveQuotationSignatures(
|
||||
quotationId: string,
|
||||
organizationId: string
|
||||
): Promise<SignatureBlock> {
|
||||
const [quotation] = await db
|
||||
.select({
|
||||
id: crmQuotations.id,
|
||||
organizationId: crmQuotations.organizationId,
|
||||
salesmanId: crmQuotations.salesmanId,
|
||||
createdBy: crmQuotations.createdBy,
|
||||
createdAt: crmQuotations.createdAt
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.id, quotationId),
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!quotation) {
|
||||
throw new AuthError('Quotation not found', 404);
|
||||
}
|
||||
|
||||
const approvalRequests = await listApprovalRequests(organizationId, {
|
||||
entityType: 'quotation',
|
||||
entityId: quotationId,
|
||||
limit: 20
|
||||
});
|
||||
const approvalRequest =
|
||||
approvalRequests.items.find((item) => item.status === 'approved') ??
|
||||
approvalRequests.items[0] ??
|
||||
null;
|
||||
const approvalDetail = approvalRequest
|
||||
? await getApprovalRequest(approvalRequest.id, organizationId)
|
||||
: null;
|
||||
const finalStep = approvalDetail?.steps.at(-1) ?? null;
|
||||
const approvalActions = mapApprovalActions(approvalDetail);
|
||||
const approvedByAction = resolveApprovedByAction(approvalActions, finalStep?.roleCode ?? null);
|
||||
const authorizedByAction = resolveAuthorizedByAction(
|
||||
approvalActions,
|
||||
finalStep?.roleCode ?? null
|
||||
);
|
||||
const preparedByUserId = quotation.salesmanId ?? quotation.createdBy;
|
||||
const userIds = [
|
||||
preparedByUserId,
|
||||
approvedByAction?.actedBy ?? null,
|
||||
authorizedByAction?.actedBy ?? null
|
||||
].filter((value): value is string => Boolean(value));
|
||||
const uniqueUserIds = [...new Set(userIds)];
|
||||
const [userRows, membershipRows, positionLookup] = await Promise.all([
|
||||
uniqueUserIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, uniqueUserIds))
|
||||
: Promise.resolve([]),
|
||||
uniqueUserIds.length
|
||||
? db
|
||||
.select({
|
||||
userId: memberships.userId,
|
||||
businessRole: memberships.businessRole
|
||||
})
|
||||
.from(memberships)
|
||||
.where(
|
||||
and(
|
||||
eq(memberships.organizationId, organizationId),
|
||||
inArray(memberships.userId, uniqueUserIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
getSignaturePositionLookup(organizationId)
|
||||
]);
|
||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||
const membershipMap = new Map(membershipRows.map((row) => [row.userId, row.businessRole]));
|
||||
|
||||
const createPartyFromUser = (args: {
|
||||
userId: string | null | undefined;
|
||||
actedAt?: string | null;
|
||||
roleCode?: string | null;
|
||||
fallbackName?: string | null;
|
||||
}) => {
|
||||
if (!args.userId && !args.fallbackName) {
|
||||
return toSignatureParty(EMPTY_SIGNATURE_PARTY);
|
||||
}
|
||||
|
||||
const businessRole =
|
||||
(args.userId ? membershipMap.get(args.userId) : null) ?? args.roleCode ?? null;
|
||||
|
||||
return toSignatureParty({
|
||||
name:
|
||||
(args.userId ? userMap.get(args.userId) : null) ??
|
||||
args.fallbackName?.trim() ??
|
||||
EMPTY_SIGNATURE_PARTY.name,
|
||||
position: resolveSignaturePositionLabel(businessRole, positionLookup),
|
||||
actedAt: args.actedAt ?? null,
|
||||
userId: args.userId ?? null,
|
||||
roleCode: businessRole
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
preparedBy: createPartyFromUser({
|
||||
userId: preparedByUserId,
|
||||
actedAt: quotation.createdAt.toISOString()
|
||||
}),
|
||||
approvedBy: createPartyFromUser({
|
||||
userId: approvedByAction?.actedBy,
|
||||
actedAt: approvedByAction?.actedAt ?? null,
|
||||
roleCode: approvedByAction?.roleCode ?? null,
|
||||
fallbackName: approvedByAction?.actorName ?? null
|
||||
}),
|
||||
authorizedBy: createPartyFromUser({
|
||||
userId: authorizedByAction?.actedBy,
|
||||
actedAt: authorizedByAction?.actedAt ?? null,
|
||||
roleCode: authorizedByAction?.roleCode ?? finalStep?.roleCode ?? null,
|
||||
fallbackName: authorizedByAction?.actorName ?? null
|
||||
})
|
||||
};
|
||||
}
|
||||
43
src/features/crm/quotations/document/signature-position.ts
Normal file
43
src/features/crm/quotations/document/signature-position.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
|
||||
const JOB_TITLE_OPTION_CATEGORY = 'crm_job_title';
|
||||
|
||||
const BUSINESS_ROLE_FALLBACK_LABELS: Record<string, string> = {
|
||||
sales: 'Sales Engineer',
|
||||
sales_support: 'Sales Support',
|
||||
sales_manager: 'Sales Manager',
|
||||
department_manager: 'Department Manager',
|
||||
top_manager: 'General Manager'
|
||||
};
|
||||
|
||||
function toStartCase(value: string) {
|
||||
return value
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export async function getSignaturePositionLookup(organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(JOB_TITLE_OPTION_CATEGORY, { organizationId });
|
||||
return new Map(options.map((option) => [option.code, option.label]));
|
||||
}
|
||||
|
||||
export function getBusinessRoleFallbackLabel(businessRole: string | null | undefined) {
|
||||
if (!businessRole) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return BUSINESS_ROLE_FALLBACK_LABELS[businessRole] ?? toStartCase(businessRole);
|
||||
}
|
||||
|
||||
export function resolveSignaturePositionLabel(
|
||||
businessRole: string | null | undefined,
|
||||
positionLookup: Map<string, string>
|
||||
) {
|
||||
if (!businessRole) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return positionLookup.get(businessRole) ?? getBusinessRoleFallbackLabel(businessRole);
|
||||
}
|
||||
13
src/features/crm/quotations/document/signature.types.ts
Normal file
13
src/features/crm/quotations/document/signature.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface SignatureParty {
|
||||
name: string;
|
||||
position: string;
|
||||
actedAt?: string | null;
|
||||
userId?: string | null;
|
||||
roleCode?: string | null;
|
||||
}
|
||||
|
||||
export interface SignatureBlock {
|
||||
preparedBy: SignatureParty;
|
||||
approvedBy: SignatureParty;
|
||||
authorizedBy: SignatureParty;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
DocumentTemplateMappingWithColumns,
|
||||
ResolvedDocumentTemplate
|
||||
} from '@/features/foundation/document-template/types';
|
||||
import type { SignatureBlock } from './signature.types';
|
||||
import type { PdfTopic } from './server/pdf-topic.type';
|
||||
|
||||
export interface QuotationDocumentTopicGroup {
|
||||
@@ -132,17 +133,7 @@ export interface QuotationDocumentData {
|
||||
currentStepRoleName: string | null;
|
||||
approvers: QuotationDocumentApprovalStep[];
|
||||
};
|
||||
signatures: {
|
||||
preparedBy: string | null;
|
||||
preparedByPosition?: string | null;
|
||||
requestedBy: string | null;
|
||||
salesManager: string | null;
|
||||
salesManagerPosition?: string | null;
|
||||
departmentManager: string | null;
|
||||
departmentManagerPosition?: string | null;
|
||||
topManager: string | null;
|
||||
topManagerPosition?: string | null;
|
||||
};
|
||||
signatures: SignatureBlock;
|
||||
watermarkStatus: string | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user