taks-d.2.1
This commit is contained in:
@@ -147,7 +147,10 @@ export interface QuotationCustomerRecord {
|
||||
quotationId: string;
|
||||
customerId: string;
|
||||
role: string;
|
||||
roleCode: string;
|
||||
roleLabel: string | null;
|
||||
isPrimary: boolean;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@@ -244,7 +247,7 @@ export interface QuotationReferenceData {
|
||||
discountTypes: QuotationOption[];
|
||||
units: QuotationOption[];
|
||||
sentVias: QuotationOption[];
|
||||
customerRoles: QuotationOption[];
|
||||
projectPartyRoles: QuotationOption[];
|
||||
topicTypes: QuotationOption[];
|
||||
followupTypes: QuotationOption[];
|
||||
productTypes: QuotationOption[];
|
||||
@@ -353,6 +356,7 @@ export interface QuotationMutationPayload {
|
||||
isActive?: boolean;
|
||||
sentVia?: string | null;
|
||||
revisionRemark?: string | null;
|
||||
projectParties?: QuotationCustomerMutationPayload[];
|
||||
}
|
||||
|
||||
export interface QuotationItemMutationPayload {
|
||||
@@ -372,6 +376,7 @@ export interface QuotationCustomerMutationPayload {
|
||||
customerId: string;
|
||||
role: string;
|
||||
isPrimary?: boolean;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface QuotationTopicMutationPayload {
|
||||
|
||||
@@ -268,16 +268,18 @@ function CustomerDialog({
|
||||
const [customerId, setCustomerId] = useState(
|
||||
relation?.customerId ?? referenceData.customers[0]?.id ?? ''
|
||||
);
|
||||
const [role, setRole] = useState(relation?.role ?? 'owner');
|
||||
const [isPrimary, setIsPrimary] = useState(relation?.isPrimary ?? false);
|
||||
const [role, setRole] = useState(
|
||||
relation?.role ?? referenceData.projectPartyRoles[0]?.id ?? ''
|
||||
);
|
||||
const [remark, setRemark] = useState(relation?.remark ?? '');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{relation ? 'Edit Related Customer' : 'Add Related Customer'}</DialogTitle>
|
||||
<DialogTitle>{relation ? 'Edit Project Party' : 'Add Project Party'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Keep owner, consultant, contractor, and billing parties visible on the quote.
|
||||
Keep related companies visible with their role in this quotation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4'>
|
||||
@@ -298,22 +300,14 @@ function CustomerDialog({
|
||||
<SelectValue placeholder='Role' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.customerRoles.map((item) => (
|
||||
{referenceData.projectPartyRoles.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className='flex items-center justify-between rounded-lg border px-4 py-3 text-sm'>
|
||||
<span>Primary relation</span>
|
||||
<input
|
||||
aria-label='Primary relation'
|
||||
type='checkbox'
|
||||
checked={isPrimary}
|
||||
onChange={(e) => setIsPrimary(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<Input value={remark} onChange={(event) => setRemark(event.target.value)} placeholder='Remark' />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
@@ -322,7 +316,7 @@ function CustomerDialog({
|
||||
<Button
|
||||
isLoading={pending}
|
||||
onClick={async () => {
|
||||
await onSubmit({ customerId, role, isPrimary });
|
||||
await onSubmit({ customerId, role, remark: remark || null });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
@@ -720,24 +714,24 @@ export function QuotationDetail({
|
||||
const [deleteCustomerId, setDeleteCustomerId] = useState<string | null>(null);
|
||||
const createCustomer = useMutation({
|
||||
...createQuotationCustomerMutation,
|
||||
onSuccess: () => toast.success('Related customer saved'),
|
||||
onSuccess: () => toast.success('Project party saved'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save customer')
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save project party')
|
||||
});
|
||||
const updateCustomer = useMutation({
|
||||
...updateQuotationCustomerMutation,
|
||||
onSuccess: () => toast.success('Related customer updated'),
|
||||
onSuccess: () => toast.success('Project party updated'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update customer')
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update project party')
|
||||
});
|
||||
const deleteCustomer = useMutation({
|
||||
...deleteQuotationCustomerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Related customer deleted');
|
||||
toast.success('Project party deleted');
|
||||
setDeleteCustomerId(null);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete customer')
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete project party')
|
||||
});
|
||||
|
||||
const [topicOpen, setTopicOpen] = useState(false);
|
||||
@@ -1137,7 +1131,7 @@ export function QuotationDetail({
|
||||
<TabsList className='flex flex-wrap'>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='items'>Items</TabsTrigger>
|
||||
<TabsTrigger value='customers'>Customers</TabsTrigger>
|
||||
<TabsTrigger value='customers'>Project Parties</TabsTrigger>
|
||||
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
||||
@@ -1155,7 +1149,7 @@ export function QuotationDetail({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Customer' value={customer?.name} />
|
||||
<FieldItem label='Billing Customer' value={customer?.name} />
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
@@ -1188,6 +1182,28 @@ export function QuotationDetail({
|
||||
quotation.chancePercent !== null ? `${quotation.chancePercent}%` : null
|
||||
}
|
||||
/>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='text-muted-foreground text-xs'>Project Parties</div>
|
||||
{!customersData.items.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
No project parties have been added yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{customersData.items.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-4 text-sm'>
|
||||
<div className='font-medium'>{item.customerName}</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Role: {item.roleLabel ?? item.roleCode}
|
||||
</div>
|
||||
{item.remark ? <div className='mt-1'>{item.remark}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
@@ -1285,8 +1301,10 @@ export function QuotationDetail({
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle>Customers</CardTitle>
|
||||
<CardDescription>All parties involved in this quotation.</CardDescription>
|
||||
<CardTitle>Project Parties</CardTitle>
|
||||
<CardDescription>
|
||||
Related companies for this quotation, each with a visible role.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canManageCustomers ? (
|
||||
<Button
|
||||
@@ -1295,13 +1313,13 @@ export function QuotationDetail({
|
||||
setCustomerOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Customer
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Party
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{!customersData.items.length ? (
|
||||
<EmptyState message='No additional customer relations yet.' />
|
||||
<EmptyState message='No project parties have been added yet.' />
|
||||
) : (
|
||||
customersData.items.map((item) => (
|
||||
<div
|
||||
@@ -1311,9 +1329,11 @@ export function QuotationDetail({
|
||||
<div>
|
||||
<div className='font-medium'>{item.customerName}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{item.role}
|
||||
{item.isPrimary ? ' - Primary' : ''}
|
||||
Role: {item.roleLabel ?? item.roleCode}
|
||||
</div>
|
||||
{item.remark ? (
|
||||
<div className='text-muted-foreground mt-1 text-sm'>{item.remark}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{canManageCustomers ? (
|
||||
<div className='flex gap-2'>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
||||
<CardDescription>Bill-to and attention context for the document.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||
<Field label='Customer' value={documentData.customer.name} />
|
||||
<Field label='Billing Customer' value={documentData.customer.name} />
|
||||
<Field label='Contact' value={documentData.contact?.name} />
|
||||
<Field label='Phone' value={documentData.customer.phone} />
|
||||
<Field label='Email' value={documentData.customer.email} />
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ProjectPartiesEditor,
|
||||
type ProjectPartyEditorItem
|
||||
} from '@/features/crm/components/project-parties-editor';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { quotationCustomersOptions } from '../api/queries';
|
||||
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
||||
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
||||
|
||||
@@ -123,6 +128,11 @@ export function QuotationFormSheet({
|
||||
const isEdit = !!quotation;
|
||||
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
|
||||
const [state, setState] = useState<FormState>(defaultState);
|
||||
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||
const projectPartiesQuery = useQuery({
|
||||
...quotationCustomersOptions(quotation?.id ?? ''),
|
||||
enabled: open && !!quotation?.id
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createQuotationMutation,
|
||||
@@ -147,8 +157,18 @@ export function QuotationFormSheet({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setState(defaultState);
|
||||
setProjectParties(
|
||||
quotation
|
||||
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
|
||||
key: item.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark ?? ''
|
||||
}))
|
||||
: []
|
||||
);
|
||||
}
|
||||
}, [defaultState, open]);
|
||||
}, [defaultState, open, projectPartiesQuery.data?.items, quotation]);
|
||||
|
||||
const contacts = useMemo(
|
||||
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
|
||||
@@ -200,7 +220,14 @@ export function QuotationFormSheet({
|
||||
taxRate: state.taxRate ? Number(state.taxRate) : 0,
|
||||
sentVia: state.sentVia || null,
|
||||
revisionRemark: state.revisionRemark || null,
|
||||
isActive: state.isActive
|
||||
isActive: state.isActive,
|
||||
projectParties: projectParties
|
||||
.filter((item) => item.customerId && item.role)
|
||||
.map((item) => ({
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark || null
|
||||
}))
|
||||
};
|
||||
|
||||
if (isEdit && quotation) {
|
||||
@@ -219,7 +246,7 @@ export function QuotationFormSheet({
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture quotation header data, customer linkage, and commercial context.
|
||||
Capture quotation header data, billing customer, project parties, and commercial context.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -241,7 +268,7 @@ export function QuotationFormSheet({
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label='Customer'>
|
||||
<Field label='Billing Customer'>
|
||||
<Select
|
||||
value={state.customerId}
|
||||
onValueChange={(value) => {
|
||||
@@ -434,6 +461,15 @@ export function QuotationFormSheet({
|
||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||
</Field>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<ProjectPartiesEditor
|
||||
customers={referenceData.customers}
|
||||
roles={referenceData.projectPartyRoles}
|
||||
items={projectParties}
|
||||
onChange={setProjectParties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='md:col-span-2'>
|
||||
<Field label='Notes'>
|
||||
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />
|
||||
|
||||
@@ -34,7 +34,7 @@ const DOCUMENT_OPTION_CATEGORIES = {
|
||||
currency: 'crm_currency',
|
||||
discountType: 'crm_discount_type',
|
||||
unit: 'crm_unit',
|
||||
customerRole: 'crm_quotation_customer_role',
|
||||
customerRole: 'crm_project_party_role',
|
||||
productType: 'crm_product_type',
|
||||
topicType: 'crm_quotation_topic_type'
|
||||
} as const;
|
||||
@@ -348,8 +348,8 @@ export async function buildQuotationDocumentData(
|
||||
customerId: item.customerId,
|
||||
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
|
||||
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
|
||||
roleCode: item.role,
|
||||
roleLabel: findOptionLabel(customerRoleOptions, item.role),
|
||||
roleCode: item.roleCode,
|
||||
roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role),
|
||||
isPrimary: item.isPrimary
|
||||
})),
|
||||
topics: {
|
||||
|
||||
@@ -67,7 +67,17 @@ export const quotationSchema = z.object({
|
||||
taxRate: coerceOptionalNumber('Tax rate must be a number'),
|
||||
isActive: z.boolean().default(true),
|
||||
sentVia: z.string().optional().nullable(),
|
||||
revisionRemark: z.string().optional().nullable()
|
||||
revisionRemark: z.string().optional().nullable(),
|
||||
projectParties: z
|
||||
.array(
|
||||
z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
role: z.string().min(1, 'Please select a role'),
|
||||
isPrimary: z.boolean().default(false).optional(),
|
||||
remark: z.string().nullable().optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const quotationItemSchema = z.object({
|
||||
@@ -98,7 +108,8 @@ export const quotationItemSchema = z.object({
|
||||
export const quotationCustomerSchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
role: z.string().min(1, 'Please select a role'),
|
||||
isPrimary: z.boolean().default(false)
|
||||
isPrimary: z.boolean().default(false),
|
||||
remark: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const quotationTopicSchema = z.object({
|
||||
|
||||
@@ -48,6 +48,11 @@ import type {
|
||||
QuotationTopicMutationPayload,
|
||||
QuotationTopicRecord
|
||||
} from '../api/types';
|
||||
import {
|
||||
buildProjectPartyKey,
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
|
||||
const QUOTATION_OPTION_CATEGORIES = {
|
||||
status: 'crm_quotation_status',
|
||||
@@ -56,7 +61,7 @@ const QUOTATION_OPTION_CATEGORIES = {
|
||||
discountType: 'crm_discount_type',
|
||||
unit: 'crm_unit',
|
||||
sentVia: 'crm_sent_via',
|
||||
customerRole: 'crm_quotation_customer_role',
|
||||
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY,
|
||||
topicType: 'crm_quotation_topic_type',
|
||||
followupType: 'crm_followup_type',
|
||||
productType: 'crm_product_type'
|
||||
@@ -191,15 +196,19 @@ function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): Quo
|
||||
}
|
||||
|
||||
function mapQuotationCustomerRecord(
|
||||
row: typeof crmQuotationCustomers.$inferSelect
|
||||
row: typeof crmQuotationCustomers.$inferSelect,
|
||||
options: { role: { id: string; code: string; label: string } }
|
||||
): QuotationCustomerRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
quotationId: row.quotationId,
|
||||
customerId: row.customerId,
|
||||
role: row.role,
|
||||
role: options.role.id,
|
||||
roleCode: options.role.code,
|
||||
roleLabel: options.role.label,
|
||||
isPrimary: row.isPrimary,
|
||||
remark: row.remark,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
@@ -641,6 +650,15 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
||||
QUOTATION_OPTION_CATEGORIES.sentVia,
|
||||
payload.sentVia ?? null
|
||||
);
|
||||
|
||||
for (const projectParty of payload.projectParties ?? []) {
|
||||
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
|
||||
projectParty.role
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateQuotationItemPayload(
|
||||
@@ -671,11 +689,156 @@ async function validateQuotationCustomerPayload(
|
||||
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.customerRole,
|
||||
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
|
||||
payload.role
|
||||
);
|
||||
}
|
||||
|
||||
async function buildPreparedQuotationProjectParties(
|
||||
organizationId: string,
|
||||
billingCustomerId: string,
|
||||
projectParties: QuotationCustomerMutationPayload[] = []
|
||||
) {
|
||||
const roleOptions = await getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, {
|
||||
organizationId
|
||||
});
|
||||
const billingRole = roleOptions.find((option) => option.code === 'billing_customer');
|
||||
|
||||
if (!billingRole) {
|
||||
throw new AuthError('Billing customer project-party role is not configured', 409);
|
||||
}
|
||||
|
||||
const prepared = new Map<
|
||||
string,
|
||||
{
|
||||
customerId: string;
|
||||
roleId: string;
|
||||
roleCode: string;
|
||||
isPrimary: boolean;
|
||||
remark: string | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const item of projectParties) {
|
||||
const resolvedRole = resolveProjectPartyRole(roleOptions, item.role);
|
||||
|
||||
if (!resolvedRole) {
|
||||
throw new AuthError('Invalid project party role', 400);
|
||||
}
|
||||
|
||||
const key = buildProjectPartyKey(item.customerId, resolvedRole.code);
|
||||
|
||||
if (!prepared.has(key)) {
|
||||
prepared.set(key, {
|
||||
customerId: item.customerId,
|
||||
roleId: resolvedRole.id,
|
||||
roleCode: resolvedRole.code,
|
||||
isPrimary: resolvedRole.code === 'billing_customer' || !!item.isPrimary,
|
||||
remark: item.remark?.trim() || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const billingKey = buildProjectPartyKey(billingCustomerId, billingRole.code);
|
||||
|
||||
if (!prepared.has(billingKey)) {
|
||||
prepared.set(billingKey, {
|
||||
customerId: billingCustomerId,
|
||||
roleId: billingRole.id,
|
||||
roleCode: billingRole.code,
|
||||
isPrimary: true,
|
||||
remark: null
|
||||
});
|
||||
}
|
||||
|
||||
return [...prepared.values()].map((item) => ({
|
||||
...item,
|
||||
isPrimary: item.roleCode === 'billing_customer'
|
||||
}));
|
||||
}
|
||||
|
||||
async function syncQuotationProjectParties(
|
||||
tx: Pick<typeof db, 'insert' | 'update'>,
|
||||
organizationId: string,
|
||||
quotationId: string,
|
||||
billingCustomerId: string,
|
||||
payload: QuotationCustomerMutationPayload[]
|
||||
) {
|
||||
const now = new Date();
|
||||
const preparedParties = await buildPreparedQuotationProjectParties(
|
||||
organizationId,
|
||||
billingCustomerId,
|
||||
payload
|
||||
);
|
||||
|
||||
await tx
|
||||
.update(crmQuotationCustomers)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationCustomers.quotationId, quotationId),
|
||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||
isNull(crmQuotationCustomers.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
if (!preparedParties.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.insert(crmQuotationCustomers).values(
|
||||
preparedParties.map((item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
quotationId,
|
||||
customerId: item.customerId,
|
||||
role: item.roleId,
|
||||
isPrimary: item.isPrimary,
|
||||
remark: item.remark
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function assertQuotationProjectPartyNotDuplicated(
|
||||
quotationId: string,
|
||||
organizationId: string,
|
||||
payload: QuotationCustomerMutationPayload,
|
||||
excludeRelationId?: string
|
||||
) {
|
||||
const [relations, roleOptions] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmQuotationCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationCustomers.quotationId, quotationId),
|
||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||
isNull(crmQuotationCustomers.deletedAt)
|
||||
)
|
||||
),
|
||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
|
||||
]);
|
||||
const incomingRole = resolveProjectPartyRole(roleOptions, payload.role);
|
||||
|
||||
if (!incomingRole) {
|
||||
throw new AuthError('Invalid project party role', 400);
|
||||
}
|
||||
|
||||
const duplicate = relations.find((relation) => {
|
||||
if (excludeRelationId && relation.id === excludeRelationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentRole = resolveProjectPartyRole(roleOptions, relation.role);
|
||||
|
||||
return relation.customerId === payload.customerId && currentRole?.code === incomingRole.code;
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
throw new AuthError('This project party already exists for the quotation', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateQuotationTopicPayload(
|
||||
organizationId: string,
|
||||
payload: QuotationTopicMutationPayload
|
||||
@@ -801,7 +964,7 @@ export async function getQuotationReferenceData(
|
||||
discountTypes,
|
||||
units,
|
||||
sentVias,
|
||||
customerRoles,
|
||||
projectPartyRoles,
|
||||
topicTypes,
|
||||
followupTypes,
|
||||
productTypes,
|
||||
@@ -817,7 +980,7 @@ export async function getQuotationReferenceData(
|
||||
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.projectPartyRole, { organizationId }),
|
||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
|
||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
@@ -857,7 +1020,7 @@ export async function getQuotationReferenceData(
|
||||
discountTypes: discountTypes.map(mapOption),
|
||||
units: units.map(mapOption),
|
||||
sentVias: sentVias.map(mapOption),
|
||||
customerRoles: customerRoles.map(mapOption),
|
||||
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||
topicTypes: topicTypes.map(mapOption),
|
||||
followupTypes: followupTypes.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
@@ -1062,59 +1225,60 @@ export async function createQuotation(
|
||||
branchId: payload.branchId ?? enquiry?.branchId ?? ''
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmQuotations)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
return db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(crmQuotations)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
||||
code: documentCode.code,
|
||||
enquiryId: payload.enquiryId ?? null,
|
||||
customerId: payload.customerId,
|
||||
contactId: payload.contactId ?? enquiry?.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,
|
||||
attention: payload.attention?.trim() || null,
|
||||
reference: payload.reference?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
status: payload.status,
|
||||
revision: 0,
|
||||
revisionRemark: payload.revisionRemark?.trim() || null,
|
||||
currency: payload.currency,
|
||||
exchangeRate: payload.exchangeRate ?? 1,
|
||||
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,
|
||||
salesmanId: payload.salesmanId ?? null,
|
||||
isSent: false,
|
||||
sentVia: payload.sentVia ?? null,
|
||||
approvedAt: null,
|
||||
approvedArtifactId: null,
|
||||
approvedPdfUrl: null,
|
||||
approvedSnapshot: null,
|
||||
approvedTemplateVersionId: null,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await syncQuotationProjectParties(
|
||||
tx,
|
||||
organizationId,
|
||||
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
||||
code: documentCode.code,
|
||||
enquiryId: payload.enquiryId ?? null,
|
||||
customerId: payload.customerId,
|
||||
contactId: payload.contactId ?? enquiry?.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,
|
||||
attention: payload.attention?.trim() || null,
|
||||
reference: payload.reference?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
status: payload.status,
|
||||
revision: 0,
|
||||
revisionRemark: payload.revisionRemark?.trim() || null,
|
||||
currency: payload.currency,
|
||||
exchangeRate: payload.exchangeRate ?? 1,
|
||||
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,
|
||||
salesmanId: payload.salesmanId ?? null,
|
||||
isSent: false,
|
||||
sentVia: payload.sentVia ?? null,
|
||||
approvedAt: null,
|
||||
approvedArtifactId: null,
|
||||
approvedPdfUrl: null,
|
||||
approvedSnapshot: null,
|
||||
approvedTemplateVersionId: null,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
created.id,
|
||||
payload.customerId,
|
||||
payload.projectParties ?? []
|
||||
);
|
||||
|
||||
await db.insert(crmQuotationCustomers).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
quotationId: created.id,
|
||||
customerId: created.customerId,
|
||||
role: 'owner',
|
||||
isPrimary: true
|
||||
return created;
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function updateQuotation(
|
||||
@@ -1129,39 +1293,51 @@ export async function updateQuotation(
|
||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||
: null;
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmQuotations)
|
||||
.set({
|
||||
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
||||
enquiryId: payload.enquiryId ?? null,
|
||||
customerId: payload.customerId,
|
||||
contactId: payload.contactId ?? enquiry?.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,
|
||||
attention: payload.attention?.trim() || null,
|
||||
reference: payload.reference?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
status: payload.status,
|
||||
revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
|
||||
currency: payload.currency,
|
||||
exchangeRate: payload.exchangeRate ?? 1,
|
||||
discount: payload.discount ?? 0,
|
||||
discountType: payload.discountType ?? null,
|
||||
taxRate: payload.taxRate ?? 0,
|
||||
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
|
||||
salesmanId: payload.salesmanId ?? null,
|
||||
sentVia: payload.sentVia ?? null,
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmQuotations.id, id))
|
||||
.returning();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(crmQuotations)
|
||||
.set({
|
||||
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
||||
enquiryId: payload.enquiryId ?? null,
|
||||
customerId: payload.customerId,
|
||||
contactId: payload.contactId ?? enquiry?.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,
|
||||
attention: payload.attention?.trim() || null,
|
||||
reference: payload.reference?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
status: payload.status,
|
||||
revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
|
||||
currency: payload.currency,
|
||||
exchangeRate: payload.exchangeRate ?? 1,
|
||||
discount: payload.discount ?? 0,
|
||||
discountType: payload.discountType ?? null,
|
||||
taxRate: payload.taxRate ?? 0,
|
||||
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
|
||||
salesmanId: payload.salesmanId ?? null,
|
||||
sentVia: payload.sentVia ?? null,
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmQuotations.id, id))
|
||||
.returning();
|
||||
|
||||
await syncQuotationProjectParties(
|
||||
tx,
|
||||
organizationId,
|
||||
id,
|
||||
payload.customerId,
|
||||
payload.projectParties ?? []
|
||||
);
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
await refreshQuotationTotals(id, organizationId, userId);
|
||||
return updated;
|
||||
@@ -1341,28 +1517,41 @@ export async function softDeleteQuotationItem(
|
||||
|
||||
export async function listQuotationCustomers(id: string, organizationId: string) {
|
||||
await assertQuotationBelongsToOrganization(id, organizationId);
|
||||
const relations = await db
|
||||
.select()
|
||||
.from(crmQuotationCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationCustomers.quotationId, id),
|
||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||
isNull(crmQuotationCustomers.deletedAt)
|
||||
const [relations, roleOptions] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmQuotationCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationCustomers.quotationId, id),
|
||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||
isNull(crmQuotationCustomers.deletedAt)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.role));
|
||||
.orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.createdAt)),
|
||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
|
||||
]);
|
||||
const customerIds = [...new Set(relations.map((row) => row.customerId))];
|
||||
const customers = customerIds.length
|
||||
? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
|
||||
: [];
|
||||
const customerMap = new Map(customers.map((item) => [item.id, item]));
|
||||
|
||||
return relations.map<QuotationCustomerListItem>((row) => ({
|
||||
...mapQuotationCustomerRecord(row),
|
||||
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
|
||||
customerCode: customerMap.get(row.customerId)?.code ?? '-'
|
||||
}));
|
||||
return relations
|
||||
.map((row) => {
|
||||
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
|
||||
|
||||
if (!resolvedRole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...mapQuotationCustomerRecord(row, { role: resolvedRole }),
|
||||
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
|
||||
customerCode: customerMap.get(row.customerId)?.code ?? '-'
|
||||
} satisfies QuotationCustomerListItem;
|
||||
})
|
||||
.filter((item): item is QuotationCustomerListItem => item !== null);
|
||||
}
|
||||
|
||||
export async function createQuotationCustomer(
|
||||
@@ -1372,9 +1561,22 @@ export async function createQuotationCustomer(
|
||||
) {
|
||||
await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
||||
await validateQuotationCustomerPayload(organizationId, payload);
|
||||
await assertQuotationProjectPartyNotDuplicated(quotationId, organizationId, payload);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (payload.isPrimary) {
|
||||
const roleOptions = await getActiveOptionsByCategory(
|
||||
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
|
||||
{ organizationId }
|
||||
);
|
||||
const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role);
|
||||
|
||||
if (!resolvedRole) {
|
||||
throw new AuthError('Invalid project party role', 400);
|
||||
}
|
||||
|
||||
const isPrimary = resolvedRole.code === 'billing_customer';
|
||||
|
||||
if (isPrimary) {
|
||||
await tx
|
||||
.update(crmQuotationCustomers)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
@@ -1394,8 +1596,9 @@ export async function createQuotationCustomer(
|
||||
organizationId,
|
||||
quotationId,
|
||||
customerId: payload.customerId,
|
||||
role: payload.role,
|
||||
isPrimary: payload.isPrimary ?? false
|
||||
role: resolvedRole.id,
|
||||
isPrimary,
|
||||
remark: payload.remark?.trim() || null
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -1411,9 +1614,27 @@ export async function updateQuotationCustomer(
|
||||
) {
|
||||
await validateQuotationCustomerPayload(organizationId, payload);
|
||||
await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);
|
||||
await assertQuotationProjectPartyNotDuplicated(
|
||||
quotationId,
|
||||
organizationId,
|
||||
payload,
|
||||
relationId
|
||||
);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (payload.isPrimary) {
|
||||
const roleOptions = await getActiveOptionsByCategory(
|
||||
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
|
||||
{ organizationId }
|
||||
);
|
||||
const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role);
|
||||
|
||||
if (!resolvedRole) {
|
||||
throw new AuthError('Invalid project party role', 400);
|
||||
}
|
||||
|
||||
const isPrimary = resolvedRole.code === 'billing_customer';
|
||||
|
||||
if (isPrimary) {
|
||||
await tx
|
||||
.update(crmQuotationCustomers)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
@@ -1430,8 +1651,9 @@ export async function updateQuotationCustomer(
|
||||
.update(crmQuotationCustomers)
|
||||
.set({
|
||||
customerId: payload.customerId,
|
||||
role: payload.role,
|
||||
isPrimary: payload.isPrimary ?? false,
|
||||
role: resolvedRole.id,
|
||||
isPrimary,
|
||||
remark: payload.remark?.trim() || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmQuotationCustomers.id, relationId))
|
||||
@@ -1908,7 +2130,8 @@ export async function createQuotationRevision(
|
||||
quotationId: created.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
isPrimary: item.isPrimary
|
||||
isPrimary: item.isPrimary,
|
||||
remark: item.remark
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user