taks-d.2.1

This commit is contained in:
phaichayon
2026-06-17 14:46:52 +07:00
parent 0a484e0b45
commit 5be6c54272
39 changed files with 6254 additions and 316 deletions

View File

@@ -0,0 +1,153 @@
'use client';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
export interface ProjectPartyEditorCustomer {
id: string;
code: string;
name: string;
}
export interface ProjectPartyEditorRole {
id: string;
code: string;
label: string;
}
export interface ProjectPartyEditorItem {
key: string;
customerId: string;
role: string;
remark: string;
}
export function createEmptyProjectPartyItem(
roles: ProjectPartyEditorRole[],
initial?: Partial<Omit<ProjectPartyEditorItem, 'key'>>
): ProjectPartyEditorItem {
return {
key: crypto.randomUUID(),
customerId: initial?.customerId ?? '',
role: initial?.role ?? roles[0]?.id ?? '',
remark: initial?.remark ?? ''
};
}
export function ProjectPartiesEditor({
customers,
roles,
items,
onChange
}: {
customers: ProjectPartyEditorCustomer[];
roles: ProjectPartyEditorRole[];
items: ProjectPartyEditorItem[];
onChange: (items: ProjectPartyEditorItem[]) => void;
}) {
function updateItem(
key: string,
field: keyof Omit<ProjectPartyEditorItem, 'key'>,
value: string
) {
onChange(
items.map((item) => (item.key === key ? { ...item, [field]: value } : item))
);
}
function removeItem(key: string) {
onChange(items.filter((item) => item.key !== key));
}
function addItem() {
onChange([...items, createEmptyProjectPartyItem(roles)]);
}
return (
<div className='space-y-3'>
<div className='flex items-center justify-between gap-3'>
<div>
<div className='text-sm font-medium'>Project Parties</div>
<p className='text-muted-foreground text-sm'>
Add related companies with their role and optional remark.
</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={addItem}>
<Icons.add className='mr-2 h-4 w-4' /> Add Party
</Button>
</div>
{!items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No project parties yet. Billing customer will still stay separate from this list.
</div>
) : null}
<div className='space-y-3'>
{items.map((item, index) => (
<div key={item.key} className='rounded-lg border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div className='text-sm font-medium'>Party {index + 1}</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeItem(item.key)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Remove
</Button>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<Select
value={item.customerId || '__none__'}
onValueChange={(value) =>
updateItem(item.key, 'customerId', value === '__none__' ? '' : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Customer' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Select customer</SelectItem>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name} ({customer.code})
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={item.role || '__none__'} onValueChange={(value) => updateItem(item.key, 'role', value === '__none__' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder='Role' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Select role</SelectItem>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={item.remark}
onChange={(event) => updateItem(item.key, 'remark', event.target.value)}
placeholder='Remark'
/>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -25,12 +25,20 @@ async function invalidateEnquiryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
}
async function invalidateEnquiryProjectParties(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.projectParties(id) });
}
async function invalidateEnquiryFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
}
export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([invalidateEnquiryLists(), invalidateEnquiryDetail(id)]);
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(id),
invalidateEnquiryProjectParties(id)
]);
}
export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) {
@@ -65,6 +73,7 @@ export const deleteEnquiryMutation = mutationOptions({
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
await invalidateEnquiryLists();
}

View File

@@ -1,5 +1,10 @@
import { queryOptions } from '@tanstack/react-query';
import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service';
import {
getEnquiries,
getEnquiryById,
getEnquiryFollowups,
getEnquiryProjectParties
} from './service';
import type { EnquiryFilters } from './types';
export const enquiryKeys = {
@@ -8,6 +13,8 @@ export const enquiryKeys = {
list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const,
details: () => [...enquiryKeys.all, 'detail'] as const,
detail: (id: string) => [...enquiryKeys.details(), id] as const,
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
};
@@ -24,6 +31,12 @@ export const enquiryByIdOptions = (id: string) =>
queryFn: () => getEnquiryById(id)
});
export const enquiryProjectPartiesOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.projectParties(id),
queryFn: () => getEnquiryProjectParties(id)
});
export const enquiryFollowupsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.followups(id),

View File

@@ -7,6 +7,7 @@ import type {
EnquiryFollowupsResponse,
EnquiryListResponse,
EnquiryMutationPayload,
EnquiryProjectPartiesResponse,
MutationSuccessResponse
} from './types';
@@ -32,6 +33,10 @@ export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse>
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
}
export async function getEnquiryProjectParties(id: string): Promise<EnquiryProjectPartiesResponse> {
return apiClient<EnquiryProjectPartiesResponse>(`/crm/enquiries/${id}/customers`);
}
export async function createEnquiry(data: EnquiryMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
method: 'POST',

View File

@@ -5,6 +5,25 @@ export interface EnquiryOption {
value: string | null;
}
export interface EnquiryProjectPartyRecord {
id: string;
organizationId: string;
enquiryId: string;
customerId: string;
role: string;
roleCode: string;
roleLabel: string | null;
remark: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface EnquiryProjectPartyListItem extends EnquiryProjectPartyRecord {
customerName: string;
customerCode: string;
}
export interface EnquiryBranchOption {
id: string;
code: string;
@@ -113,6 +132,7 @@ export interface EnquiryReferenceData {
priorities: EnquiryOption[];
leadChannels: EnquiryOption[];
followupTypes: EnquiryOption[];
projectPartyRoles: EnquiryOption[];
customers: EnquiryCustomerLookup[];
contacts: EnquiryContactLookup[];
assignableUsers: EnquiryAssignableUserLookup[];
@@ -148,6 +168,13 @@ export interface EnquiryDetailResponse {
activity: EnquiryActivityRecord[];
}
export interface EnquiryProjectPartiesResponse {
success: boolean;
time: string;
message: string;
items: EnquiryProjectPartyListItem[];
}
export interface EnquiryFollowupsResponse {
success: boolean;
time: string;
@@ -155,6 +182,12 @@ export interface EnquiryFollowupsResponse {
items: EnquiryFollowupRecord[];
}
export interface EnquiryProjectPartyMutationPayload {
customerId: string;
role: string;
remark?: string | null;
}
export interface EnquiryMutationPayload {
customerId: string;
contactId?: string | null;
@@ -176,6 +209,7 @@ export interface EnquiryMutationPayload {
isHotProject?: boolean;
notes?: string;
isActive?: boolean;
projectParties?: EnquiryProjectPartyMutationPayload[];
}
export interface EnquiryFollowupMutationPayload {

View File

@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { enquiryByIdOptions } from '../api/queries';
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
@@ -48,6 +48,7 @@ export function EnquiryDetail({
};
}) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
const [editOpen, setEditOpen] = useState(false);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const enquiry = data.enquiry;
@@ -142,7 +143,7 @@ export function EnquiryDetail({
</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'
@@ -192,6 +193,28 @@ export function EnquiryDetail({
/>
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
<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>
{!projectPartiesData.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'>
{projectPartiesData.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'

View File

@@ -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 {
Select,
SelectContent,
@@ -22,6 +26,7 @@ import {
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { enquiryProjectPartiesOptions } from '../api/queries';
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
@@ -72,8 +77,13 @@ export function EnquiryFormSheet({
[enquiry, referenceData]
);
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
useFormFields<EnquiryFormValues>();
const projectPartiesQuery = useQuery({
...enquiryProjectPartiesOptions(enquiry?.id ?? ''),
enabled: open && !!enquiry?.id
});
const createMutation = useMutation({
...createEnquiryMutation,
@@ -127,7 +137,14 @@ export function EnquiryFormSheet({
source: value.source,
isHotProject: value.isHotProject ?? false,
notes: value.notes,
isActive: value.isActive ?? true
isActive: value.isActive ?? true,
projectParties: projectParties
.filter((item) => item.customerId && item.role)
.map((item) => ({
customerId: item.customerId,
role: item.role,
remark: item.remark || null
}))
};
if (isEdit && enquiry) {
@@ -146,7 +163,17 @@ export function EnquiryFormSheet({
setSelectedCustomerId(defaultValues.customerId);
form.reset(defaultValues);
}, [defaultValues, form, open]);
setProjectParties(
enquiry
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
key: item.id,
customerId: item.customerId,
role: item.role,
remark: item.remark ?? ''
}))
: []
);
}, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]);
const isPending = createMutation.isPending || updateMutation.isPending;
@@ -168,7 +195,7 @@ export function EnquiryFormSheet({
children={(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Customer *</field.FieldLabel>
<field.FieldLabel>Billing Customer *</field.FieldLabel>
<Select
value={field.state.value ?? ''}
onValueChange={(value) => {
@@ -340,6 +367,14 @@ export function EnquiryFormSheet({
description='Inactive enquiries stay in history but should not progress further.'
/>
</div>
<div className='md:col-span-2'>
<ProjectPartiesEditor
customers={referenceData.customers}
roles={referenceData.projectPartyRoles}
items={projectParties}
onChange={setProjectParties}
/>
</div>
</form.Form>
</form.AppForm>
</div>

View File

@@ -50,7 +50,16 @@ export const enquirySchema = z.object({
source: z.string().optional(),
isHotProject: z.boolean().default(false),
notes: z.string().optional(),
isActive: z.boolean().default(true)
isActive: z.boolean().default(true),
projectParties: z
.array(
z.object({
customerId: z.string().min(1, 'Please select a customer'),
role: z.string().min(1, 'Please select a role'),
remark: z.string().nullable().optional()
})
)
.optional()
});
export const enquiryFollowupSchema = z.object({

View File

@@ -3,6 +3,7 @@ import {
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmEnquiryCustomers,
crmEnquiryFollowups,
memberships,
users
@@ -25,16 +26,25 @@ import type {
EnquiryListItem,
EnquiryMutationPayload,
EnquiryOption,
EnquiryProjectPartyListItem,
EnquiryProjectPartyMutationPayload,
EnquiryProjectPartyRecord,
EnquiryRecord,
EnquiryReferenceData
} from '../api/types';
import {
buildProjectPartyKey,
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
const ENQUIRY_OPTION_CATEGORIES = {
status: 'crm_enquiry_status',
productType: 'crm_product_type',
priority: 'crm_priority',
leadChannel: 'crm_lead_channel',
followupType: 'crm_followup_type'
followupType: 'crm_followup_type',
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
} as const;
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
@@ -123,6 +133,25 @@ function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): Enquir
};
}
function mapProjectPartyRecord(
row: typeof crmEnquiryCustomers.$inferSelect,
options: { role: { id: string; code: string; label: string } }
): EnquiryProjectPartyRecord {
return {
id: row.id,
organizationId: row.organizationId,
enquiryId: row.enquiryId,
customerId: row.customerId,
role: options.role.id,
roleCode: options.role.code,
roleLabel: options.role.label,
remark: row.remark,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function parseSort(sort?: string) {
if (!sort) {
return desc(crmEnquiries.updatedAt);
@@ -352,6 +381,15 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
if (payload.branchId) {
await validateBranchAccess(payload.branchId);
}
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
organizationId,
ENQUIRY_OPTION_CATEGORIES.projectPartyRole,
projectParty.role
);
}
}
async function validateFollowupPayload(
@@ -410,6 +448,7 @@ export async function getEnquiryReferenceData(
priorities,
leadChannels,
followupTypes,
projectPartyRoles,
customers,
contacts,
assignableUsers
@@ -420,6 +459,7 @@ export async function getEnquiryReferenceData(
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
db
.select()
.from(crmCustomers)
@@ -445,6 +485,7 @@ export async function getEnquiryReferenceData(
priorities: priorities.map(mapOption),
leadChannels: leadChannels.map(mapOption),
followupTypes: followupTypes.map(mapOption),
projectPartyRoles: projectPartyRoles.map(mapOption),
customers: customers.map((customer) => ({
id: customer.id,
code: customer.code,
@@ -575,6 +616,142 @@ export async function getEnquiryDetail(id: string, organizationId: string): Prom
});
}
async function buildPreparedProjectParties(
organizationId: string,
billingCustomerId: string,
projectParties: EnquiryProjectPartyMutationPayload[] = []
) {
const roleOptions = await getActiveOptionsByCategory(ENQUIRY_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; 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,
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,
remark: null
});
}
return [...prepared.values()];
}
async function syncEnquiryProjectParties(
tx: Pick<typeof db, 'insert' | 'update'>,
organizationId: string,
enquiryId: string,
billingCustomerId: string,
payload: EnquiryProjectPartyMutationPayload[]
) {
const now = new Date();
const preparedParties = await buildPreparedProjectParties(
organizationId,
billingCustomerId,
payload
);
await tx
.update(crmEnquiryCustomers)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
eq(crmEnquiryCustomers.organizationId, organizationId),
eq(crmEnquiryCustomers.enquiryId, enquiryId),
isNull(crmEnquiryCustomers.deletedAt)
)
);
if (!preparedParties.length) {
return;
}
await tx.insert(crmEnquiryCustomers).values(
preparedParties.map((item) => ({
id: crypto.randomUUID(),
organizationId,
enquiryId,
customerId: item.customerId,
role: item.roleId,
remark: item.remark
}))
);
}
export async function listEnquiryProjectParties(
id: string,
organizationId: string
): Promise<EnquiryProjectPartyListItem[]> {
await assertEnquiryBelongsToOrganization(id, organizationId);
const [relations, customers, roleOptions] = await Promise.all([
db
.select()
.from(crmEnquiryCustomers)
.where(
and(
eq(crmEnquiryCustomers.enquiryId, id),
eq(crmEnquiryCustomers.organizationId, organizationId),
isNull(crmEnquiryCustomers.deletedAt)
)
)
.orderBy(asc(crmEnquiryCustomers.createdAt)),
db
.select()
.from(crmCustomers)
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt))),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId })
]);
const customerMap = new Map(customers.map((item) => [item.id, item]));
return relations
.map((row) => {
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
if (!resolvedRole) {
return null;
}
return {
...mapProjectPartyRecord(row, { role: resolvedRole }),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
customerCode: customerMap.get(row.customerId)?.code ?? '-'
} satisfies EnquiryProjectPartyListItem;
})
.filter((item): item is EnquiryProjectPartyListItem => item !== null);
}
export async function getEnquiryActivity(
id: string,
organizationId: string
@@ -624,38 +801,48 @@ export async function createEnquiry(
branchId: payload.branchId ?? ''
});
const [created] = await db
.insert(crmEnquiries)
.values({
id: crypto.randomUUID(),
organizationId,
branchId: payload.branchId ?? null,
code: documentCode.code,
customerId: payload.customerId,
contactId: payload.contactId ?? null,
title: payload.title.trim(),
description: payload.description?.trim() || null,
requirement: payload.requirement?.trim() || null,
projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType,
status: payload.status,
priority: payload.priority,
leadChannel: payload.leadChannel ?? null,
estimatedValue: payload.estimatedValue ?? null,
chancePercent: payload.chancePercent ?? null,
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
competitor: payload.competitor?.trim() || null,
source: payload.source?.trim() || null,
notes: payload.notes?.trim() || null,
isHotProject: payload.isHotProject ?? false,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
return db.transaction(async (tx) => {
const [created] = await tx
.insert(crmEnquiries)
.values({
id: crypto.randomUUID(),
organizationId,
branchId: payload.branchId ?? null,
code: documentCode.code,
customerId: payload.customerId,
contactId: payload.contactId ?? null,
title: payload.title.trim(),
description: payload.description?.trim() || null,
requirement: payload.requirement?.trim() || null,
projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType,
status: payload.status,
priority: payload.priority,
leadChannel: payload.leadChannel ?? null,
estimatedValue: payload.estimatedValue ?? null,
chancePercent: payload.chancePercent ?? null,
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
competitor: payload.competitor?.trim() || null,
source: payload.source?.trim() || null,
notes: payload.notes?.trim() || null,
isHotProject: payload.isHotProject ?? false,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
return created;
await syncEnquiryProjectParties(
tx,
organizationId,
created.id,
payload.customerId,
payload.projectParties ?? []
);
return created;
});
}
export async function updateEnquiry(
@@ -667,36 +854,46 @@ export async function updateEnquiry(
await validateEnquiryPayload(organizationId, payload);
await assertEnquiryBelongsToOrganization(id, organizationId);
const [updated] = await db
.update(crmEnquiries)
.set({
branchId: payload.branchId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? null,
title: payload.title.trim(),
description: payload.description?.trim() || null,
requirement: payload.requirement?.trim() || null,
projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType,
status: payload.status,
priority: payload.priority,
leadChannel: payload.leadChannel ?? null,
estimatedValue: payload.estimatedValue ?? null,
chancePercent: payload.chancePercent ?? null,
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
competitor: payload.competitor?.trim() || null,
source: payload.source?.trim() || null,
notes: payload.notes?.trim() || null,
isHotProject: payload.isHotProject ?? false,
isActive: payload.isActive ?? true,
updatedBy: userId,
updatedAt: new Date()
})
.where(eq(crmEnquiries.id, id))
.returning();
return db.transaction(async (tx) => {
const [updated] = await tx
.update(crmEnquiries)
.set({
branchId: payload.branchId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? null,
title: payload.title.trim(),
description: payload.description?.trim() || null,
requirement: payload.requirement?.trim() || null,
projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType,
status: payload.status,
priority: payload.priority,
leadChannel: payload.leadChannel ?? null,
estimatedValue: payload.estimatedValue ?? null,
chancePercent: payload.chancePercent ?? null,
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
competitor: payload.competitor?.trim() || null,
source: payload.source?.trim() || null,
notes: payload.notes?.trim() || null,
isHotProject: payload.isHotProject ?? false,
isActive: payload.isActive ?? true,
updatedBy: userId,
updatedAt: new Date()
})
.where(eq(crmEnquiries.id, id))
.returning();
return updated;
await syncEnquiryProjectParties(
tx,
organizationId,
id,
payload.customerId,
payload.projectParties ?? []
);
return updated;
});
}
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
@@ -728,6 +925,20 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
)
);
await db
.update(crmEnquiryCustomers)
.set({
deletedAt: now,
updatedAt: now
})
.where(
and(
eq(crmEnquiryCustomers.enquiryId, id),
eq(crmEnquiryCustomers.organizationId, organizationId),
isNull(crmEnquiryCustomers.deletedAt)
)
);
return updated;
}

View File

@@ -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 {

View File

@@ -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'>

View File

@@ -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} />

View File

@@ -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' />

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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
}))
);
}

View File

@@ -0,0 +1,341 @@
import {
and,
asc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
or,
type SQL
} from 'drizzle-orm';
import {
crmCustomers,
crmEnquiryCustomers,
crmQuotationCustomers,
crmQuotations
} from '@/db/schema';
import { db } from '@/lib/db';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
interface NormalizedProjectParty {
customerId: string;
roleCode: string;
}
function splitFilterValue(value?: string) {
return value
?.split(',')
.map((item) => item.trim())
.filter(Boolean) ?? [];
}
function buildRevenueAttributionFilters(
organizationId: string,
filters: RevenueAttributionFilters
): SQL[] {
const statuses = splitFilterValue(filters.status);
const quotationTypes = splitFilterValue(filters.quotationType);
const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer);
const enquiries = splitFilterValue(filters.enquiry);
return [
eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt),
...(statuses.length
? [inArray(crmQuotations.status, statuses)]
: filters.includeCancelled
? []
: [ne(crmQuotations.status, 'cancelled')]),
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
...(filters.quotationDateFrom ? [gte(crmQuotations.quotationDate, new Date(filters.quotationDateFrom))] : []),
...(filters.quotationDateTo ? [lte(crmQuotations.quotationDate, new Date(filters.quotationDateTo))] : []),
...(filters.search
? [
or(
ilike(crmQuotations.code, `%${filters.search}%`),
ilike(crmQuotations.projectName, `%${filters.search}%`),
ilike(crmQuotations.projectLocation, `%${filters.search}%`),
ilike(crmQuotations.reference, `%${filters.search}%`),
ilike(crmQuotations.competitor, `%${filters.search}%`)
)!
]
: [])
];
}
function normalizeParties(
rows: Array<{ customerId: string; role: string }>,
roleOptions: Awaited<ReturnType<typeof getActiveOptionsByCategory>>
) {
const normalized: NormalizedProjectParty[] = [];
const seen = new Set<string>();
for (const row of rows) {
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
if (!resolvedRole) {
continue;
}
const key = `${row.customerId}::${resolvedRole.code}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
customerId: row.customerId,
roleCode: resolvedRole.code
});
}
return normalized;
}
function groupNormalizedParties<TGroupKey extends string>(
rows: Array<{ groupKey: TGroupKey; customerId: string; role: string }>,
roleOptions: Awaited<ReturnType<typeof getActiveOptionsByCategory>>
) {
const grouped = new Map<TGroupKey, Array<{ customerId: string; role: string }>>();
for (const row of rows) {
const existing = grouped.get(row.groupKey) ?? [];
existing.push({
customerId: row.customerId,
role: row.role
});
grouped.set(row.groupKey, existing);
}
return new Map(
[...grouped.entries()].map(([groupKey, groupRows]) => [
groupKey,
normalizeParties(groupRows, roleOptions)
])
);
}
function pickAttributedParties(
roleCode: SupportedRevenueRole,
quotationParties: NormalizedProjectParty[],
enquiryParties: NormalizedProjectParty[]
) {
const authoritativeParties = quotationParties.length > 0 ? quotationParties : enquiryParties;
if (roleCode === 'end_customer') {
const endCustomers = authoritativeParties.filter((party) => party.roleCode === 'end_customer');
if (endCustomers.length > 0) {
return endCustomers;
}
return authoritativeParties.filter((party) => party.roleCode === 'billing_customer');
}
return authoritativeParties.filter((party) => party.roleCode === roleCode);
}
async function getRevenueByRole(
organizationId: string,
roleCode: SupportedRevenueRole,
filters: RevenueAttributionFilters = {}
): Promise<RevenueAttributionSummary[]> {
const whereFilters = buildRevenueAttributionFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const quotations = await db
.select({
id: crmQuotations.id,
enquiryId: crmQuotations.enquiryId,
totalAmount: crmQuotations.totalAmount
})
.from(crmQuotations)
.where(where)
.orderBy(asc(crmQuotations.quotationDate), asc(crmQuotations.code));
if (quotations.length === 0) {
return [];
}
const quotationIds = quotations.map((quotation) => quotation.id);
const enquiryIds = [...new Set(quotations.map((quotation) => quotation.enquiryId).filter(Boolean))] as string[];
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
db
.select({
quotationId: crmQuotationCustomers.quotationId,
customerId: crmQuotationCustomers.customerId,
role: crmQuotationCustomers.role
})
.from(crmQuotationCustomers)
.where(
and(
eq(crmQuotationCustomers.organizationId, organizationId),
inArray(crmQuotationCustomers.quotationId, quotationIds),
isNull(crmQuotationCustomers.deletedAt)
)
),
enquiryIds.length
? db
.select({
enquiryId: crmEnquiryCustomers.enquiryId,
customerId: crmEnquiryCustomers.customerId,
role: crmEnquiryCustomers.role
})
.from(crmEnquiryCustomers)
.where(
and(
eq(crmEnquiryCustomers.organizationId, organizationId),
inArray(crmEnquiryCustomers.enquiryId, enquiryIds),
isNull(crmEnquiryCustomers.deletedAt)
)
)
: []
]);
const quotationPartyMap = groupNormalizedParties(
quotationParties.map((row) => ({
groupKey: row.quotationId,
customerId: row.customerId,
role: row.role
})),
roleOptions
);
const enquiryPartyMap = groupNormalizedParties(
enquiryParties.map((row) => ({
groupKey: row.enquiryId,
customerId: row.customerId,
role: row.role
})),
roleOptions
);
const attributedCustomerIds = new Set<string>();
const attributionByQuotation = new Map<string, NormalizedProjectParty[]>();
for (const quotation of quotations) {
const attributedParties = pickAttributedParties(
roleCode,
quotationPartyMap.get(quotation.id) ?? [],
quotation.enquiryId ? (enquiryPartyMap.get(quotation.enquiryId) ?? []) : []
);
attributionByQuotation.set(quotation.id, attributedParties);
for (const party of attributedParties) {
attributedCustomerIds.add(party.customerId);
}
}
if (attributedCustomerIds.size === 0) {
return [];
}
const customers = await db
.select({
id: crmCustomers.id,
code: crmCustomers.code,
name: crmCustomers.name
})
.from(crmCustomers)
.where(
and(
eq(crmCustomers.organizationId, organizationId),
inArray(crmCustomers.id, [...attributedCustomerIds]),
isNull(crmCustomers.deletedAt)
)
);
const customerMap = new Map(customers.map((customer) => [customer.id, customer]));
const summaryMap = new Map<string, RevenueAttributionSummary>();
for (const quotation of quotations) {
const attributedParties = attributionByQuotation.get(quotation.id) ?? [];
for (const party of attributedParties) {
const customer = customerMap.get(party.customerId);
if (!customer) {
continue;
}
const existing = summaryMap.get(customer.id);
if (existing) {
existing.revenue += quotation.totalAmount;
if (!existing.quotationIds.includes(quotation.id)) {
existing.quotationIds.push(quotation.id);
existing.quotationCount += 1;
}
continue;
}
summaryMap.set(customer.id, {
customerId: customer.id,
customerCode: customer.code,
customerName: customer.name,
roleCode,
revenue: quotation.totalAmount,
quotationCount: 1,
quotationIds: [quotation.id]
});
}
}
return [...summaryMap.values()].sort((a, b) => {
if (b.revenue !== a.revenue) {
return b.revenue - a.revenue;
}
return a.customerName.localeCompare(b.customerName);
});
}
export async function getRevenueByEndCustomer(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'end_customer', filters);
}
export async function getRevenueByBillingCustomer(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'billing_customer', filters);
}
export async function getRevenueByContractor(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'contractor', filters);
}
export async function getRevenueByConsultant(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'consultant', filters);
}

View File

@@ -0,0 +1,22 @@
export interface RevenueAttributionFilters {
search?: string;
status?: string;
quotationType?: string;
branch?: string;
customer?: string;
enquiry?: string;
hot?: string;
quotationDateFrom?: string | null;
quotationDateTo?: string | null;
includeCancelled?: boolean;
}
export interface RevenueAttributionSummary {
customerId: string;
customerCode: string;
customerName: string;
roleCode: string;
revenue: number;
quotationCount: number;
quotationIds: string[];
}

View File

@@ -0,0 +1,78 @@
export const PROJECT_PARTY_OPTION_CATEGORY = 'crm_project_party_role';
const LEGACY_PROJECT_PARTY_ROLE_CODE_MAP = {
owner: 'customer',
billing: 'billing_customer',
consultant: 'consultant',
contractor: 'contractor'
} as const;
export const PROJECT_PARTY_CODE_LABELS: Record<string, string> = {
billing_customer: 'Billing Customer',
customer: 'Customer',
consultant: 'Consultant',
contractor: 'Contractor',
end_customer: 'End Customer',
other: 'Other'
};
export interface ProjectPartyRoleOptionLike {
id: string;
code: string;
label: string;
}
export interface ProjectPartyResolvedRole {
id: string;
code: string;
label: string;
}
export function normalizeProjectPartyRoleCode(role: string | null | undefined) {
if (!role) {
return null;
}
return LEGACY_PROJECT_PARTY_ROLE_CODE_MAP[role as keyof typeof LEGACY_PROJECT_PARTY_ROLE_CODE_MAP] ?? role;
}
export function resolveProjectPartyRole(
options: ProjectPartyRoleOptionLike[],
role: string | null | undefined
): ProjectPartyResolvedRole | null {
if (!role) {
return null;
}
const direct = options.find((option) => option.id === role);
if (direct) {
return {
id: direct.id,
code: direct.code,
label: direct.label
};
}
const normalizedCode = normalizeProjectPartyRoleCode(role);
if (!normalizedCode) {
return null;
}
const byCode = options.find((option) => option.code === normalizedCode);
if (!byCode) {
return null;
}
return {
id: byCode.id,
code: byCode.code,
label: byCode.label
};
}
export function buildProjectPartyKey(customerId: string, roleCode: string) {
return `${customerId}::${roleCode}`;
}