taks-d.2.1
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user