task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

View File

@@ -28,7 +28,7 @@ export async function getQuotations(filters: QuotationFilters): Promise<Quotatio
if (filters.quotationType) searchParams.set('quotationType', filters.quotationType);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.enquiry) searchParams.set('enquiry', filters.enquiry);
if (filters.opportunity) searchParams.set('opportunity', filters.opportunity);
if (filters.hot) searchParams.set('hot', filters.hot);
if (filters.sort) searchParams.set('sort', filters.sort);
@@ -212,3 +212,4 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
body: JSON.stringify({ revisionRemark })
});
}

View File

@@ -28,7 +28,7 @@ export interface QuotationContactLookup {
isPrimary: boolean;
}
export interface QuotationEnquiryLookup {
export interface QuotationOpportunityLookup {
id: string;
code: string;
customerId: string;
@@ -64,7 +64,7 @@ export interface QuotationRecord {
organizationId: string;
branchId: string | null;
code: string;
enquiryId: string | null;
opportunityId: string | null;
customerId: string;
contactId: string | null;
quotationDate: string;
@@ -115,7 +115,7 @@ export interface QuotationRecord {
export interface QuotationListItem extends QuotationRecord {
customerName: string;
contactName: string | null;
enquiryCode: string | null;
opportunityCode: string | null;
itemCount: number;
revisionLabel: string;
}
@@ -253,7 +253,7 @@ export interface QuotationReferenceData {
productTypes: QuotationOption[];
customers: QuotationCustomerLookup[];
contacts: QuotationContactLookup[];
enquiries: QuotationEnquiryLookup[];
opportunities: QuotationOpportunityLookup[];
salesmen: QuotationSalesmanLookup[];
}
@@ -265,7 +265,7 @@ export interface QuotationFilters {
quotationType?: string;
branch?: string;
customer?: string;
enquiry?: string;
opportunity?: string;
hot?: string;
sort?: string;
}
@@ -331,7 +331,7 @@ export interface QuotationRevisionsResponse {
}
export interface QuotationMutationPayload {
enquiryId?: string | null;
opportunityId?: string | null;
customerId: string;
contactId?: string | null;
quotationDate: string;
@@ -420,3 +420,4 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -114,16 +114,16 @@ export function getQuotationColumns({
enableColumnFilter: true
},
{
id: 'enquiry',
accessorFn: (row) => row.enquiryId ?? '',
id: 'opportunity',
accessorFn: (row) => row.opportunityId ?? '',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='โอกาสขาย' />
),
cell: ({ row }) => row.original.enquiryCode ?? '-',
cell: ({ row }) => row.original.opportunityCode ?? '-',
meta: {
label: 'โอกาสขาย',
variant: 'multiSelect' as const,
options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
options: referenceData.opportunities.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
},
enableColumnFilter: true
},
@@ -165,3 +165,4 @@ export function getQuotationColumns({
}
];
}

View File

@@ -39,7 +39,7 @@ import { createQuotationMutation, updateQuotationMutation } from '../api/mutatio
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
type FormState = {
enquiryId: string;
opportunityId: string;
customerId: string;
contactId: string;
quotationDate: string;
@@ -71,7 +71,7 @@ function toFormState(
referenceData: QuotationReferenceData
): FormState {
return {
enquiryId: quotation?.enquiryId ?? '',
opportunityId: quotation?.opportunityId ?? '',
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: quotation?.contactId ?? '',
quotationDate:
@@ -190,16 +190,16 @@ export function QuotationFormSheet({
setState((current) => ({ ...current, [key]: value }));
}
function handleEnquiryChange(value: string) {
const enquiry = referenceData.enquiries.find((item) => item.id === value);
function handleOpportunityChange(value: string) {
const opportunity = referenceData.opportunities.find((item) => item.id === value);
setState((current) => ({
...current,
enquiryId: value === '__none__' ? '' : value,
customerId: enquiry?.customerId ?? current.customerId,
contactId: enquiry?.contactId ?? '',
projectName: enquiry?.projectName ?? current.projectName,
projectLocation: enquiry?.projectLocation ?? current.projectLocation,
branchId: enquiry?.branchId ?? current.branchId
opportunityId: value === '__none__' ? '' : value,
customerId: opportunity?.customerId ?? current.customerId,
contactId: opportunity?.contactId ?? '',
projectName: opportunity?.projectName ?? current.projectName,
projectLocation: opportunity?.projectLocation ?? current.projectLocation,
branchId: opportunity?.branchId ?? current.branchId
}));
}
@@ -207,7 +207,7 @@ export function QuotationFormSheet({
event.preventDefault();
const payload: QuotationMutationPayload = {
enquiryId: state.enquiryId || null,
opportunityId: state.opportunityId || null,
customerId: state.customerId,
contactId: state.contactId || null,
quotationDate: state.quotationDate,
@@ -263,14 +263,14 @@ export function QuotationFormSheet({
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
<div className='grid gap-4 py-4 md:grid-cols-2'>
<Field label='Enquiry'>
<Select value={state.enquiryId || '__none__'} onValueChange={handleEnquiryChange}>
<Field label='Opportunity'>
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
<SelectTrigger>
<SelectValue placeholder='Select enquiry' />
<SelectValue placeholder='Select opportunity' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No linked enquiry</SelectItem>
{referenceData.enquiries.map((item) => (
<SelectItem value='__none__'>No linked opportunity</SelectItem>
{referenceData.opportunities.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.code} - {item.title}
</SelectItem>
@@ -541,3 +541,4 @@ export function QuotationFormSheetTrigger({
</>
);
}

View File

@@ -21,7 +21,7 @@ export default function QuotationListing({
const quotationType = searchParamsCache.get('quotationType');
const branch = searchParamsCache.get('branch');
const customer = searchParamsCache.get('customer');
const enquiry = searchParamsCache.get('enquiry');
const opportunity = searchParamsCache.get('opportunity');
const hot = searchParamsCache.get('hot');
const sort = searchParamsCache.get('sort');
const filters = {
@@ -32,7 +32,7 @@ export default function QuotationListing({
...(quotationType && { quotationType }),
...(branch && { branch }),
...(customer && { customer }),
...(enquiry && { enquiry }),
...(opportunity && { opportunity }),
...(hot && { hot }),
...(sort && { sort })
};
@@ -46,3 +46,4 @@ export default function QuotationListing({
</HydrationBoundary>
);
}

View File

@@ -33,7 +33,7 @@ export function QuotationsTable({
quotationType: parseAsString,
branch: parseAsString,
customer: parseAsString,
enquiry: parseAsString,
opportunity: parseAsString,
hot: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
@@ -46,7 +46,7 @@ export function QuotationsTable({
...(params.quotationType && { quotationType: params.quotationType }),
...(params.branch && { branch: params.branch }),
...(params.customer && { customer: params.customer }),
...(params.enquiry && { enquiry: params.enquiry }),
...(params.opportunity && { opportunity: params.opportunity }),
...(params.hot && { hot: params.hot }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
@@ -70,3 +70,4 @@ export function QuotationsTable({
</DataTable>
);
}

View File

@@ -39,7 +39,7 @@ function coerceRequiredNumber(message: string) {
}
export const quotationSchema = z.object({
enquiryId: z.string().optional().nullable(),
opportunityId: z.string().optional().nullable(),
customerId: z.string().min(1, 'Please select a customer'),
contactId: z.string().optional().nullable(),
quotationDate: z.string().min(1, 'Quotation date is required'),
@@ -159,3 +159,4 @@ export type QuotationCustomerFormValues = z.input<typeof quotationCustomerSchema
export type QuotationTopicFormValues = z.input<typeof quotationTopicSchema>;
export type QuotationFollowupFormValues = z.input<typeof quotationFollowupSchema>;
export type QuotationAttachmentFormValues = z.input<typeof quotationAttachmentSchema>;

View File

@@ -3,7 +3,7 @@ import {
crmDocumentArtifacts,
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmOpportunities,
crmQuotationAttachments,
crmQuotationCustomers,
crmQuotationFollowups,
@@ -25,7 +25,7 @@ import {
} from '@/features/crm/security/server/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service';
import type {
QuotationActivityRecord,
QuotationAttachmentMutationPayload,
@@ -37,7 +37,7 @@ import type {
QuotationCustomerLookup,
QuotationCustomerMutationPayload,
QuotationCustomerRecord,
QuotationEnquiryLookup,
QuotationOpportunityLookup,
QuotationFilters,
QuotationFollowupMutationPayload,
QuotationFollowupRecord,
@@ -128,7 +128,7 @@ function mapQuotationRecord(
organizationId: row.organizationId,
branchId: row.branchId,
code: row.code,
enquiryId: row.enquiryId,
opportunityId: row.opportunityId,
customerId: row.customerId,
contactId: row.contactId,
quotationDate: row.quotationDate.toISOString(),
@@ -437,24 +437,24 @@ export async function assertContactBelongsToOrganization(
return contact;
}
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
const [enquiry] = await db
async function assertOpportunityBelongsToOrganization(id: string, organizationId: string) {
const [opportunity] = await db
.select()
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(
eq(crmEnquiries.id, id),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
eq(crmOpportunities.id, id),
eq(crmOpportunities.organizationId, organizationId),
isNull(crmOpportunities.deletedAt)
)
)
.limit(1);
if (!enquiry) {
throw new AuthError('Enquiry not found', 404);
if (!opportunity) {
throw new AuthError('Opportunity not found', 404);
}
return enquiry;
return opportunity;
}
async function assertQuotationBelongsToOrganization(
@@ -665,11 +665,11 @@ async function validateQuotationPayload(
await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId);
}
if (payload.enquiryId) {
const enquiry = await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId);
if (payload.opportunityId) {
const opportunity = await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId);
if (enquiry.customerId !== payload.customerId) {
throw new AuthError('Selected enquiry belongs to a different customer', 400);
if (opportunity.customerId !== payload.customerId) {
throw new AuthError('Selected opportunity belongs to a different customer', 400);
}
}
@@ -941,7 +941,7 @@ function buildQuotationFilters(
const quotationTypes = splitFilterValue(filters.quotationType);
const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer);
const enquiries = splitFilterValue(filters.enquiry);
const opportunities = splitFilterValue(filters.opportunity);
return [
eq(crmQuotations.organizationId, organizationId),
@@ -951,7 +951,7 @@ function buildQuotationFilters(
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []),
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
...(filters.search
@@ -1035,7 +1035,7 @@ export async function getQuotationReferenceData(
productTypes,
customers,
contacts,
enquiries,
opportunities,
salesmen
] = await Promise.all([
getUserBranches(),
@@ -1066,9 +1066,9 @@ export async function getQuotationReferenceData(
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt)))
.orderBy(desc(crmEnquiries.updatedAt)),
.from(crmOpportunities)
.where(and(eq(crmOpportunities.organizationId, organizationId), isNull(crmOpportunities.deletedAt)))
.orderBy(desc(crmOpportunities.updatedAt)),
db
.select({ id: users.id, name: users.name })
.from(memberships)
@@ -1103,15 +1103,15 @@ export async function getQuotationReferenceData(
mobile: contact.mobile,
isPrimary: contact.isPrimary
})),
enquiries: enquiries.map<QuotationEnquiryLookup>((enquiry) => ({
id: enquiry.id,
code: enquiry.code,
customerId: enquiry.customerId,
contactId: enquiry.contactId,
title: enquiry.title,
projectName: enquiry.projectName,
projectLocation: enquiry.projectLocation,
branchId: enquiry.branchId
opportunities: opportunities.map<QuotationOpportunityLookup>((opportunity) => ({
id: opportunity.id,
code: opportunity.code,
customerId: opportunity.customerId,
contactId: opportunity.contactId,
title: opportunity.title,
projectName: opportunity.projectName,
projectLocation: opportunity.projectLocation,
branchId: opportunity.branchId
})),
salesmen: salesmen.map<QuotationSalesmanLookup>((salesman) => ({
id: salesman.id,
@@ -1142,18 +1142,18 @@ export async function listQuotations(
const customerIds = [...new Set(rows.map((row) => row.customerId))];
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean))] as string[];
const enquiryIds = [...new Set(rows.map((row) => row.enquiryId).filter(Boolean))] as string[];
const opportunityIds = [...new Set(rows.map((row) => row.opportunityId).filter(Boolean))] as string[];
const quotationIds = rows.map((row) => row.id);
const [customers, contacts, enquiries, itemCounts] = await Promise.all([
const [customers, contacts, opportunities, itemCounts] = await Promise.all([
customerIds.length
? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
: [],
contactIds.length
? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds))
: [],
enquiryIds.length
? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds))
opportunityIds.length
? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds))
: [],
quotationIds.length
? db
@@ -1172,7 +1172,7 @@ export async function listQuotations(
const customerMap = new Map(customers.map((item) => [item.id, item]));
const contactMap = new Map(contacts.map((item) => [item.id, item]));
const enquiryMap = new Map(enquiries.map((item) => [item.id, item]));
const opportunityMap = new Map(opportunities.map((item) => [item.id, item]));
const itemCountMap = new Map(itemCounts.map((item) => [item.quotationId, item.value]));
return {
@@ -1180,7 +1180,7 @@ export async function listQuotations(
...mapQuotationRecord(row),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
contactName: row.contactId ? (contactMap.get(row.contactId)?.name ?? null) : null,
enquiryCode: row.enquiryId ? (enquiryMap.get(row.enquiryId)?.code ?? null) : null,
opportunityCode: row.opportunityId ? (opportunityMap.get(row.opportunityId)?.code ?? null) : null,
itemCount: itemCountMap.get(row.id) ?? 0,
revisionLabel: toRevisionLabel(row.revision)
})),
@@ -1289,13 +1289,13 @@ export async function createQuotation(
payload.status
);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
branchId: payload.branchId ?? enquiry?.branchId ?? ''
branchId: payload.branchId ?? opportunity?.branchId ?? ''
});
return db.transaction(async (tx) => {
@@ -1304,16 +1304,16 @@ export async function createQuotation(
.values({
id: crypto.randomUUID(),
organizationId,
branchId: payload.branchId ?? enquiry?.branchId ?? null,
branchId: payload.branchId ?? opportunity?.branchId ?? null,
code: documentCode.code,
enquiryId: payload.enquiryId ?? null,
opportunityId: payload.opportunityId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
contactId: payload.contactId ?? opportunity?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
projectName: payload.projectName?.trim() || opportunity?.projectName || null,
projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
@@ -1325,9 +1325,9 @@ export async function createQuotation(
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? opportunity?.isHotProject ?? false,
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
salesmanId: payload.salesmanId ?? null,
isSent: false,
sentVia: payload.sentVia ?? null,
@@ -1350,9 +1350,9 @@ export async function createQuotation(
payload.projectParties ?? []
);
if (created.enquiryId) {
await syncEnquiryPipelineStageFromQuotationStatus(
created.enquiryId,
if (created.opportunityId) {
await syncOpportunityPipelineStageFromQuotationStatus(
created.opportunityId,
organizationId,
quotationStatusCode
);
@@ -1376,23 +1376,23 @@ export async function updateQuotation(
payload.status
);
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(crmQuotations)
.set({
branchId: payload.branchId ?? enquiry?.branchId ?? null,
enquiryId: payload.enquiryId ?? null,
branchId: payload.branchId ?? opportunity?.branchId ?? null,
opportunityId: payload.opportunityId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
contactId: payload.contactId ?? opportunity?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
projectName: payload.projectName?.trim() || opportunity?.projectName || null,
projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
@@ -1403,9 +1403,9 @@ export async function updateQuotation(
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
salesmanId: payload.salesmanId ?? null,
sentVia: payload.sentVia ?? null,
isActive: payload.isActive ?? true,
@@ -1423,9 +1423,9 @@ export async function updateQuotation(
payload.projectParties ?? []
);
if (row.enquiryId) {
await syncEnquiryPipelineStageFromQuotationStatus(
row.enquiryId,
if (row.opportunityId) {
await syncOpportunityPipelineStageFromQuotationStatus(
row.opportunityId,
organizationId,
quotationStatusCode
);
@@ -2160,7 +2160,7 @@ export async function createQuotationRevision(
organizationId,
branchId: parent.branchId,
code: documentCode.code,
enquiryId: parent.enquiryId,
opportunityId: parent.opportunityId,
customerId: parent.customerId,
contactId: parent.contactId,
quotationDate: new Date(),
@@ -2291,8 +2291,8 @@ export async function listQuotationRevisions(id: string, organizationId: string)
}));
}
export async function listEnquiryQuotationRelations(
enquiryId: string,
export async function listOpportunityQuotationRelations(
opportunityId: string,
organizationId: string
): Promise<QuotationRelationItem[]> {
const rows = await db
@@ -2301,7 +2301,7 @@ export async function listEnquiryQuotationRelations(
.where(
and(
eq(crmQuotations.organizationId, organizationId),
eq(crmQuotations.enquiryId, enquiryId),
eq(crmQuotations.opportunityId, opportunityId),
isNull(crmQuotations.deletedAt)
)
)
@@ -2345,3 +2345,4 @@ export async function listCustomerQuotationRelations(
updatedAt: row.updatedAt.toISOString()
}));
}