task-d complate

This commit is contained in:
phaichayon
2026-06-15 13:46:33 +07:00
parent 8148850fda
commit 1d2406483e
32 changed files with 4812 additions and 39 deletions

View File

@@ -0,0 +1,118 @@
# Task D: Enquiry Production Module
## 1. Files Added
- `src/app/api/crm/enquiries/route.ts`
- `src/app/api/crm/enquiries/[id]/route.ts`
- `src/app/api/crm/enquiries/[id]/followups/route.ts`
- `src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts`
- `src/features/crm/enquiries/api/types.ts`
- `src/features/crm/enquiries/api/service.ts`
- `src/features/crm/enquiries/api/queries.ts`
- `src/features/crm/enquiries/api/mutations.ts`
- `src/features/crm/enquiries/schemas/enquiry.schema.ts`
- `src/features/crm/enquiries/server/service.ts`
- `src/features/crm/enquiries/components/enquiry-status-badge.tsx`
- `src/features/crm/enquiries/components/enquiry-form-sheet.tsx`
- `src/features/crm/enquiries/components/followup-form-sheet.tsx`
- `src/features/crm/enquiries/components/enquiry-cell-action.tsx`
- `src/features/crm/enquiries/components/enquiry-columns.tsx`
- `src/features/crm/enquiries/components/enquiries-table.tsx`
- `src/features/crm/enquiries/components/enquiry-listing.tsx`
- `src/features/crm/enquiries/components/enquiry-followups-tab.tsx`
- `src/features/crm/enquiries/components/enquiry-detail.tsx`
- `drizzle/0003_blushing_bruce_banner.sql`
- `drizzle/meta/0003_snapshot.json`
## 2. Files Modified
- `src/app/dashboard/crm/enquiries/page.tsx`
- `src/app/dashboard/crm/enquiries/[id]/page.tsx`
- `src/app/dashboard/crm/customers/[id]/page.tsx`
- `src/features/crm/customers/api/types.ts`
- `src/features/crm/customers/components/customer-detail.tsx`
- `src/db/schema.ts`
- `src/db/seeds/foundation.seed.ts`
- `src/lib/auth/rbac.ts`
- `src/lib/searchparams.ts`
- `drizzle/meta/_journal.json`
## 3. Schema Added
- `crm_enquiries`
- organization-scoped sales opportunity master
- customer/contact linkage, project context, pricing expectation, probability, hot-project flag, and soft-delete columns
- unique `(organization_id, code)` for document-sequenced enquiry codes
- `crm_enquiry_followups`
- organization-scoped timeline items under enquiry
- follow-up type, contact linkage, outcome, next follow-up date, next action, and soft-delete columns
## 4. API Routes Added
- `GET /api/crm/enquiries`
- `POST /api/crm/enquiries`
- `GET /api/crm/enquiries/[id]`
- `PATCH /api/crm/enquiries/[id]`
- `DELETE /api/crm/enquiries/[id]`
- `GET /api/crm/enquiries/[id]/followups`
- `POST /api/crm/enquiries/[id]/followups`
- `PATCH /api/crm/enquiries/[id]/followups/[followupId]`
- `DELETE /api/crm/enquiries/[id]/followups/[followupId]`
## 5. UI Routes Completed
- `/dashboard/crm/enquiries`
- production list with React Query, nuqs filters, create button, edit/view/delete actions
- filters: search, status, product type, priority, branch, customer
- `/dashboard/crm/enquiries/[id]`
- detail page with tabs: Overview, Follow-ups, Activity, Related Quotations Placeholder
- `/dashboard/crm/customers/[id]`
- related documents tab now shows real linked enquiries for that customer
## 6. Permissions Used
- `crm.enquiry.read`
- `crm.enquiry.create`
- `crm.enquiry.update`
- `crm.enquiry.delete`
- `crm.enquiry.followup.read`
- `crm.enquiry.followup.create`
- `crm.enquiry.followup.update`
- `crm.enquiry.followup.delete`
Admin defaults now include enquiry and follow-up permissions. Regular users inherit read permissions only unless additional permissions are granted.
## 7. Audit Integration
- enquiry create/update/delete writes to `tr_audit_logs` with `entityType = crm_enquiry`
- follow-up create/update/delete writes to `tr_audit_logs` with `entityType = crm_enquiry_followup`
- enquiry detail activity tab merges enquiry audit and follow-up audit entries, then resolves actor names from `users`
## 8. Document Sequence Integration
- enquiry create uses `generateNextDocumentCode({ documentType: 'enquiry' })`
- branch-specific sequence is honored when `branchId` is provided
- foundation seed now includes `crm_followup_type` options so follow-up forms do not hardcode those values
## 9. Customer/Contact Integration
- enquiry create/update validates that customer belongs to the active organization
- enquiry contact must belong to the same organization and selected customer
- follow-up contact must belong to the same organization and the enquiry's customer
- enquiry form customer dropdown is sourced from production customer data
- contact dropdown is filtered by selected customer
- customer detail route now shows related enquiries in the related-documents tab
## 10. Remaining Risks
- there are still no database foreign keys between enquiry tables and customer/contact tables; organization scoping is enforced in application logic
- form date fields currently use `YYYY-MM-DD` text inputs because the project field wrapper does not expose a native date mode
- customer detail shows linked enquiries, but there is not yet a dedicated customer-side enquiry table with pagination
- follow-up activity relies on audit payload contents for relation tracing, not a dedicated combined activity view
## 11. Task E Readiness
- enquiry production backbone is ready for quotation creation and linkage
- customer, contact, and enquiry lifecycles now share the same foundation patterns: org scope, branch validation, master options, document sequence, and audit logging
- enquiry detail already has a stable placeholder tab for quotation integration
- follow-up timeline and customer-related enquiry surfacing are in place for downstream sales workflow expansion

View File

@@ -0,0 +1,50 @@
CREATE TABLE "crm_enquiries" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"code" text NOT NULL,
"customer_id" text NOT NULL,
"contact_id" text,
"title" text NOT NULL,
"description" text,
"requirement" text,
"project_name" text,
"project_location" text,
"product_type" text NOT NULL,
"status" text NOT NULL,
"priority" text NOT NULL,
"lead_channel" text,
"estimated_value" double precision,
"chance_percent" integer,
"expected_close_date" timestamp with time zone,
"competitor" text,
"source" text,
"notes" text,
"is_hot_project" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_enquiry_followups" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"followup_date" timestamp with time zone NOT NULL,
"followup_type" text NOT NULL,
"contact_id" text,
"outcome" text,
"notes" text,
"next_followup_date" timestamp with time zone,
"next_action" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "crm_enquiries_org_code_idx" ON "crm_enquiries" USING btree ("organization_id","code");

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1781498575433, "when": 1781498575433,
"tag": "0002_plain_anthem", "tag": "0002_plain_anthem",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1781505431077,
"tag": "0003_blushing_bruce_banner",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
listEnquiryFollowups,
softDeleteEnquiryFollowup,
updateEnquiryFollowup
} from '@/features/crm/enquiries/server/service';
import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; followupId: string }>;
};
const followupRequestSchema = enquiryFollowupSchema.extend({
contactId: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupUpdate
});
const payload = followupRequestSchema.parse(await request.json());
const before = (await listEnquiryFollowups(id, organization.id)).find(
(item) => item.id === followupId
);
if (!before) {
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
}
const updated = await updateEnquiryFollowup(
id,
followupId,
organization.id,
session.user.id,
payload
);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_enquiry_followup',
entityId: followupId,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Enquiry follow-up updated successfully',
followup: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update enquiry follow-up' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupDelete
});
const before = (await listEnquiryFollowups(id, organization.id)).find(
(item) => item.id === followupId
);
if (!before) {
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
}
const updated = await softDeleteEnquiryFollowup(
id,
followupId,
organization.id,
session.user.id
);
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_enquiry_followup',
entityId: followupId,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Enquiry follow-up deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete enquiry follow-up' }, { status: 500 });
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createEnquiryFollowup,
listEnquiryFollowups
} from '@/features/crm/enquiries/server/service';
import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const followupRequestSchema = enquiryFollowupSchema.extend({
contactId: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupRead
});
const items = await listEnquiryFollowups(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry follow-ups loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load enquiry follow-ups' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupCreate
});
const payload = followupRequestSchema.parse(await request.json());
const created = await createEnquiryFollowup(id, organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_enquiry_followup',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Enquiry follow-up created successfully',
followup: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create enquiry follow-up' }, { status: 500 });
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getEnquiryActivity,
getEnquiryDetail,
softDeleteEnquiry,
updateEnquiry
} from '@/features/crm/enquiries/server/service';
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const enquiryRequestSchema = enquirySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(),
chancePercent: z.number().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const [enquiry, activity] = await Promise.all([
getEnquiryDetail(id, organization.id),
getEnquiryActivity(id, organization.id)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry loaded successfully',
enquiry,
activity
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load enquiry' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryUpdate
});
const payload = enquiryRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const updated = await updateEnquiry(id, organization.id, session.user.id, payload);
await auditUpdate({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Enquiry updated successfully',
enquiry: updated
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update enquiry' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryDelete
});
const before = await getEnquiryDetail(id, organization.id);
const updated = await softDeleteEnquiry(id, organization.id, session.user.id);
await auditDelete({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Enquiry deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete enquiry' }, { status: 500 });
}
}

View File

@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service';
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const enquiryRequestSchema = enquirySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(),
chancePercent: z.number().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
});
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const status = searchParams.get('status') ?? undefined;
const productType = searchParams.get('productType') ?? undefined;
const priority = searchParams.get('priority') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listEnquiries(organization.id, {
page,
limit,
search,
status,
productType,
priority,
branch,
customer,
sort
});
const offset = (page - 1) * limit;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiries loaded successfully',
totalItems: result.totalItems,
offset,
limit,
items: result.items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load enquiries' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryCreate
});
const payload = enquiryRequestSchema.parse(await request.json());
const created = await createEnquiry(organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
branchId: created.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityId: created.id,
afterData: created
});
return NextResponse.json(
{
success: true,
message: 'Enquiry created successfully',
enquiry: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create enquiry' }, { status: 500 });
}
}

View File

@@ -4,6 +4,7 @@ import PageContainer from '@/components/layout/page-container';
import { CustomerDetail } from '@/features/crm/customers/components/customer-detail'; import { CustomerDetail } from '@/features/crm/customers/components/customer-detail';
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries'; import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
import { getCustomerReferenceData } from '@/features/crm/customers/server/service'; import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac'; import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client'; import { getQueryClient } from '@/lib/query-client';
@@ -50,6 +51,10 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
canRead && session?.user?.activeOrganizationId canRead && session?.user?.activeOrganizationId
? await getCustomerReferenceData(session.user.activeOrganizationId) ? await getCustomerReferenceData(session.user.activeOrganizationId)
: null; : null;
const relatedEnquiries =
canRead && session?.user?.activeOrganizationId
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
: [];
return ( return (
<PageContainer <PageContainer
@@ -73,6 +78,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
update: canContactUpdate, update: canContactUpdate,
delete: canContactDelete delete: canContactDelete
}} }}
relatedEnquiries={relatedEnquiries}
/> />
</HydrationBoundary> </HydrationBoundary>
) : null} ) : null}

View File

@@ -1,5 +1,11 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container'; import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries';
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { type PageProps = {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -7,24 +13,69 @@ type PageProps = {
export default async function EnquiryDetailRoute({ params }: PageProps) { export default async function EnquiryDetailRoute({ params }: PageProps) {
const { id } = await params; const { id } = await params;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
const canFollowupCreate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate)));
const canFollowupUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate)));
const canFollowupDelete =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(enquiryByIdOptions(id));
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
}
const referenceData =
canRead && session?.user?.activeOrganizationId
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
return ( return (
<PageContainer <PageContainer
pageTitle='Enquiry Detail' pageTitle='Enquiry Detail'
pageDescription={`Production detail route placeholder for enquiry ${id}.`} pageDescription='Opportunity context, follow-up timeline, and future quotation handoff.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to this enquiry.
</div>
}
> >
<CrmProductionPlaceholder {referenceData ? (
title='Enquiry detail pending' <HydrationBoundary state={dehydrate(queryClient)}>
summary='This route is reserved for the production enquiry detail page and no longer imports the demo in-memory service.' <EnquiryDetail
foundationItems={[ enquiryId={id}
'Organization context', referenceData={referenceData}
'Branch validation', canUpdate={canUpdate}
'Master option lookups', canManageFollowups={{
'Audit logging' create: canFollowupCreate,
]} update: canFollowupUpdate,
nextStep='Implement enquiry detail on top of persisted enquiry, activity, and relationship data.' delete: canFollowupDelete
demoHref={`/dashboard/crm-demo/enquiries/${id}`} }}
/> />
</HydrationBoundary>
) : null}
</PageContainer> </PageContainer>
); );
} }

View File

@@ -1,29 +1,70 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container'; import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = { export const metadata = {
title: 'Dashboard: CRM Enquiries' title: 'Dashboard: CRM Enquiries'
}; };
export default function EnquiriesRoute() { type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function EnquiriesRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
const canCreate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
const canDelete =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
searchParamsCache.parse(searchParams);
const referenceData =
canRead && session?.user?.activeOrganizationId
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
return ( return (
<PageContainer <PageContainer
pageTitle='Enquiries' pageTitle='Enquiries'
pageDescription='Production enquiry flows are intentionally blocked from mock services until the real module is built.' pageDescription='Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM enquiries.
</div>
}
pageHeaderAction={
canCreate && referenceData ? (
<EnquiryFormSheetTrigger referenceData={referenceData} />
) : null
}
> >
<CrmProductionPlaceholder {referenceData ? (
title='Enquiry module pending' <EnquiryListing referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
summary='The demo enquiry workflow remains available only under crm-demo while this production route stays foundation-safe.' ) : null}
foundationItems={[
'Master option statuses',
'Organization-scoped document sequences',
'Branch scope abstraction',
'Permission layer',
'Audit logging'
]}
nextStep='Implement enquiry listing and detail flows on production APIs after Customer and Contact are in place.'
demoHref='/dashboard/crm-demo/enquiries'
/>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -188,3 +188,61 @@ export const crmCustomerContacts = pgTable('crm_customer_contacts', {
createdBy: text('created_by').notNull(), createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull() updatedBy: text('updated_by').notNull()
}); });
export const crmEnquiries = pgTable(
'crm_enquiries',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
code: text('code').notNull(),
customerId: text('customer_id').notNull(),
contactId: text('contact_id'),
title: text('title').notNull(),
description: text('description'),
requirement: text('requirement'),
projectName: text('project_name'),
projectLocation: text('project_location'),
productType: text('product_type').notNull(),
status: text('status').notNull(),
priority: text('priority').notNull(),
leadChannel: text('lead_channel'),
estimatedValue: doublePrecision('estimated_value'),
chancePercent: integer('chance_percent'),
expectedCloseDate: timestamp('expected_close_date', { withTimezone: true }),
competitor: text('competitor'),
source: text('source'),
notes: text('notes'),
isHotProject: boolean('is_hot_project').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull()
},
(table) => ({
organizationCodeIdx: uniqueIndex('crm_enquiries_org_code_idx').on(
table.organizationId,
table.code
)
})
);
export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
followupDate: timestamp('followup_date', { withTimezone: true }).notNull(),
followupType: text('followup_type').notNull(),
contactId: text('contact_id'),
outcome: text('outcome'),
notes: text('notes'),
nextFollowupDate: timestamp('next_followup_date', { withTimezone: true }),
nextAction: text('next_action'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull()
});

View File

@@ -142,6 +142,13 @@ const FOUNDATION_OPTIONS = {
{ code: 'high', label: 'High', value: 'high', sortOrder: 3 }, { code: 'high', label: 'High', value: 'high', sortOrder: 3 },
{ code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 } { code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 }
], ],
crm_followup_type: [
{ code: 'call', label: 'Call', value: 'call', sortOrder: 1 },
{ code: 'meeting', label: 'Meeting', value: 'meeting', sortOrder: 2 },
{ code: 'site_visit', label: 'Site Visit', value: 'site_visit', sortOrder: 3 },
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
],
crm_lead_channel: [ crm_lead_channel: [
{ code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, { code: 'website', label: 'Website', value: 'website', sortOrder: 1 },
{ code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 },

View File

@@ -77,6 +77,15 @@ export interface CustomerActivityRecord {
actorName: string | null; actorName: string | null;
} }
export interface CustomerRelatedEnquiryItem {
id: string;
code: string;
title: string;
status: string;
productType: string;
updatedAt: string;
}
export interface CustomerReferenceData { export interface CustomerReferenceData {
branches: BranchOption[]; branches: BranchOption[];
customerTypes: CustomerOption[]; customerTypes: CustomerOption[];

View File

@@ -14,6 +14,7 @@ import type { CustomerReferenceData } from '../api/types';
import { CustomerFormSheet } from './customer-form-sheet'; import { CustomerFormSheet } from './customer-form-sheet';
import { CustomerContactsTab } from './customer-contacts-tab'; import { CustomerContactsTab } from './customer-contacts-tab';
import { CustomerStatusBadge } from './customer-status-badge'; import { CustomerStatusBadge } from './customer-status-badge';
import type { CustomerRelatedEnquiryItem } from '../api/types';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return ( return (
@@ -28,7 +29,8 @@ export function CustomerDetail({
customerId, customerId,
referenceData, referenceData,
canUpdate, canUpdate,
canManageContacts canManageContacts,
relatedEnquiries
}: { }: {
customerId: string; customerId: string;
referenceData: CustomerReferenceData; referenceData: CustomerReferenceData;
@@ -38,6 +40,7 @@ export function CustomerDetail({
update: boolean; update: boolean;
delete: boolean; delete: boolean;
}; };
relatedEnquiries: CustomerRelatedEnquiryItem[];
}) { }) {
const { data } = useSuspenseQuery(customerByIdOptions(customerId)); const { data } = useSuspenseQuery(customerByIdOptions(customerId));
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -212,14 +215,36 @@ export function CustomerDetail({
<CardHeader> <CardHeader>
<CardTitle>Related Documents</CardTitle> <CardTitle>Related Documents</CardTitle>
<CardDescription> <CardDescription>
Task C stops at customer and contact scope, so downstream CRM documents stay Production enquiry links are now visible here, while quotation and approval
intentionally deferred. modules stay deferred.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> {!relatedEnquiries.length ? (
Enquiry, quotation, and approval links will land here in Task D and later. <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
</div> No enquiries have been linked to this customer yet.
</div>
) : (
<div className='space-y-3'>
{relatedEnquiries.map((enquiry) => (
<Link
key={enquiry.id}
href={`/dashboard/crm/enquiries/${enquiry.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'>{enquiry.title}</div>
<div className='text-muted-foreground font-mono text-xs'>
{enquiry.code}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
</div>
</Link>
))}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View File

@@ -0,0 +1,74 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createEnquiry,
createEnquiryFollowup,
deleteEnquiry,
deleteEnquiryFollowup,
updateEnquiry,
updateEnquiryFollowup
} from './service';
import { enquiryKeys } from './queries';
import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types';
export const createEnquiryMutation = mutationOptions({
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
}
});
export const updateEnquiryMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMutationPayload }) =>
updateEnquiry(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.id) });
}
});
export const deleteEnquiryMutation = mutationOptions({
mutationFn: (id: string) => deleteEnquiry(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
}
});
export const createEnquiryFollowupMutation = mutationOptions({
mutationFn: ({
enquiryId,
values
}: {
enquiryId: string;
values: EnquiryFollowupMutationPayload;
}) => createEnquiryFollowup(enquiryId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
}
});
export const updateEnquiryFollowupMutation = mutationOptions({
mutationFn: ({
enquiryId,
followupId,
values
}: {
enquiryId: string;
followupId: string;
values: EnquiryFollowupMutationPayload;
}) => updateEnquiryFollowup(enquiryId, followupId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
}
});
export const deleteEnquiryFollowupMutation = mutationOptions({
mutationFn: ({ enquiryId, followupId }: { enquiryId: string; followupId: string }) =>
deleteEnquiryFollowup(enquiryId, followupId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
}
});

View File

@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query';
import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service';
import type { EnquiryFilters } from './types';
export const enquiryKeys = {
all: ['crm-enquiries'] as const,
list: (filters: EnquiryFilters) => [...enquiryKeys.all, 'list', filters] as const,
detail: (id: string) => [...enquiryKeys.all, 'detail', id] as const,
followups: (id: string) => [...enquiryKeys.all, 'followups', id] as const
};
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
queryOptions({
queryKey: enquiryKeys.list(filters),
queryFn: () => getEnquiries(filters)
});
export const enquiryByIdOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.detail(id),
queryFn: () => getEnquiryById(id)
});
export const enquiryFollowupsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.followups(id),
queryFn: () => getEnquiryFollowups(id)
});

View File

@@ -0,0 +1,83 @@
import { apiClient } from '@/lib/api-client';
import type {
EnquiryDetailResponse,
EnquiryFilters,
EnquiryFollowupMutationPayload,
EnquiryFollowupsResponse,
EnquiryListResponse,
EnquiryMutationPayload,
MutationSuccessResponse
} from './types';
export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryListResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.priority) searchParams.set('priority', filters.priority);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<EnquiryListResponse>(`/crm/enquiries${query ? `?${query}` : ''}`);
}
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
}
export async function createEnquiry(data: EnquiryMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateEnquiry(id: string, data: EnquiryMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteEnquiry(id: string) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
method: 'DELETE'
});
}
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
}
export async function createEnquiryFollowup(
enquiryId: string,
data: EnquiryFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateEnquiryFollowup(
enquiryId: string,
followupId: string,
data: EnquiryFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteEnquiryFollowup(enquiryId: string, followupId: string) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
method: 'DELETE'
});
}

View File

@@ -0,0 +1,189 @@
export interface EnquiryOption {
id: string;
code: string;
label: string;
value: string | null;
}
export interface EnquiryBranchOption {
id: string;
code: string;
name: string;
value: string | null;
}
export interface EnquiryCustomerLookup {
id: string;
code: string;
name: string;
branchId: string | null;
}
export interface EnquiryContactLookup {
id: string;
customerId: string;
name: string;
email: string | null;
mobile: string | null;
isPrimary: boolean;
}
export interface EnquiryRecord {
id: string;
organizationId: string;
branchId: string | null;
code: string;
customerId: string;
contactId: string | null;
title: string;
description: string | null;
requirement: string | null;
projectName: string | null;
projectLocation: string | null;
productType: string;
status: string;
priority: string;
leadChannel: string | null;
estimatedValue: number | null;
chancePercent: number | null;
expectedCloseDate: string | null;
competitor: string | null;
source: string | null;
notes: string | null;
isHotProject: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface EnquiryListItem extends EnquiryRecord {
customerName: string;
contactName: string | null;
followupCount: number;
}
export interface EnquiryFollowupRecord {
id: string;
organizationId: string;
enquiryId: string;
followupDate: string;
followupType: string;
contactId: string | null;
outcome: string | null;
notes: string | null;
nextFollowupDate: string | null;
nextAction: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface EnquiryActivityRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
}
export interface EnquiryReferenceData {
branches: EnquiryBranchOption[];
statuses: EnquiryOption[];
productTypes: EnquiryOption[];
priorities: EnquiryOption[];
leadChannels: EnquiryOption[];
followupTypes: EnquiryOption[];
customers: EnquiryCustomerLookup[];
contacts: EnquiryContactLookup[];
}
export interface EnquiryFilters {
page?: number;
limit?: number;
search?: string;
status?: string;
productType?: string;
priority?: string;
branch?: string;
customer?: string;
sort?: string;
}
export interface EnquiryListResponse {
success: boolean;
time: string;
message: string;
totalItems: number;
offset: number;
limit: number;
items: EnquiryListItem[];
}
export interface EnquiryDetailResponse {
success: boolean;
time: string;
message: string;
enquiry: EnquiryRecord;
activity: EnquiryActivityRecord[];
}
export interface EnquiryFollowupsResponse {
success: boolean;
time: string;
message: string;
items: EnquiryFollowupRecord[];
}
export interface EnquiryMutationPayload {
customerId: string;
contactId?: string | null;
title: string;
description?: string;
requirement?: string;
projectName?: string;
projectLocation?: string;
branchId?: string | null;
productType: string;
status: string;
priority: string;
leadChannel?: string | null;
estimatedValue?: number | null;
chancePercent?: number | null;
expectedCloseDate?: string | null;
competitor?: string;
source?: string;
isHotProject?: boolean;
notes?: string;
isActive?: boolean;
}
export interface EnquiryFollowupMutationPayload {
followupDate: string;
followupType: string;
contactId?: string | null;
outcome?: string;
notes?: string;
nextFollowupDate?: string | null;
nextAction?: string;
}
export interface EnquiryCustomerRelationItem {
id: string;
code: string;
title: string;
status: string;
productType: string;
updatedAt: string;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useMemo } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useSuspenseQuery } from '@tanstack/react-query';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { getSortingStateParser } from '@/lib/parsers';
import { enquiriesQueryOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import { getEnquiryColumns } from './enquiry-columns';
export function EnquiriesTable({
referenceData,
canUpdate,
canDelete
}: {
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canDelete: boolean;
}) {
const columns = useMemo(
() => getEnquiryColumns({ referenceData, canUpdate, canDelete }),
[referenceData, canUpdate, canDelete]
);
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
productType: parseAsString,
priority: parseAsString,
branch: parseAsString,
customer: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.productType && { productType: params.productType }),
...(params.priority && { priority: params.priority }),
...(params.branch && { branch: params.branch }),
...(params.customer && { customer: params.customer }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
shallow: true,
debounceMs: 500,
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { AlertModal } from '@/components/modal/alert-modal';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { deleteEnquiryMutation } from '../api/mutations';
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
import { EnquiryFormSheet } from './enquiry-form-sheet';
export function EnquiryCellAction({
data,
referenceData,
canUpdate,
canDelete
}: {
data: EnquiryListItem;
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canDelete: boolean;
}) {
const router = useRouter();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const deleteMutation = useMutation({
...deleteEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry deleted successfully');
setDeleteOpen(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry')
});
return (
<>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<EnquiryFormSheet
enquiry={data}
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<span className='sr-only'>Open actions</span>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/enquiries/${data.id}`)}>
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import Link from 'next/link';
import type { Column, ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Icons } from '@/components/icons';
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
import { EnquiryCellAction } from './enquiry-cell-action';
import { EnquiryStatusBadge } from './enquiry-status-badge';
export function getEnquiryColumns({
referenceData,
canUpdate,
canDelete
}: {
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canDelete: boolean;
}): ColumnDef<EnquiryListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item]));
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
return [
{
id: 'title',
accessorKey: 'title',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Enquiry' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<Link
href={`/dashboard/crm/enquiries/${row.original.id}`}
className='font-medium hover:underline'
>
{row.original.title}
</Link>
<span className='text-muted-foreground text-xs'>
{row.original.code} {row.original.customerName}
</span>
</div>
),
meta: {
label: 'Enquiry',
placeholder: 'Search enquiries...',
variant: 'text' as const,
icon: Icons.search
},
enableColumnFilter: true
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const status = statusMap.get(row.original.status);
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
},
meta: {
label: 'Status',
variant: 'multiSelect' as const,
options: referenceData.statuses.map((item) => ({
value: item.id,
label: item.label
}))
},
enableColumnFilter: true
},
{
id: 'productType',
accessorKey: 'productType',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Product Type' />
),
cell: ({ row }) => (
<Badge variant='outline'>
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
</Badge>
),
meta: {
label: 'Product Type',
variant: 'multiSelect' as const,
options: referenceData.productTypes.map((item) => ({
value: item.id,
label: item.label
}))
},
enableColumnFilter: true
},
{
id: 'priority',
accessorKey: 'priority',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Priority' />
),
cell: ({ row }) => (
<Badge variant='secondary'>
{priorityMap.get(row.original.priority)?.label ?? 'Unknown'}
</Badge>
),
meta: {
label: 'Priority',
variant: 'multiSelect' as const,
options: referenceData.priorities.map((item) => ({
value: item.id,
label: item.label
}))
},
enableColumnFilter: true
},
{
id: 'branch',
accessorFn: (row) => row.branchId ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Branch' />
),
cell: ({ row }) => (
<span>
{row.original.branchId
? (branchMap.get(row.original.branchId)?.name ?? 'Unknown')
: 'Unassigned'}
</span>
),
meta: {
label: 'Branch',
variant: 'multiSelect' as const,
options: referenceData.branches.map((item) => ({
value: item.id,
label: item.name
}))
},
enableColumnFilter: true
},
{
id: 'customer',
accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' />
),
cell: ({ row }) => <span>{row.original.customerName}</span>,
meta: {
label: 'Customer',
variant: 'multiSelect' as const,
options: referenceData.customers.map((item) => ({
value: item.id,
label: item.name
}))
},
enableColumnFilter: true
},
{
id: 'followupCount',
accessorKey: 'followupCount',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Follow-ups' />
),
cell: ({ row }) => <span>{row.original.followupCount}</span>
},
{
id: 'actions',
cell: ({ row }) => (
<EnquiryCellAction
data={row.original}
referenceData={referenceData}
canUpdate={canUpdate}
canDelete={canDelete}
/>
)
}
];
}

View File

@@ -0,0 +1,288 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
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 type { EnquiryReferenceData } from '../api/types';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryStatusBadge } from './enquiry-status-badge';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='text-sm'>{value || '-'}</div>
</div>
);
}
export function EnquiryDetail({
enquiryId,
referenceData,
canUpdate,
canManageFollowups
}: {
enquiryId: string;
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canManageFollowups: {
create: boolean;
update: boolean;
delete: boolean;
};
}) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const [editOpen, setEditOpen] = useState(false);
const enquiry = data.enquiry;
const branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
[referenceData]
);
const customerMap = useMemo(
() => new Map(referenceData.customers.map((item) => [item.id, item])),
[referenceData]
);
const contactMap = useMemo(
() => new Map(referenceData.contacts.map((item) => [item.id, item])),
[referenceData]
);
const statusMap = useMemo(
() => new Map(referenceData.statuses.map((item) => [item.id, item])),
[referenceData]
);
const productTypeMap = useMemo(
() => new Map(referenceData.productTypes.map((item) => [item.id, item])),
[referenceData]
);
const priorityMap = useMemo(
() => new Map(referenceData.priorities.map((item) => [item.id, item])),
[referenceData]
);
const leadChannelMap = useMemo(
() => new Map(referenceData.leadChannels.map((item) => [item.id, item])),
[referenceData]
);
const customer = customerMap.get(enquiry.customerId);
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
const status = statusMap.get(enquiry.status);
return (
<div className='space-y-6'>
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-3'>
<div className='flex items-center gap-2'>
<Button asChild variant='ghost' size='icon'>
<Link href='/dashboard/crm/enquiries'>
<Icons.chevronLeft className='h-4 w-4' />
</Link>
</Button>
<span className='text-muted-foreground font-mono text-sm'>{enquiry.code}</span>
<EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
{enquiry.isHotProject ? <Badge>Hot</Badge> : null}
</div>
<div>
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
<p className='text-muted-foreground text-sm'>
{customer?.name ?? 'Unknown customer'}
{contact ? `${contact.name}` : ''}
</p>
</div>
</div>
<div className='flex flex-wrap gap-2'>
{canUpdate ? (
<Button variant='outline' onClick={() => setEditOpen(true)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
</Button>
) : null}
</div>
</div>
<div className='grid gap-6 lg:grid-cols-3'>
<div className='space-y-6 lg:col-span-2'>
<Card>
<CardContent className='pt-6'>
<Tabs defaultValue='overview' className='gap-4'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
</TabsList>
<TabsContent value='overview'>
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>
Opportunity scope, project context, and commercial direction.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Customer' value={customer?.name} />
<FieldItem label='Contact' value={contact?.name} />
<FieldItem
label='Branch'
value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null}
/>
<FieldItem label='Active' value={enquiry.isActive ? 'Yes' : 'No'} />
<FieldItem
label='Product Type'
value={productTypeMap.get(enquiry.productType)?.label}
/>
<FieldItem
label='Priority'
value={priorityMap.get(enquiry.priority)?.label}
/>
<FieldItem
label='Lead Channel'
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
/>
<FieldItem
label='Expected Close Date'
value={
enquiry.expectedCloseDate
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
: null
}
/>
<FieldItem
label='Estimated Value'
value={
enquiry.estimatedValue !== null
? enquiry.estimatedValue.toLocaleString()
: null
}
/>
<FieldItem
label='Chance %'
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
/>
<FieldItem label='Competitor' value={enquiry.competitor} />
<FieldItem label='Source' value={enquiry.source} />
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem
label='Project'
value={
[enquiry.projectName, enquiry.projectLocation]
.filter(Boolean)
.join(' • ') || null
}
/>
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Description' value={enquiry.description} />
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Requirement' value={enquiry.requirement} />
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Notes' value={enquiry.notes} />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='followups'>
<EnquiryFollowupsTab
enquiryId={enquiryId}
customerId={enquiry.customerId}
referenceData={referenceData}
canCreate={canManageFollowups.create}
canUpdate={canManageFollowups.update}
canDelete={canManageFollowups.delete}
/>
</TabsContent>
<TabsContent value='activity'>
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Audit events for the enquiry and its follow-up records.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{!data.activity.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No activity recorded yet.
</div>
) : (
data.activity.map((item, index) => (
<div key={item.id} className='space-y-4'>
<div className='flex items-start gap-3'>
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{item.action}
</Badge>
<Badge variant='secondary'>
{item.entityType.replaceAll('_', ' ')}
</Badge>
<span className='text-sm font-medium'>
{item.actorName ?? item.userId}
</span>
</div>
<div className='text-muted-foreground text-xs'>
{new Date(item.createdAt).toLocaleString()}
</div>
</div>
</div>
{index < data.activity.length - 1 ? <Separator /> : null}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='related'>
<Card>
<CardHeader>
<CardTitle>Related Quotations</CardTitle>
<CardDescription>
Task D ends at enquiry readiness, so quotation production stays
intentionally deferred.
</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>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Record Snapshot</CardTitle>
<CardDescription>Operational metadata for this enquiry row.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem label='Created By' value={enquiry.createdBy} />
<FieldItem label='Updated By' value={enquiry.updatedBy} />
<FieldItem label='Created At' value={new Date(enquiry.createdAt).toLocaleString()} />
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} />
</CardContent>
</Card>
</div>
</div>
<EnquiryFormSheet
enquiry={enquiry}
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
/>
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { AlertModal } from '@/components/modal/alert-modal';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { deleteEnquiryFollowupMutation } from '../api/mutations';
import { enquiryFollowupsOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import { FollowupFormSheet } from './followup-form-sheet';
export function EnquiryFollowupsTab({
enquiryId,
customerId,
referenceData,
canCreate,
canUpdate,
canDelete
}: {
enquiryId: string;
customerId: string;
referenceData: EnquiryReferenceData;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}) {
const { data } = useSuspenseQuery(enquiryFollowupsOptions(enquiryId));
const [selectedId, setSelectedId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const selected = data.items.find((item) => item.id === selectedId);
const followupTypeMap = new Map(referenceData.followupTypes.map((item) => [item.id, item.label]));
const deleteMutation = useMutation({
...deleteEnquiryFollowupMutation,
onSuccess: () => {
toast.success('Follow-up deleted successfully');
setDeleteOpen(false);
setSelectedId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete follow-up')
});
return (
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle>Follow-ups</CardTitle>
<CardDescription>
Track the latest contact moments, outcomes, and next steps for this enquiry.
</CardDescription>
</div>
{canCreate ? (
<Button
size='sm'
onClick={() => {
setSelectedId(null);
setOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Follow-up
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-4'>
{!data.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No follow-ups recorded yet.
</div>
) : (
data.items.map((item) => (
<div
key={item.id}
className='flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-start md:justify-between'
>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline'>
{followupTypeMap.get(item.followupType) ?? 'Unknown type'}
</Badge>
<span className='text-sm font-medium'>
{new Date(item.followupDate).toLocaleDateString()}
</span>
</div>
{item.outcome ? <p className='text-sm'>{item.outcome}</p> : null}
{item.nextAction ? (
<p className='text-muted-foreground text-sm'>Next action: {item.nextAction}</p>
) : null}
{item.nextFollowupDate ? (
<p className='text-muted-foreground text-sm'>
Next follow-up: {new Date(item.nextFollowupDate).toLocaleDateString()}
</p>
) : null}
{item.notes ? <p className='text-muted-foreground text-sm'>{item.notes}</p> : null}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={!canUpdate}
onClick={() => {
setSelectedId(item.id);
setOpen(true);
}}
>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</Button>
<Button
variant='outline'
size='sm'
disabled={!canDelete}
onClick={() => {
setSelectedId(item.id);
setDeleteOpen(true);
}}
>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</Button>
</div>
</div>
))
)}
</CardContent>
<FollowupFormSheet
enquiryId={enquiryId}
followup={selected}
open={open}
onOpenChange={setOpen}
referenceData={referenceData}
customerId={customerId}
/>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
if (selectedId) {
deleteMutation.mutate({ enquiryId, followupId: selectedId });
}
}}
loading={deleteMutation.isPending}
/>
</Card>
);
}

View File

@@ -0,0 +1,377 @@
'use client';
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
function toDefaultValues(
enquiry: EnquiryRecord | undefined,
referenceData: EnquiryReferenceData
): EnquiryFormValues {
return {
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: enquiry?.contactId ?? undefined,
title: enquiry?.title ?? '',
description: enquiry?.description ?? '',
requirement: enquiry?.requirement ?? '',
projectName: enquiry?.projectName ?? '',
projectLocation: enquiry?.projectLocation ?? '',
branchId: enquiry?.branchId ?? undefined,
productType: enquiry?.productType ?? referenceData.productTypes[0]?.id ?? '',
status: enquiry?.status ?? referenceData.statuses[0]?.id ?? '',
priority:
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
leadChannel: enquiry?.leadChannel ?? undefined,
estimatedValue: enquiry?.estimatedValue ? String(enquiry.estimatedValue) : '',
chancePercent:
enquiry?.chancePercent !== null && enquiry?.chancePercent !== undefined
? String(enquiry.chancePercent)
: '',
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
competitor: enquiry?.competitor ?? '',
source: enquiry?.source ?? '',
isHotProject: enquiry?.isHotProject ?? false,
notes: enquiry?.notes ?? '',
isActive: enquiry?.isActive ?? true
};
}
export function EnquiryFormSheet({
enquiry,
open,
onOpenChange,
referenceData
}: {
enquiry?: EnquiryRecord;
open: boolean;
onOpenChange: (open: boolean) => void;
referenceData: EnquiryReferenceData;
}) {
const isEdit = !!enquiry;
const defaultValues = useMemo(
() => toDefaultValues(enquiry, referenceData),
[enquiry, referenceData]
);
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
useFormFields<EnquiryFormValues>();
const createMutation = useMutation({
...createEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry created successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create enquiry')
});
const updateMutation = useMutation({
...updateEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry updated successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update enquiry')
});
const contactsForCustomer = referenceData.contacts.filter(
(contact) => contact.customerId === selectedCustomerId
);
const form = useAppForm({
defaultValues,
validators: {
onSubmit: enquirySchema
},
onSubmit: async ({ value }) => {
const payload: EnquiryMutationPayload = {
customerId: value.customerId,
contactId: value.contactId || null,
title: value.title,
description: value.description,
requirement: value.requirement,
projectName: value.projectName,
projectLocation: value.projectLocation,
branchId: value.branchId || null,
productType: value.productType,
status: value.status,
priority: value.priority,
leadChannel: value.leadChannel || null,
estimatedValue: value.estimatedValue ? Number(value.estimatedValue) : null,
chancePercent: value.chancePercent ? Number(value.chancePercent) : null,
expectedCloseDate: value.expectedCloseDate || null,
competitor: value.competitor,
source: value.source,
isHotProject: value.isHotProject ?? false,
notes: value.notes,
isActive: value.isActive ?? true
};
if (isEdit && enquiry) {
await updateMutation.mutateAsync({ id: enquiry.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
});
useEffect(() => {
if (!open) {
return;
}
setSelectedCustomerId(defaultValues.customerId);
form.reset(defaultValues);
}, [defaultValues, form, open]);
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-4xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
<SheetDescription>
Capture the opportunity details before quotation and approval stages begin.
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='enquiry-form-sheet' className='grid gap-4 md:grid-cols-2'>
<form.AppField
name='customerId'
children={(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Customer *</field.FieldLabel>
<Select
value={field.state.value ?? ''}
onValueChange={(value) => {
field.handleChange(value);
field.handleBlur();
setSelectedCustomerId(value);
form.setFieldValue('contactId', '');
}}
>
<SelectTrigger aria-label='Customer'>
<SelectValue placeholder='Select customer' />
</SelectTrigger>
<SelectContent>
{referenceData.customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name} ({customer.code})
</SelectItem>
))}
</SelectContent>
</Select>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
<form.AppField
name='contactId'
children={(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Contact</field.FieldLabel>
<Select
value={field.state.value ?? ''}
onValueChange={(value) => {
field.handleChange(value === '__none__' ? '' : value);
field.handleBlur();
}}
>
<SelectTrigger aria-label='Contact'>
<SelectValue placeholder='Select contact' />
</SelectTrigger>
<SelectContent>
{contactsForCustomer.length ? (
contactsForCustomer.map((contact) => (
<SelectItem key={contact.id} value={contact.id}>
{contact.name}
{contact.isPrimary ? ' (Primary)' : ''}
</SelectItem>
))
) : (
<SelectItem value='__none__'>No contact selected</SelectItem>
)}
</SelectContent>
</Select>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
<FormTextField
name='title'
label='Title'
required
placeholder='Warehouse crane upgrade opportunity'
/>
<FormTextField
name='projectName'
label='Project Name'
placeholder='Bangna Warehouse Expansion'
/>
<FormTextField
name='projectLocation'
label='Project Location'
placeholder='Bangkok'
/>
<FormTextField
name='source'
label='Source'
placeholder='Existing customer referral'
/>
<FormSelectField
name='branchId'
label='Branch'
options={referenceData.branches.map((branch) => ({
value: branch.id,
label: branch.name
}))}
/>
<FormSelectField
name='productType'
label='Product Type'
required
options={referenceData.productTypes.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<FormSelectField
name='status'
label='Status'
required
options={referenceData.statuses.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<FormSelectField
name='priority'
label='Priority'
required
options={referenceData.priorities.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<FormSelectField
name='leadChannel'
label='Lead Channel'
options={referenceData.leadChannels.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<FormTextField
name='estimatedValue'
label='Estimated Value'
type='number'
placeholder='2500000'
/>
<FormTextField name='chancePercent' label='Chance %' type='number' placeholder='60' />
<FormTextField
name='expectedCloseDate'
label='Expected Close Date'
placeholder='YYYY-MM-DD'
/>
<FormTextField name='competitor' label='Competitor' placeholder='Other vendor name' />
<div className='md:col-span-2'>
<FormTextareaField
name='description'
label='Description'
placeholder='High-level summary of the opportunity'
/>
</div>
<div className='md:col-span-2'>
<FormTextareaField
name='requirement'
label='Requirement'
placeholder='Specific technical or commercial requirements'
/>
</div>
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
label='Notes'
placeholder='Internal notes, blockers, or guidance for the sales team'
/>
</div>
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
<FormSwitchField
name='isHotProject'
label='Hot Project'
description='Use for urgent or highly strategic opportunities.'
/>
<FormSwitchField
name='isActive'
label='Active Record'
description='Inactive enquiries stay in history but should not progress further.'
/>
</div>
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export function EnquiryFormSheetTrigger({
referenceData
}: {
referenceData: EnquiryReferenceData;
}) {
const [open, setOpen] = React.useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> Add Enquiry
</Button>
<EnquiryFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
</>
);
}

View File

@@ -0,0 +1,46 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { EnquiryReferenceData } from '../api/types';
import { enquiriesQueryOptions } from '../api/queries';
import { EnquiriesTable } from './enquiries-table';
export default function EnquiryListing({
referenceData,
canUpdate,
canDelete
}: {
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canDelete: boolean;
}) {
const page = searchParamsCache.get('page');
const limit = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('status');
const productType = searchParamsCache.get('productType');
const priority = searchParamsCache.get('priority');
const branch = searchParamsCache.get('branch');
const customer = searchParamsCache.get('customer');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit,
...(search && { search }),
...(status && { status }),
...(productType && { productType }),
...(priority && { priority }),
...(branch && { branch }),
...(customer && { customer }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Icons } from '@/components/icons';
export function EnquiryStatusBadge({ code, label }: { code?: string | null; label: string }) {
const normalized = code?.toLowerCase() ?? '';
const variant =
normalized === 'closed_lost' || normalized === 'cancelled'
? 'outline'
: normalized === 'converted'
? 'default'
: normalized === 'follow_up'
? 'secondary'
: 'secondary';
const Icon =
normalized === 'converted'
? Icons.circleCheck
: normalized === 'closed_lost' || normalized === 'cancelled'
? Icons.warning
: normalized === 'follow_up'
? Icons.clock
: Icons.circle;
return (
<Badge variant={variant} className='capitalize'>
<Icon className='h-3 w-3' />
{label}
</Badge>
);
}

View File

@@ -0,0 +1,249 @@
'use client';
import { useEffect, useMemo } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { createEnquiryFollowupMutation, updateEnquiryFollowupMutation } from '../api/mutations';
import type {
EnquiryFollowupMutationPayload,
EnquiryFollowupRecord,
EnquiryReferenceData
} from '../api/types';
import { enquiryFollowupSchema, type EnquiryFollowupFormValues } from '../schemas/enquiry.schema';
function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFollowupFormValues {
return {
followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '',
followupType: followup?.followupType ?? '',
contactId: followup?.contactId ?? undefined,
outcome: followup?.outcome ?? '',
notes: followup?.notes ?? '',
nextFollowupDate: followup?.nextFollowupDate ? followup.nextFollowupDate.slice(0, 10) : '',
nextAction: followup?.nextAction ?? ''
};
}
export function FollowupFormSheet({
enquiryId,
followup,
open,
onOpenChange,
referenceData,
customerId
}: {
enquiryId: string;
followup?: EnquiryFollowupRecord;
open: boolean;
onOpenChange: (open: boolean) => void;
referenceData: EnquiryReferenceData;
customerId: string;
}) {
const isEdit = !!followup;
const defaultValues = useMemo(() => toDefaultValues(followup), [followup]);
const { FormTextField, FormTextareaField } = useFormFields<EnquiryFollowupFormValues>();
const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId);
const createMutation = useMutation({
...createEnquiryFollowupMutation,
onSuccess: () => {
toast.success('Follow-up created successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create follow-up')
});
const updateMutation = useMutation({
...updateEnquiryFollowupMutation,
onSuccess: () => {
toast.success('Follow-up updated successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update follow-up')
});
const form = useAppForm({
defaultValues,
validators: {
onSubmit: enquiryFollowupSchema
},
onSubmit: async ({ value }) => {
const payload: EnquiryFollowupMutationPayload = {
followupDate: value.followupDate,
followupType: value.followupType,
contactId: value.contactId || null,
outcome: value.outcome,
notes: value.notes,
nextFollowupDate: value.nextFollowupDate || null,
nextAction: value.nextAction
};
if (isEdit && followup) {
await updateMutation.mutateAsync({
enquiryId,
followupId: followup.id,
values: payload
});
return;
}
await createMutation.mutateAsync({
enquiryId,
values: payload
});
}
});
useEffect(() => {
if (!open) {
return;
}
form.reset(defaultValues);
}, [defaultValues, form, open]);
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-2xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Follow-up' : 'New Follow-up'}</SheetTitle>
<SheetDescription>
Keep the enquiry timeline current with the latest customer interaction and next step.
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='enquiry-followup-form' className='grid gap-4 md:grid-cols-2'>
<FormTextField
name='followupDate'
label='Follow-up Date'
required
placeholder='YYYY-MM-DD'
/>
<form.AppField
name='followupType'
children={(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Follow-up Type *</field.FieldLabel>
<Select
value={field.state.value ?? ''}
onValueChange={(value) => {
field.handleChange(value === '__none__' ? '' : value);
field.handleBlur();
}}
>
<SelectTrigger aria-label='Follow-up type'>
<SelectValue placeholder='Select follow-up type' />
</SelectTrigger>
<SelectContent>
{referenceData.followupTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
<form.AppField
name='contactId'
children={(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Contact</field.FieldLabel>
<Select
value={field.state.value ?? ''}
onValueChange={(value) => {
field.handleChange(value);
field.handleBlur();
}}
>
<SelectTrigger aria-label='Follow-up contact'>
<SelectValue placeholder='Select contact' />
</SelectTrigger>
<SelectContent>
{contactOptions.length ? (
contactOptions.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))
) : (
<SelectItem value='__none__'>No contact selected</SelectItem>
)}
</SelectContent>
</Select>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
<FormTextField
name='nextFollowupDate'
label='Next Follow-up Date'
placeholder='YYYY-MM-DD'
/>
<div className='md:col-span-2'>
<FormTextField
name='nextAction'
label='Next Action'
placeholder='Prepare revised technical proposal'
/>
</div>
<div className='md:col-span-2'>
<FormTextareaField
name='outcome'
label='Outcome'
placeholder='Customer asked for revised dimensions and a site survey'
/>
</div>
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
label='Notes'
placeholder='Internal notes from the follow-up session'
/>
</div>
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='enquiry-followup-form' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Follow-up' : 'Create Follow-up'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,47 @@
import * as z from 'zod';
export const enquirySchema = z.object({
customerId: z.string().min(1, 'Please select a customer'),
contactId: z.string().optional().nullable(),
title: z.string().min(2, 'Title must be at least 2 characters'),
description: z.string().optional(),
requirement: z.string().optional(),
projectName: z.string().optional(),
projectLocation: z.string().optional(),
branchId: z.string().optional().nullable(),
productType: z.string().min(1, 'Please select a product type'),
status: z.string().min(1, 'Please select a status'),
priority: z.string().min(1, 'Please select a priority'),
leadChannel: z.string().optional().nullable(),
estimatedValue: z
.string()
.optional()
.refine((value) => !value || !Number.isNaN(Number(value)), 'Estimated value must be a number'),
chancePercent: z
.string()
.optional()
.refine((value) => !value || !Number.isNaN(Number(value)), 'Chance percent must be a number')
.refine(
(value) => !value || (Number(value) >= 0 && Number(value) <= 100),
'Chance percent must be between 0 and 100'
),
expectedCloseDate: z.string().optional().nullable(),
competitor: z.string().optional(),
source: z.string().optional(),
isHotProject: z.boolean().default(false),
notes: z.string().optional(),
isActive: z.boolean().default(true)
});
export const enquiryFollowupSchema = z.object({
followupDate: z.string().min(1, 'Follow-up date is required'),
followupType: z.string().min(1, 'Please select a follow-up type'),
contactId: z.string().optional().nullable(),
outcome: z.string().optional(),
notes: z.string().optional(),
nextFollowupDate: z.string().optional().nullable(),
nextAction: z.string().optional()
});
export type EnquiryFormValues = z.input<typeof enquirySchema>;
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;

View File

@@ -0,0 +1,734 @@
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import {
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmEnquiryFollowups,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import type {
EnquiryActivityRecord,
EnquiryBranchOption,
EnquiryContactLookup,
EnquiryCustomerLookup,
EnquiryCustomerRelationItem,
EnquiryFilters,
EnquiryFollowupMutationPayload,
EnquiryFollowupRecord,
EnquiryListItem,
EnquiryMutationPayload,
EnquiryOption,
EnquiryRecord,
EnquiryReferenceData
} from '../api/types';
const ENQUIRY_OPTION_CATEGORIES = {
status: 'crm_enquiry_status',
productType: 'crm_product_type',
priority: 'crm_priority',
leadChannel: 'crm_lead_channel',
followupType: 'crm_followup_type'
} as const;
function mapOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
): EnquiryOption {
return {
id: option.id,
code: option.code,
label: option.label,
value: option.value
};
}
function mapBranch(
branch: Awaited<ReturnType<typeof getUserBranches>>[number]
): EnquiryBranchOption {
return {
id: branch.id,
code: branch.code,
name: branch.name,
value: branch.value
};
}
function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId,
code: row.code,
customerId: row.customerId,
contactId: row.contactId,
title: row.title,
description: row.description,
requirement: row.requirement,
projectName: row.projectName,
projectLocation: row.projectLocation,
productType: row.productType,
status: row.status,
priority: row.priority,
leadChannel: row.leadChannel,
estimatedValue: row.estimatedValue,
chancePercent: row.chancePercent,
expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null,
competitor: row.competitor,
source: row.source,
notes: row.notes,
isHotProject: row.isHotProject,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy
};
}
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
return {
id: row.id,
organizationId: row.organizationId,
enquiryId: row.enquiryId,
followupDate: row.followupDate.toISOString(),
followupType: row.followupType,
contactId: row.contactId,
outcome: row.outcome,
notes: row.notes,
nextFollowupDate: row.nextFollowupDate?.toISOString() ?? null,
nextAction: row.nextAction,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy
};
}
function parseSort(sort?: string) {
if (!sort) {
return desc(crmEnquiries.updatedAt);
}
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
const [rule] = parsed;
if (!rule) {
return desc(crmEnquiries.updatedAt);
}
const sortMap = {
code: crmEnquiries.code,
title: crmEnquiries.title,
status: crmEnquiries.status,
priority: crmEnquiries.priority,
productType: crmEnquiries.productType,
branch: crmEnquiries.branchId,
customer: crmEnquiries.customerId,
updatedAt: crmEnquiries.updatedAt
} as const;
const column = sortMap[rule.id as keyof typeof sortMap];
if (!column) {
return desc(crmEnquiries.updatedAt);
}
return rule.desc ? desc(column) : asc(column);
} catch {
return desc(crmEnquiries.updatedAt);
}
}
function splitFilterValue(value?: string) {
return value
? value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean)
: [];
}
async function resolveValidOptionIds(category: string, organizationId: string) {
const options = await getActiveOptionsByCategory(category, { organizationId });
return new Set(options.map((option) => option.id));
}
async function assertMasterOptionValue(
organizationId: string,
category: string,
optionId?: string | null
) {
if (!optionId) {
return;
}
const validIds = await resolveValidOptionIds(category, organizationId);
if (!validIds.has(optionId)) {
throw new AuthError(`Invalid option for ${category}`, 400);
}
}
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)
)
});
if (!customer) {
throw new AuthError('Customer not found', 404);
}
return customer;
}
export async function assertContactBelongsToOrganization(
contactId: string,
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)] : [])
)
});
if (!contact) {
throw new AuthError('Contact not found', 404);
}
return contact;
}
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)
)
});
if (!enquiry) {
throw new AuthError('Enquiry not found', 404);
}
return enquiry;
}
async function assertFollowupBelongsToEnquiry(
followupId: string,
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)
)
});
if (!followup) {
throw new AuthError('Follow-up not found', 404);
}
return followup;
}
async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (payload.contactId) {
await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId);
}
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status);
await assertMasterOptionValue(
organizationId,
ENQUIRY_OPTION_CATEGORIES.productType,
payload.productType
);
await assertMasterOptionValue(
organizationId,
ENQUIRY_OPTION_CATEGORIES.priority,
payload.priority
);
await assertMasterOptionValue(
organizationId,
ENQUIRY_OPTION_CATEGORIES.leadChannel,
payload.leadChannel ?? null
);
if (payload.branchId) {
await validateBranchAccess(payload.branchId);
}
}
async function validateFollowupPayload(
organizationId: string,
enquiryId: string,
payload: EnquiryFollowupMutationPayload
) {
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
await assertMasterOptionValue(
organizationId,
ENQUIRY_OPTION_CATEGORIES.followupType,
payload.followupType
);
if (payload.contactId) {
await assertContactBelongsToOrganization(payload.contactId, organizationId, enquiry.customerId);
}
}
function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] {
const statuses = splitFilterValue(filters.status);
const productTypes = splitFilterValue(filters.productType);
const priorities = splitFilterValue(filters.priority);
const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer);
return [
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt),
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
...(branches.length ? [inArray(crmEnquiries.branchId, branches)] : []),
...(customers.length ? [inArray(crmEnquiries.customerId, customers)] : []),
...(filters.search
? [
or(
ilike(crmEnquiries.code, `%${filters.search}%`),
ilike(crmEnquiries.title, `%${filters.search}%`),
ilike(crmEnquiries.projectName, `%${filters.search}%`),
ilike(crmEnquiries.competitor, `%${filters.search}%`)
)!
]
: [])
];
}
export async function getEnquiryReferenceData(
organizationId: string
): Promise<EnquiryReferenceData> {
const [
branches,
statuses,
productTypes,
priorities,
leadChannels,
followupTypes,
customers,
contacts
] = await Promise.all([
getUserBranches(),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
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)]
})
]);
return {
branches: branches.map(mapBranch),
statuses: statuses.map(mapOption),
productTypes: productTypes.map(mapOption),
priorities: priorities.map(mapOption),
leadChannels: leadChannels.map(mapOption),
followupTypes: followupTypes.map(mapOption),
customers: customers.map((customer) => ({
id: customer.id,
code: customer.code,
name: customer.name,
branchId: customer.branchId
})),
contacts: contacts.map((contact) => ({
id: contact.id,
customerId: contact.customerId,
name: contact.name,
email: contact.email,
mobile: contact.mobile,
isPrimary: contact.isPrimary
}))
};
}
export async function listEnquiries(
organizationId: string,
filters: EnquiryFilters
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildEnquiryFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ value: count() }).from(crmEnquiries).where(where);
const rows = await db
.select()
.from(crmEnquiries)
.where(where)
.orderBy(parseSort(filters.sort))
.limit(limit)
.offset(offset);
const customerIds = [...new Set(rows.map((row) => row.customerId))];
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean) as string[])];
const enquiryIds = rows.map((row) => row.id);
const [customers, contacts, followupCounts] = await Promise.all([
customerIds.length
? db.query.crmCustomers.findMany({
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)
)
})
: [],
enquiryIds.length
? db
.select({
enquiryId: crmEnquiryFollowups.enquiryId,
value: count()
})
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, organizationId),
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.groupBy(crmEnquiryFollowups.enquiryId)
: []
]);
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
return {
items: rows.map((row) => ({
...mapEnquiryRecord(row),
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
followupCount: followupCountMap.get(row.id) ?? 0
})),
totalItems: totalResult?.value ?? 0
};
}
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
return mapEnquiryRecord(enquiry);
}
export async function getEnquiryActivity(
id: string,
organizationId: string
): Promise<EnquiryActivityRecord[]> {
const auditLogs = await listAuditLogs({
organizationId,
entityId: id,
limit: 30
});
const filteredLogs = auditLogs.filter(
(log) =>
(log.entityType === 'crm_enquiry' && log.entityId === id) ||
(log.entityType === 'crm_enquiry_followup' &&
((log.afterData as { enquiryId?: string } | null)?.enquiryId === id ||
(log.beforeData as { enquiryId?: string } | null)?.enquiryId === id))
);
const userIds = [...new Set(filteredLogs.map((log) => log.userId))];
const actorRows = userIds.length
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, userIds))
: [];
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
return filteredLogs.map((log) => ({
id: log.id,
action: log.action,
entityType: log.entityType,
entityId: log.entityId,
createdAt: log.createdAt,
userId: log.userId,
actorName: actorMap.get(log.userId) ?? null
}));
}
export async function createEnquiry(
organizationId: string,
userId: string,
payload: EnquiryMutationPayload
) {
await validateEnquiryPayload(organizationId, payload);
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'enquiry',
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 created;
}
export async function updateEnquiry(
id: string,
organizationId: string,
userId: string,
payload: EnquiryMutationPayload
) {
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 updated;
}
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
await assertEnquiryBelongsToOrganization(id, organizationId);
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
deletedAt: now,
updatedAt: now,
updatedBy: userId,
isActive: false
})
.where(eq(crmEnquiries.id, id))
.returning();
await db
.update(crmEnquiryFollowups)
.set({
deletedAt: now,
updatedAt: now,
updatedBy: userId
})
.where(
and(
eq(crmEnquiryFollowups.enquiryId, id),
eq(crmEnquiryFollowups.organizationId, organizationId)
)
);
return updated;
}
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)]
});
return rows.map(mapFollowupRecord);
}
export async function createEnquiryFollowup(
enquiryId: string,
organizationId: string,
userId: string,
payload: EnquiryFollowupMutationPayload
) {
await validateFollowupPayload(organizationId, enquiryId, payload);
const [created] = await db
.insert(crmEnquiryFollowups)
.values({
id: crypto.randomUUID(),
organizationId,
enquiryId,
followupDate: new Date(payload.followupDate),
followupType: payload.followupType,
contactId: payload.contactId ?? null,
outcome: payload.outcome?.trim() || null,
notes: payload.notes?.trim() || null,
nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null,
nextAction: payload.nextAction?.trim() || null,
createdBy: userId,
updatedBy: userId
})
.returning();
return created;
}
export async function updateEnquiryFollowup(
enquiryId: string,
followupId: string,
organizationId: string,
userId: string,
payload: EnquiryFollowupMutationPayload
) {
await validateFollowupPayload(organizationId, enquiryId, payload);
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
const [updated] = await db
.update(crmEnquiryFollowups)
.set({
followupDate: new Date(payload.followupDate),
followupType: payload.followupType,
contactId: payload.contactId ?? null,
outcome: payload.outcome?.trim() || null,
notes: payload.notes?.trim() || null,
nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null,
nextAction: payload.nextAction?.trim() || null,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmEnquiryFollowups.id, followupId))
.returning();
return updated;
}
export async function softDeleteEnquiryFollowup(
enquiryId: string,
followupId: string,
organizationId: string,
userId: string
) {
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
const [updated] = await db
.update(crmEnquiryFollowups)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmEnquiryFollowups.id, followupId))
.returning();
return updated;
}
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
});
return rows.map((row) => ({
id: row.id,
code: row.code,
title: row.title,
status: row.status,
productType: row.productType,
updatedAt: row.updatedAt.toISOString()
}));
}

View File

@@ -26,7 +26,15 @@ export const PERMISSIONS = {
crmContactRead: 'crm.contact.read', crmContactRead: 'crm.contact.read',
crmContactCreate: 'crm.contact.create', crmContactCreate: 'crm.contact.create',
crmContactUpdate: 'crm.contact.update', crmContactUpdate: 'crm.contact.update',
crmContactDelete: 'crm.contact.delete' crmContactDelete: 'crm.contact.delete',
crmEnquiryRead: 'crm.enquiry.read',
crmEnquiryCreate: 'crm.enquiry.create',
crmEnquiryUpdate: 'crm.enquiry.update',
crmEnquiryDelete: 'crm.enquiry.delete',
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete'
} as const; } as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@@ -45,11 +53,25 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmContactRead, PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate, PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate, PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmContactDelete PERMISSIONS.crmContactDelete,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryDelete,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmEnquiryFollowupDelete
]; ];
} }
return [PERMISSIONS.productsRead, PERMISSIONS.crmCustomerRead, PERMISSIONS.crmContactRead]; return [
PERMISSIONS.productsRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead
];
} }
export function getBusinessRolePermissions(role: BusinessRole): Permission[] { export function getBusinessRolePermissions(role: BusinessRole): Permission[] {

View File

@@ -13,8 +13,10 @@ export const searchParams = {
category: parseAsString, category: parseAsString,
customerType: parseAsString, customerType: parseAsString,
customerStatus: parseAsString, customerStatus: parseAsString,
customer: parseAsString,
branch: parseAsString, branch: parseAsString,
productType: parseAsString, productType: parseAsString,
priority: parseAsString,
salesman: parseAsString, salesman: parseAsString,
dateFrom: parseAsString, dateFrom: parseAsString,
dateTo: parseAsString, dateTo: parseAsString,