This commit is contained in:
phaichayon
2026-06-15 16:34:41 +07:00
parent 1d2406483e
commit eae0dee1f2
40 changed files with 9982 additions and 132 deletions

View File

@@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { enquiryByIdOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryStatusBadge } from './enquiry-status-badge';
@@ -27,11 +28,13 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
export function EnquiryDetail({
enquiryId,
referenceData,
relatedQuotations,
canUpdate,
canManageFollowups
}: {
enquiryId: string;
referenceData: EnquiryReferenceData;
relatedQuotations: QuotationRelationItem[];
canUpdate: boolean;
canManageFollowups: {
create: boolean;
@@ -245,14 +248,35 @@ export function EnquiryDetail({
<CardHeader>
<CardTitle>Related Quotations</CardTitle>
<CardDescription>
Task D ends at enquiry readiness, so quotation production stays
intentionally deferred.
Production quotations already linked to this enquiry.
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
Quotation linkage will land here in Task E.
</div>
{!relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No quotations have been linked to this enquiry yet.
</div>
) : (
<div className='space-y-3'>
{relatedQuotations.map((quotation) => (
<Link
key={quotation.id}
href={`/dashboard/crm/quotations/${quotation.id}`}
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='font-medium'>{quotation.code}</div>
<div className='text-muted-foreground text-sm'>
{quotation.totalAmount.toLocaleString()}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>

View File

@@ -178,13 +178,17 @@ async function assertMasterOptionValue(
}
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
const customer = await db.query.crmCustomers.findFirst({
where: and(
eq(crmCustomers.id, id),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
const [customer] = await db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.id, id),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
)
)
});
.limit(1);
if (!customer) {
throw new AuthError('Customer not found', 404);
@@ -198,14 +202,18 @@ export async function assertContactBelongsToOrganization(
organizationId: string,
customerId?: string | null
) {
const contact = await db.query.crmCustomerContacts.findFirst({
where: and(
eq(crmCustomerContacts.id, contactId),
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt),
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
const [contact] = await db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.id, contactId),
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt),
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
)
)
});
.limit(1);
if (!contact) {
throw new AuthError('Contact not found', 404);
@@ -215,13 +223,17 @@ export async function assertContactBelongsToOrganization(
}
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
const enquiry = await db.query.crmEnquiries.findFirst({
where: and(
eq(crmEnquiries.id, id),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
const [enquiry] = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.id, id),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
)
)
});
.limit(1);
if (!enquiry) {
throw new AuthError('Enquiry not found', 404);
@@ -235,14 +247,18 @@ async function assertFollowupBelongsToEnquiry(
enquiryId: string,
organizationId: string
) {
const followup = await db.query.crmEnquiryFollowups.findFirst({
where: and(
eq(crmEnquiryFollowups.id, followupId),
eq(crmEnquiryFollowups.enquiryId, enquiryId),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
const [followup] = await db
.select()
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.id, followupId),
eq(crmEnquiryFollowups.enquiryId, enquiryId),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
});
.limit(1);
if (!followup) {
throw new AuthError('Follow-up not found', 404);
@@ -345,17 +361,21 @@ export async function getEnquiryReferenceData(
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
db.query.crmCustomers.findMany({
where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)),
orderBy: [asc(crmCustomers.name)]
}),
db.query.crmCustomerContacts.findMany({
where: and(
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt)
),
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
})
db
.select()
.from(crmCustomers)
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
.orderBy(asc(crmCustomers.name)),
db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt)
)
)
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
]);
return {
@@ -407,20 +427,26 @@ export async function listEnquiries(
const [customers, contacts, followupCounts] = await Promise.all([
customerIds.length
? db.query.crmCustomers.findMany({
where: and(
eq(crmCustomers.organizationId, organizationId),
inArray(crmCustomers.id, customerIds)
? db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.organizationId, organizationId),
inArray(crmCustomers.id, customerIds)
)
)
})
: [],
contactIds.length
? db.query.crmCustomerContacts.findMany({
where: and(
eq(crmCustomerContacts.organizationId, organizationId),
inArray(crmCustomerContacts.id, contactIds)
? db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.organizationId, organizationId),
inArray(crmCustomerContacts.id, contactIds)
)
)
})
: [],
enquiryIds.length
? db
@@ -618,14 +644,17 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
export async function listEnquiryFollowups(id: string, organizationId: string) {
await assertEnquiryBelongsToOrganization(id, organizationId);
const rows = await db.query.crmEnquiryFollowups.findMany({
where: and(
eq(crmEnquiryFollowups.enquiryId, id),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
),
orderBy: [desc(crmEnquiryFollowups.followupDate)]
});
const rows = await db
.select()
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.enquiryId, id),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.orderBy(desc(crmEnquiryFollowups.followupDate));
return rows.map(mapFollowupRecord);
}
@@ -713,15 +742,18 @@ export async function listCustomerEnquiryRelations(
customerId: string,
organizationId: string
): Promise<EnquiryCustomerRelationItem[]> {
const rows = await db.query.crmEnquiries.findMany({
where: and(
eq(crmEnquiries.customerId, customerId),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
),
orderBy: [desc(crmEnquiries.updatedAt)],
limit: 10
});
const rows = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.customerId, customerId),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
)
)
.orderBy(desc(crmEnquiries.updatedAt))
.limit(10);
return rows.map((row) => ({
id: row.id,