task-e
This commit is contained in:
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user