This commit is contained in:
phaichayon
2026-06-16 08:43:19 +07:00
parent eae0dee1f2
commit b8f13e36b3
12 changed files with 547 additions and 21 deletions

View File

@@ -122,9 +122,11 @@ const FOUNDATION_OPTIONS = {
{ code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 }
],
crm_quotation_type: [
{ code: 'standard', label: 'Standard', value: 'standard', sortOrder: 1 },
{ code: 'budgetary', label: 'Budgetary', value: 'budgetary', sortOrder: 2 },
{ code: 'service', label: 'Service', value: 'service', sortOrder: 3 }
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
{ code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 },
{ code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 },
{ code: 'service', label: 'Service', value: 'service', sortOrder: 4 },
{ code: 'other', label: 'Other', value: 'other', sortOrder: 5 }
],
crm_product_type: [
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
@@ -137,10 +139,27 @@ const FOUNDATION_OPTIONS = {
{ code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 }
],
crm_discount_type: [
{ code: 'amount', label: 'Amount', value: 'amount', sortOrder: 1 },
{ code: 'percent', label: 'Percent', value: 'percent', sortOrder: 2 }
{ code: 'fixed', label: 'Fixed', value: 'fixed', sortOrder: 1 },
{ code: 'percentage', label: 'Percentage', value: 'percentage', sortOrder: 2 }
],
crm_topic_type: [
crm_unit: [
{ code: 'pcs', label: 'PCS', value: 'pcs', sortOrder: 1 },
{ code: 'set', label: 'Set', value: 'set', sortOrder: 2 },
{ code: 'lot', label: 'Lot', value: 'lot', sortOrder: 3 },
{ code: 'job', label: 'Job', value: 'job', sortOrder: 4 }
],
crm_sent_via: [
{ code: 'email', label: 'Email', value: 'email', sortOrder: 1 },
{ code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 },
{ code: 'system', label: 'System', value: 'system', sortOrder: 3 }
],
crm_quotation_customer_role: [
{ code: 'owner', label: 'Owner', value: 'owner', sortOrder: 1 },
{ code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 2 },
{ code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 3 },
{ code: 'billing', label: 'Billing', value: 'billing', sortOrder: 4 }
],
crm_quotation_topic_type: [
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },
{ code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 },
{ code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 }

View File

@@ -15,8 +15,6 @@ import { getActiveOptionsByCategory } from '@/features/foundation/master-options
import type {
EnquiryActivityRecord,
EnquiryBranchOption,
EnquiryContactLookup,
EnquiryCustomerLookup,
EnquiryCustomerRelationItem,
EnquiryFilters,
EnquiryFollowupMutationPayload,

View File

@@ -220,6 +220,9 @@ export interface QuotationReferenceData {
quotationTypes: QuotationOption[];
currencies: QuotationOption[];
discountTypes: QuotationOption[];
units: QuotationOption[];
sentVias: QuotationOption[];
customerRoles: QuotationOption[];
topicTypes: QuotationOption[];
followupTypes: QuotationOption[];
productTypes: QuotationOption[];

View File

@@ -40,6 +40,7 @@ import {
deleteQuotationFollowupMutation,
deleteQuotationItemMutation,
deleteQuotationTopicMutation,
updateQuotationMutation,
updateQuotationAttachmentMutation,
updateQuotationCustomerMutation,
updateQuotationFollowupMutation,
@@ -134,7 +135,15 @@ function ItemDialog({
<Input value={values.description} onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))} placeholder='Description' />
<div className='grid gap-4 md:grid-cols-2'>
<Input value={values.quantity} onChange={(e) => setValues((s) => ({ ...s, quantity: e.target.value }))} type='number' placeholder='Quantity' />
<Input value={values.unit} onChange={(e) => setValues((s) => ({ ...s, unit: e.target.value }))} placeholder='Unit' />
<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'>
<Input value={values.unitPrice} onChange={(e) => setValues((s) => ({ ...s, unitPrice: e.target.value }))} type='number' placeholder='Unit price' />
@@ -219,8 +228,8 @@ function CustomerDialog({
<Select value={role} onValueChange={setRole}>
<SelectTrigger><SelectValue placeholder='Role' /></SelectTrigger>
<SelectContent>
{['owner', 'consultant', 'contractor', 'billing'].map((item) => (
<SelectItem key={item} value={item}>{item}</SelectItem>
{referenceData.customerRoles.map((item) => (
<SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>
))}
</SelectContent>
</Select>
@@ -601,6 +610,12 @@ export function QuotationDetail({
onSuccess: () => toast.success('Revision created successfully'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision')
});
const submitForApproval = useMutation({
...updateQuotationMutation,
onSuccess: () => toast.success('Quotation moved to pending approval'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
});
const branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
@@ -637,6 +652,10 @@ export function QuotationDetail({
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 canSubmitApproval = ['draft', 'revised'].includes(status?.code ?? '');
return (
<div className='space-y-6'>
@@ -795,7 +814,49 @@ export function QuotationDetail({
</div>
</div>
<div className='flex flex-wrap gap-2'>
{canCreateRevision ? (
{canUpdate && canSubmitApproval ? (
<Button
variant='outline'
isLoading={submitForApproval.isPending}
onClick={() =>
submitForApproval.mutate({
id: quotationId,
values: {
enquiryId: quotation.enquiryId,
customerId: quotation.customerId,
contactId: quotation.contactId,
quotationDate: quotation.quotationDate,
validUntil: quotation.validUntil,
quotationType: quotation.quotationType,
projectName: quotation.projectName ?? '',
projectLocation: quotation.projectLocation ?? '',
attention: quotation.attention ?? '',
branchId: quotation.branchId,
currency: quotation.currency,
exchangeRate: quotation.exchangeRate,
status:
referenceData.statuses.find((item) => item.code === 'pending_approval')?.id ??
quotation.status,
chancePercent: quotation.chancePercent,
isHotProject: quotation.isHotProject,
competitor: quotation.competitor ?? '',
reference: quotation.reference ?? '',
notes: quotation.notes ?? '',
salesmanId: quotation.salesmanId,
discount: quotation.discount,
discountType: quotation.discountType,
taxRate: quotation.taxRate,
sentVia: quotation.sentVia,
revisionRemark: quotation.revisionRemark,
isActive: quotation.isActive
}
})
}
>
<Icons.send className='mr-2 h-4 w-4' /> Submit for approval
</Button>
) : null}
{canCreateRevision && canReviseByStatus ? (
<Button
variant='outline'
isLoading={createRevision.isPending}

View File

@@ -49,6 +49,7 @@ type FormState = {
discount: string;
discountType: string;
taxRate: string;
sentVia: string;
revisionRemark: string;
isHotProject: boolean;
isActive: boolean;
@@ -84,6 +85,7 @@ function toFormState(
discount: String(quotation?.discount ?? 0),
discountType: quotation?.discountType ?? '',
taxRate: String(quotation?.taxRate ?? 0),
sentVia: quotation?.sentVia ?? '',
revisionRemark: quotation?.revisionRemark ?? '',
isHotProject: quotation?.isHotProject ?? false,
isActive: quotation?.isActive ?? true
@@ -196,6 +198,7 @@ export function QuotationFormSheet({
discount: state.discount ? Number(state.discount) : 0,
discountType: state.discountType || null,
taxRate: state.taxRate ? Number(state.taxRate) : 0,
sentVia: state.sentVia || null,
revisionRemark: state.revisionRemark || null,
isActive: state.isActive
};
@@ -411,6 +414,22 @@ export function QuotationFormSheet({
<Input value={state.taxRate} onChange={(e) => setField('taxRate', e.target.value)} type='number' step='0.01' />
</Field>
<Field label='Sent Via'>
<Select value={state.sentVia || '__none__'} onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder='Select sent via' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Not sent yet</SelectItem>
{referenceData.sentVias.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Competitor'>
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
</Field>

View File

@@ -61,14 +61,17 @@ import type {
const QUOTATION_OPTION_CATEGORIES = {
status: 'crm_quotation_status',
quotationType: 'crm_quotation_type',
currency: 'currency',
currency: 'crm_currency',
discountType: 'crm_discount_type',
topicType: 'crm_topic_type',
unit: 'crm_unit',
sentVia: 'crm_sent_via',
customerRole: 'crm_quotation_customer_role',
topicType: 'crm_quotation_topic_type',
followupType: 'crm_followup_type',
productType: 'crm_product_type'
} as const;
const QUOTATION_CUSTOMER_ROLES = ['owner', 'consultant', 'contractor', 'billing'] as const;
const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);
function mapOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
@@ -291,7 +294,7 @@ function calculateDiscountAmount(baseAmount: number, discount = 0, discountType?
return 0;
}
if (discountType === 'percent') {
if (discountType === 'percent' || discountType === 'percentage') {
return (baseAmount * discount) / 100;
}
@@ -314,6 +317,28 @@ async function resolveValidOptionIds(category: string, organizationId: string) {
return new Set(options.map((option) => option.id));
}
async function resolveOptionCodeById(
organizationId: string,
category: string,
optionId?: string | null
) {
if (!optionId) {
return null;
}
const options = await getActiveOptionsByCategory(category, { organizationId });
return options.find((option) => option.id === optionId)?.code ?? null;
}
async function resolveOptionIdByCode(
organizationId: string,
category: string,
code: string
) {
const options = await getActiveOptionsByCategory(category, { organizationId });
return options.find((option) => option.code === code)?.id ?? null;
}
async function assertMasterOptionValue(
organizationId: string,
category: string,
@@ -591,6 +616,11 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
QUOTATION_OPTION_CATEGORIES.discountType,
payload.discountType ?? null
);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.sentVia,
payload.sentVia ?? null
);
}
async function validateQuotationItemPayload(
@@ -607,6 +637,11 @@ async function validateQuotationItemPayload(
QUOTATION_OPTION_CATEGORIES.discountType,
payload.discountType ?? null
);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.unit,
payload.unit ?? null
);
}
async function validateQuotationCustomerPayload(
@@ -614,10 +649,11 @@ async function validateQuotationCustomerPayload(
payload: QuotationCustomerMutationPayload
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (!QUOTATION_CUSTOMER_ROLES.includes(payload.role as (typeof QUOTATION_CUSTOMER_ROLES)[number])) {
throw new AuthError('Invalid quotation customer role', 400);
}
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.customerRole,
payload.role
);
}
async function validateQuotationTopicPayload(
@@ -739,6 +775,9 @@ export async function getQuotationReferenceData(
quotationTypes,
currencies,
discountTypes,
units,
sentVias,
customerRoles,
topicTypes,
followupTypes,
productTypes,
@@ -752,6 +791,9 @@ export async function getQuotationReferenceData(
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.quotationType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.currency, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
@@ -789,6 +831,9 @@ export async function getQuotationReferenceData(
quotationTypes: quotationTypes.map(mapOption),
currencies: currencies.map(mapOption),
discountTypes: discountTypes.map(mapOption),
units: units.map(mapOption),
sentVias: sentVias.map(mapOption),
customerRoles: customerRoles.map(mapOption),
topicTypes: topicTypes.map(mapOption),
followupTypes: followupTypes.map(mapOption),
productTypes: productTypes.map(mapOption),
@@ -1642,6 +1687,25 @@ export async function createQuotationRevision(
revisionRemark?: string
) {
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
const statusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
parent.status
);
if (!statusCode || !REVISION_ALLOWED_STATUS_CODES.has(statusCode)) {
throw new AuthError(
'Revision is allowed only for approved, sent, accepted, or rejected quotations',
400
);
}
const revisedStatusId =
(await resolveOptionIdByCode(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
'revised'
)) ?? parent.status;
const familyRootId = parent.parentQuotationId ?? parent.id;
const familyRows = await db
.select()
@@ -1683,7 +1747,7 @@ export async function createQuotationRevision(
attention: parent.attention,
reference: parent.reference,
notes: parent.notes,
status: parent.status,
status: revisedStatusId,
revision: nextRevision,
parentQuotationId: familyRootId,
revisionRemark: revisionRemark?.trim() || null,

View File

@@ -4,6 +4,12 @@ export const CRM_MASTER_OPTION_CATEGORIES = [
'crm_customer_type',
'crm_enquiry_status',
'crm_quotation_status',
'crm_quotation_type',
'crm_discount_type',
'crm_unit',
'crm_sent_via',
'crm_quotation_customer_role',
'crm_quotation_topic_type',
'crm_product_type',
'crm_currency',
'crm_payment_term',