task-d complate
This commit is contained in:
118
docs/implementation/task-d-enquiry-production.md
Normal file
118
docs/implementation/task-d-enquiry-production.md
Normal 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
|
||||||
50
drizzle/0003_blushing_bruce_banner.sql
Normal file
50
drizzle/0003_blushing_bruce_banner.sql
Normal 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");
|
||||||
1290
drizzle/meta/0003_snapshot.json
Normal file
1290
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
124
src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts
Normal file
124
src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/api/crm/enquiries/[id]/followups/route.ts
Normal file
91
src/app/api/crm/enquiries/[id]/followups/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/app/api/crm/enquiries/[id]/route.ts
Normal file
137
src/app/api/crm/enquiries/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/app/api/crm/enquiries/route.ts
Normal file
111
src/app/api/crm/enquiries/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!relatedEnquiries.length ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
Enquiry, quotation, and approval links will land here in Task D and later.
|
No enquiries have been linked to this customer yet.
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
74
src/features/crm/enquiries/api/mutations.ts
Normal file
74
src/features/crm/enquiries/api/mutations.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
28
src/features/crm/enquiries/api/queries.ts
Normal file
28
src/features/crm/enquiries/api/queries.ts
Normal 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)
|
||||||
|
});
|
||||||
83
src/features/crm/enquiries/api/service.ts
Normal file
83
src/features/crm/enquiries/api/service.ts
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
189
src/features/crm/enquiries/api/types.ts
Normal file
189
src/features/crm/enquiries/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
70
src/features/crm/enquiries/components/enquiries-table.tsx
Normal file
70
src/features/crm/enquiries/components/enquiries-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/features/crm/enquiries/components/enquiry-columns.tsx
Normal file
176
src/features/crm/enquiries/components/enquiry-columns.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
288
src/features/crm/enquiries/components/enquiry-detail.tsx
Normal file
288
src/features/crm/enquiries/components/enquiry-detail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/features/crm/enquiries/components/enquiry-followups-tab.tsx
Normal file
150
src/features/crm/enquiries/components/enquiry-followups-tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
src/features/crm/enquiries/components/enquiry-form-sheet.tsx
Normal file
377
src/features/crm/enquiries/components/enquiry-form-sheet.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/features/crm/enquiries/components/enquiry-listing.tsx
Normal file
46
src/features/crm/enquiries/components/enquiry-listing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/features/crm/enquiries/components/followup-form-sheet.tsx
Normal file
249
src/features/crm/enquiries/components/followup-form-sheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/features/crm/enquiries/schemas/enquiry.schema.ts
Normal file
47
src/features/crm/enquiries/schemas/enquiry.schema.ts
Normal 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>;
|
||||||
734
src/features/crm/enquiries/server/service.ts
Normal file
734
src/features/crm/enquiries/server/service.ts
Normal 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()
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user