task-d.5.5
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# Task D.5.5: Opportunity / Sales Workspace Refinement
|
||||
|
||||
## Objective
|
||||
Refine the sales-facing enquiry workspace so lead-origin enquiries are presented as opportunities without renaming the underlying `crm_enquiries` model or changing quotation, dashboard, report, or approval foundations.
|
||||
|
||||
## Review Completed
|
||||
- `AGENTS.md`
|
||||
- `plans/task-d.5.5.md`
|
||||
- `docs/standards/project-foundations.md`
|
||||
- `docs/standards/task-catalog.md`
|
||||
- `docs/adr/0018-lead-enquiry-domain-separation.md`
|
||||
- `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
|
||||
- `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
|
||||
- `docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md`
|
||||
- `docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md`
|
||||
- `docs/security/crm-authorization-boundaries.md`
|
||||
- existing enquiry list/detail/form/assignment UI
|
||||
- existing quotation reference-data and creation UI
|
||||
|
||||
## What Changed
|
||||
- Updated [enquiries page](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/enquiries/page.tsx) to use sales-facing opportunity wording in page title and description while keeping the same enquiry route.
|
||||
- Refined list-loading and table UI in:
|
||||
- [enquiry-listing.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/components/enquiry-listing.tsx)
|
||||
- [enquiries-table.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/components/enquiries-table.tsx)
|
||||
- [enquiry-columns.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/components/enquiry-columns.tsx)
|
||||
- Opportunity list now emphasizes sales execution fields:
|
||||
- opportunity code
|
||||
- source lead indicator
|
||||
- customer
|
||||
- project name
|
||||
- product type
|
||||
- sales owner
|
||||
- status
|
||||
- estimated value
|
||||
- chance percent
|
||||
- expected close date
|
||||
- next follow-up
|
||||
- created date
|
||||
- Updated [opportunity detail page](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/enquiries/[id]/page.tsx) to load:
|
||||
- optional source lead summary
|
||||
- quotation reference data when quotation creation permission exists
|
||||
- Rebuilt [enquiry-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/components/enquiry-detail.tsx) around clearer sales sections:
|
||||
- Opportunity Summary
|
||||
- Source Lead
|
||||
- Customer / Contact
|
||||
- Requirement
|
||||
- Sales Qualification
|
||||
- Follow-up Timeline
|
||||
- Linked Quotations
|
||||
- Activity
|
||||
- Quotation Readiness
|
||||
- Added [source-lead-card.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/components/source-lead-card.tsx) for read-only lead provenance visibility.
|
||||
- Added [quotation-readiness-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/components/quotation-readiness-panel.tsx) for non-blocking readiness checks.
|
||||
- Extended [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/api/types.ts) with optional list-only fields for future source lead and follow-up enrichment without breaking existing API consumers.
|
||||
|
||||
## UI Wording Decisions
|
||||
- Route kept as `/dashboard/crm/enquiries`
|
||||
- Page title changed to `CRM Opportunities`
|
||||
- Detail title changed to `Opportunity Detail`
|
||||
- Technical code and API names remain `enquiry` / `crm_enquiries`
|
||||
|
||||
## APIs and Foundations Reused
|
||||
- Existing enquiry APIs remained unchanged:
|
||||
- `GET /api/crm/enquiries`
|
||||
- `GET /api/crm/enquiries/[id]`
|
||||
- existing follow-up and assignment APIs
|
||||
- Existing quotation reference-data foundation reused for the `Create Quotation` action.
|
||||
- Lead detail service reused for source lead summary display.
|
||||
|
||||
## Permission Mapping
|
||||
- Opportunity list/detail still rely on existing enquiry permissions:
|
||||
- `crm.enquiry.read`
|
||||
- `crm.enquiry.update`
|
||||
- `crm.enquiry.assign`
|
||||
- `crm.enquiry.reassign`
|
||||
- `crm.enquiry.followup.create`
|
||||
- `crm.enquiry.followup.update`
|
||||
- `crm.enquiry.followup.delete`
|
||||
- Quotation action is shown only when `crm.quotation.create` is available.
|
||||
- Source lead visibility on the opportunity page does not widen lead permissions; it is only rendered for users who can already access the enquiry page.
|
||||
|
||||
## Compatibility Notes
|
||||
- `crm_enquiries`, `/api/crm/enquiries`, and existing quotation flows were not renamed.
|
||||
- Records without `lead_id` continue to render as direct sales opportunities.
|
||||
- Dashboard, reports, approvals, and quotation services were not changed.
|
||||
|
||||
## Known Limitations
|
||||
- The current `Create Quotation` action opens the existing quotation creation sheet without automatically preselecting the current opportunity.
|
||||
- Source-lead-specific list enrichment is prepared at the UI contract level, but the list still uses lightweight lead linkage signaling rather than a fully joined source-lead dataset.
|
||||
|
||||
## Verification
|
||||
- `npm exec tsc --noEmit`
|
||||
432
plans/task-d.5.5.md
Normal file
432
plans/task-d.5.5.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Task D.5.5: Opportunity / Sales Workspace Refinement
|
||||
|
||||
## Status
|
||||
|
||||
Planned
|
||||
|
||||
## Objective
|
||||
|
||||
Refine the Sales workspace so enquiries created from leads are presented and operated as Sales Opportunities, while preserving existing enquiry, quotation, dashboard, report, and approval compatibility.
|
||||
|
||||
Business boundary:
|
||||
|
||||
```txt
|
||||
Lead = Marketing-owned source record
|
||||
Opportunity / Enquiry = Sales-owned execution workspace
|
||||
```
|
||||
|
||||
This task does not replace `crm_enquiries`. It improves the sales-facing workspace around it.
|
||||
|
||||
---
|
||||
|
||||
## Mandatory Review
|
||||
|
||||
Review before implementation:
|
||||
|
||||
* `AGENTS.md`
|
||||
* `docs/standards/project-foundations.md`
|
||||
* `docs/standards/task-catalog.md`
|
||||
* `docs/adr/0018-lead-enquiry-domain-separation.md`
|
||||
* `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
|
||||
* `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
|
||||
* `docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md`
|
||||
* `docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md`
|
||||
* `docs/security/crm-authorization-boundaries.md`
|
||||
* existing enquiry API/service/UI
|
||||
* existing quotation conversion flow
|
||||
|
||||
---
|
||||
|
||||
## Current Foundation
|
||||
|
||||
Already available:
|
||||
|
||||
```txt
|
||||
crm_leads
|
||||
crm_enquiries
|
||||
crm_enquiries.lead_id
|
||||
|
||||
Lead UI
|
||||
Enquiry UI
|
||||
Lead Assignment API
|
||||
Lead -> Enquiry creation
|
||||
Source Lead display in Enquiry detail
|
||||
```
|
||||
|
||||
Do not recreate:
|
||||
|
||||
```txt
|
||||
tables
|
||||
migrations
|
||||
lead assignment service
|
||||
lead APIs
|
||||
enquiry APIs
|
||||
quotation APIs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Business Rule
|
||||
|
||||
After a Lead is assigned:
|
||||
|
||||
```txt
|
||||
Lead
|
||||
-> creates / reuses Enquiry
|
||||
-> Enquiry becomes Sales Opportunity workspace
|
||||
```
|
||||
|
||||
The DB object remains:
|
||||
|
||||
```txt
|
||||
crm_enquiries
|
||||
```
|
||||
|
||||
The sales-facing business wording may display as:
|
||||
|
||||
```txt
|
||||
Opportunity
|
||||
```
|
||||
|
||||
where appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.1 Sales Workspace Wording
|
||||
|
||||
Refine enquiry UI labels for sales-facing pages.
|
||||
|
||||
Recommended display naming:
|
||||
|
||||
```txt
|
||||
Menu: Enquiries or Opportunities
|
||||
Page title: Opportunities
|
||||
Detail title: Opportunity Detail
|
||||
Source label: Source Lead
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Do not rename database tables
|
||||
* Do not rename API paths in this task
|
||||
* Do not break existing `/api/crm/enquiries`
|
||||
* Avoid global find/replace that may break compatibility
|
||||
* Keep technical names in code where already established unless safely isolated
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.2 Opportunity List Refinement
|
||||
|
||||
Update existing enquiry list to better support Sales workflow.
|
||||
|
||||
Recommended columns:
|
||||
|
||||
```txt
|
||||
Enquiry / Opportunity Code
|
||||
Source Lead
|
||||
Customer
|
||||
Project Name
|
||||
Product Type
|
||||
Sales Owner
|
||||
Status
|
||||
Estimated Value
|
||||
Chance %
|
||||
Expected Close Date
|
||||
Last Follow-up
|
||||
Next Follow-up
|
||||
Created Date
|
||||
```
|
||||
|
||||
Recommended filters:
|
||||
|
||||
```txt
|
||||
Sales Owner
|
||||
Product Type
|
||||
Status
|
||||
Customer
|
||||
Source Lead
|
||||
Expected Close Date
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Use existing enquiry API where possible
|
||||
* Add only non-breaking response fields if needed
|
||||
* Do not change dashboard/report query contracts
|
||||
* Preserve current enquiry list behavior for records without `lead_id`
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.3 Opportunity Detail Refinement
|
||||
|
||||
Update existing enquiry detail page to make sales execution clearer.
|
||||
|
||||
Sections:
|
||||
|
||||
```txt
|
||||
Opportunity Summary
|
||||
Source Lead
|
||||
Customer / Contact
|
||||
Requirement
|
||||
Sales Qualification
|
||||
Follow-up Timeline
|
||||
Quotation Readiness
|
||||
Linked Quotations
|
||||
```
|
||||
|
||||
Actions:
|
||||
|
||||
```txt
|
||||
Edit Opportunity
|
||||
Create Follow-up
|
||||
Create Quotation
|
||||
Open Source Lead
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* `Source Lead` is read-only
|
||||
* Marketing users may not get broader enquiry access from this block
|
||||
* Quotation creation behavior must remain unchanged
|
||||
* Enquiry records without `lead_id` must still render normally
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.4 Sales Qualification Fields
|
||||
|
||||
Use existing enquiry fields:
|
||||
|
||||
```txt
|
||||
requirement
|
||||
estimatedValue
|
||||
chancePercent
|
||||
expectedCloseDate
|
||||
competitor
|
||||
priority
|
||||
status
|
||||
```
|
||||
|
||||
Refine UI grouping only.
|
||||
|
||||
Rules:
|
||||
|
||||
* Do not add new fields unless already present in schema
|
||||
* Do not create migration
|
||||
* Keep validation compatible with existing enquiry service
|
||||
* Avoid changing required fields that could break legacy creation
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.5 Source Lead Read Model
|
||||
|
||||
Enhance enquiry detail read model if needed to include minimal source lead data:
|
||||
|
||||
```ts
|
||||
sourceLead?: {
|
||||
id: string;
|
||||
code: string;
|
||||
projectName?: string | null;
|
||||
status?: string | null;
|
||||
awareness?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Only expose source lead if current user can access the enquiry
|
||||
* Do not widen Lead permission from Enquiry page
|
||||
* Do not make extra client-side calls if server-side enrichment already exists
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.6 Follow-up Continuity
|
||||
|
||||
Keep existing enquiry follow-up architecture.
|
||||
|
||||
Rules:
|
||||
|
||||
* Sales follow-ups belong to enquiry/opportunity
|
||||
* Lead follow-ups remain audit-backed from D.5.2 until future persistence decision
|
||||
* Do not merge lead follow-up and enquiry follow-up timelines in this task
|
||||
* Optional: show Source Lead activity as read-only if already available and safe
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.7 Quotation Readiness Panel
|
||||
|
||||
Add a lightweight readiness panel on Opportunity detail.
|
||||
|
||||
Suggested checks:
|
||||
|
||||
```txt
|
||||
Customer selected
|
||||
Contact selected
|
||||
Project name present
|
||||
Product type present
|
||||
Requirement present
|
||||
Estimated value present
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```txt
|
||||
Ready to create quotation
|
||||
Missing required information
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Informational only
|
||||
* Do not block quotation creation unless existing service already blocks it
|
||||
* Do not change quotation service validation
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.8 Permission Handling
|
||||
|
||||
Use existing enquiry permissions and CRM access boundaries.
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
crm.enquiry.read
|
||||
crm.enquiry.update
|
||||
crm.enquiry.followup.create
|
||||
crm.quotation.create
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Do not check role strings directly
|
||||
* Hide unauthorized actions
|
||||
* Handle 403 gracefully
|
||||
* Backend remains the source of truth
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.5.9 Documentation
|
||||
|
||||
Create:
|
||||
|
||||
```txt
|
||||
docs/implementation/task-d55-opportunity-sales-workspace-refinement.md
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
```txt
|
||||
UI wording decisions
|
||||
routes changed
|
||||
components changed
|
||||
API fields added
|
||||
compatibility notes
|
||||
known limitations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
Updated:
|
||||
|
||||
```txt
|
||||
src/app/**/crm/enquiries/page.tsx
|
||||
src/app/**/crm/enquiries/[id]/page.tsx
|
||||
```
|
||||
|
||||
Updated or added components:
|
||||
|
||||
```txt
|
||||
src/features/crm/enquiries/components/enquiry-list.tsx
|
||||
src/features/crm/enquiries/components/enquiries-table.tsx
|
||||
src/features/crm/enquiries/components/enquiry-detail.tsx
|
||||
src/features/crm/enquiries/components/source-lead-card.tsx
|
||||
src/features/crm/enquiries/components/quotation-readiness-panel.tsx
|
||||
```
|
||||
|
||||
Optional updated API/read model:
|
||||
|
||||
```txt
|
||||
src/features/crm/enquiries/api/types.ts
|
||||
src/features/crm/enquiries/server/service.ts
|
||||
```
|
||||
|
||||
Documentation:
|
||||
|
||||
```txt
|
||||
docs/implementation/task-d55-opportunity-sales-workspace-refinement.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explicit Non-Scope
|
||||
|
||||
Do not:
|
||||
|
||||
* create new tables
|
||||
* create migrations
|
||||
* rename `crm_enquiries`
|
||||
* rename `/api/crm/enquiries`
|
||||
* rewrite quotation conversion
|
||||
* change approval workflow
|
||||
* change dashboard datasets
|
||||
* change reports
|
||||
* implement won/lost synchronization
|
||||
* migrate legacy enquiry records
|
||||
* merge lead and enquiry follow-up persistence
|
||||
* remove `crm_enquiries.pipeline_stage`
|
||||
|
||||
These belong to later phases.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```txt
|
||||
npm exec tsc --noEmit
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```txt
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Manual verification:
|
||||
|
||||
```txt
|
||||
Opportunity/Enquiry list still loads
|
||||
Records with lead_id show Source Lead
|
||||
Records without lead_id still work
|
||||
Opportunity detail shows sales execution sections
|
||||
Create quotation still works
|
||||
Existing enquiry follow-ups still work
|
||||
Lead page still opens linked enquiry
|
||||
Quotation page still works
|
||||
Dashboard still works
|
||||
Reports still work
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Task is complete when:
|
||||
|
||||
* Sales workspace clearly represents assigned enquiries as opportunities
|
||||
* Existing enquiry API compatibility remains intact
|
||||
* Source Lead is visible from Opportunity detail
|
||||
* Quotation readiness is visible but non-blocking
|
||||
* Existing quotation, dashboard, report, and approval behavior remains unchanged
|
||||
|
||||
Result:
|
||||
|
||||
```txt
|
||||
Opportunity Workspace = Refined
|
||||
Sales Execution UX = Clarified
|
||||
Lead / Enquiry Boundary = Preserved
|
||||
Ready for D.5.6 Won / Lost Lifecycle Alignment
|
||||
```
|
||||
@@ -1,9 +1,7 @@
|
||||
import { auth } from '@/auth';
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
enquiryByIdOptions,
|
||||
enquiryFollowupsOptions,
|
||||
@@ -13,6 +11,7 @@ import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detai
|
||||
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { getLeadById } from '@/features/crm/leads/server/service';
|
||||
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
@@ -90,6 +89,12 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
||||
|
||||
const canCreateQuotation =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationCreate)));
|
||||
|
||||
const canViewOutcomeCommercialData = canRead;
|
||||
|
||||
if (!canRead || !session?.user?.activeOrganizationId || !session.user.id) {
|
||||
@@ -119,33 +124,20 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
]);
|
||||
|
||||
const enquiryDetail = await getEnquiryDetail(id, session.user.activeOrganizationId, accessContext);
|
||||
const [referenceData, relatedQuotations, sourceLead] = await Promise.all([
|
||||
const [referenceData, relatedQuotations, sourceLead, quotationReferenceData] = await Promise.all([
|
||||
getEnquiryReferenceData(session.user.activeOrganizationId),
|
||||
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
|
||||
enquiryDetail.leadId
|
||||
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
: Promise.resolve(null),
|
||||
canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='รายละเอียดโอกาสขาย'
|
||||
pageDescription='พื้นที่ทำงานของฝ่ายขายสำหรับติดตาม execution ของ enquiry และเชื่อมกับ quotation'
|
||||
pageTitle='Opportunity Detail'
|
||||
pageDescription='Sales execution workspace for follow-up continuity, quotation readiness, and outcome control.'
|
||||
>
|
||||
{sourceLead ? (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Source Lead</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='text-sm'>
|
||||
<Link href={`/dashboard/crm/leads/${sourceLead.id}`} className='font-medium hover:underline'>
|
||||
{sourceLead.code}
|
||||
</Link>
|
||||
<div className='text-muted-foreground mt-1'>{sourceLead.projectName ?? 'No project name'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiryDetail
|
||||
enquiryId={id}
|
||||
@@ -160,7 +152,20 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
canMarkWon={canMarkWon}
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
canCreateQuotation={canCreateQuotation}
|
||||
canManageFollowups={canManageFollowups}
|
||||
sourceLead={
|
||||
sourceLead
|
||||
? {
|
||||
id: sourceLead.id,
|
||||
code: sourceLead.code,
|
||||
projectName: sourceLead.projectName,
|
||||
status: sourceLead.statusLabel,
|
||||
awareness: sourceLead.awarenessLabel
|
||||
}
|
||||
: null
|
||||
}
|
||||
quotationReferenceData={quotationReferenceData}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { auth } from "@/auth";
|
||||
import PageContainer from "@/components/layout/page-container";
|
||||
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";
|
||||
import { auth } from '@/auth';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
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 type { SearchParams } from 'nuqs/server';
|
||||
|
||||
export const metadata = {
|
||||
title: "Dashboard: CRM Enquiries",
|
||||
title: 'Dashboard: CRM Opportunities'
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
@@ -16,75 +15,69 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function EnquiriesRoute(props: PageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
const canRead =
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
||||
|
||||
const canCreate =
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
|
||||
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
||||
|
||||
const canDelete =
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
||||
|
||||
const canAssign =
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
||||
|
||||
const canReassign =
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
session.user.activePermissions.includes(
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
)));
|
||||
const businessRole = session?.user?.activeBusinessRole ?? null;
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
||||
|
||||
searchParamsCache.parse(searchParams);
|
||||
if (!canRead || !session?.user?.activeOrganizationId) {
|
||||
return (
|
||||
<PageContainer access={false} pageTitle='CRM Opportunities'>
|
||||
{null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle="โอกาสขาย"
|
||||
pageDescription="พื้นที่ทำงานของฝ่ายขายสำหรับดูแลโอกาสขาย ติดตามความคืบหน้า และเตรียมใบเสนอราคา"
|
||||
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} workspace='enquiry' />
|
||||
) : null
|
||||
}
|
||||
pageTitle='CRM Opportunities'
|
||||
pageDescription='Sales execution workspace for opportunities created directly or handed off from marketing leads.'
|
||||
pageHeaderAction={canCreate ? <EnquiryFormSheetTrigger referenceData={referenceData} /> : null}
|
||||
>
|
||||
{referenceData ? (
|
||||
<EnquiryListing
|
||||
referenceData={referenceData}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
) : null}
|
||||
<EnquiryListing
|
||||
referenceData={referenceData}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ export interface EnquiryRecord {
|
||||
export interface EnquiryListItem extends EnquiryRecord {
|
||||
customerName: string;
|
||||
contactName: string | null;
|
||||
sourceLeadCode?: string | null;
|
||||
sourceLeadProjectName?: string | null;
|
||||
lastFollowupDate?: string | null;
|
||||
followupCount: number;
|
||||
quotationCount: number;
|
||||
nextFollowupDate: string | null;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
'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";
|
||||
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,
|
||||
@@ -17,10 +17,10 @@ export function EnquiriesTable({
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: "lead" | "enquiry";
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -34,13 +34,12 @@ export function EnquiriesTable({
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canReassign
|
||||
}),
|
||||
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign],
|
||||
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
|
||||
);
|
||||
const columnIds = columns
|
||||
.map((column) => column.id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
@@ -50,7 +49,7 @@ export function EnquiriesTable({
|
||||
priority: parseAsString,
|
||||
branch: parseAsString,
|
||||
customer: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
@@ -63,21 +62,19 @@ export function EnquiriesTable({
|
||||
...(params.priority && { priority: params.priority }),
|
||||
...(params.branch && { branch: params.branch }),
|
||||
...(params.customer && { customer: params.customer }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
|
||||
console.log("data", data);
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
const pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage));
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
clearOnDefault: true
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Icons } from '@/components/icons';
|
||||
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';
|
||||
|
||||
type SharedColumnsConfig = {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
};
|
||||
|
||||
function getLeadColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
@@ -28,10 +36,7 @@ function getLeadColumns({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
<Link href={`/dashboard/crm/leads/${row.original.id}`} className='font-medium hover:underline'>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
@@ -57,68 +62,19 @@ function getLeadColumns({
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Company' />
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Company',
|
||||
variant: 'multiSelect' as const,
|
||||
label: 'Customer',
|
||||
variant: 'select' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
label: `${item.name} (${item.code})`
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
accessorFn: (row) => row.contactId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Contact' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.contactName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Assigned Sales' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
accessorFn: (row) => row.createdByName ?? row.createdBy,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created By' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.createdByName ?? row.original.createdBy}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
),
|
||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
accessorFn: (row) => row.createdAt,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Age' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ageInDays = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
);
|
||||
|
||||
return <span>{ageInDays} days</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
@@ -164,13 +120,14 @@ function getEnquiryWorkspaceColumns({
|
||||
canReassign
|
||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
<DataTableColumnHeader column={column} title='Opportunity Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
@@ -184,92 +141,76 @@ function getEnquiryWorkspaceColumns({
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Code',
|
||||
placeholder: 'Search enquiries...',
|
||||
label: 'Opportunity',
|
||||
placeholder: 'Search opportunities...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'projectName',
|
||||
accessorFn: (row) => row.projectName ?? row.title,
|
||||
id: 'sourceLead',
|
||||
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'),
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Project' />
|
||||
<DataTableColumnHeader column={column} title='Source Lead' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? row.original.title}</span>
|
||||
cell: ({ row }) =>
|
||||
row.original.leadId ? (
|
||||
<Badge variant='outline'>{row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'}</Badge>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>Direct sales</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='ลูกค้าผู้รับใบเสนอราคา' />
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'ลูกค้าผู้รับใบเสนอราคา',
|
||||
variant: 'multiSelect' as const,
|
||||
label: 'Customer',
|
||||
variant: 'select' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
label: `${item.name} (${item.code})`
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
id: 'projectName',
|
||||
accessorFn: (row) => row.projectName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Project Name' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
accessorFn: (row) => row.productType,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Product Type' />
|
||||
),
|
||||
cell: ({ row }) => <span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>,
|
||||
meta: {
|
||||
label: 'Product Type',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'assignedToName',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry Owner' />
|
||||
<DataTableColumnHeader column={column} title='Sales Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'chancePercent',
|
||||
accessorKey: 'chancePercent',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Chance %' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null
|
||||
? row.original.estimatedValue.toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quotationCount',
|
||||
accessorKey: 'quotationCount',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Quotation Count' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.quotationCount}</span>
|
||||
},
|
||||
{
|
||||
id: 'nextFollowupDate',
|
||||
accessorKey: 'nextFollowupDate',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.nextFollowupDate
|
||||
? new Date(row.original.nextFollowupDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
@@ -290,6 +231,60 @@ function getEnquiryWorkspaceColumns({
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'chancePercent',
|
||||
accessorKey: 'chancePercent',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Chance %' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'expectedCloseDate',
|
||||
accessorKey: 'expectedCloseDate',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Expected Close' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.expectedCloseDate
|
||||
? new Date(row.original.expectedCloseDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'nextFollowupDate',
|
||||
accessorKey: 'nextFollowupDate',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.nextFollowupDate ? new Date(row.original.nextFollowupDate).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created Date' />
|
||||
),
|
||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
@@ -307,14 +302,6 @@ function getEnquiryWorkspaceColumns({
|
||||
];
|
||||
}
|
||||
|
||||
type SharedColumnsConfig = {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
};
|
||||
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
workspace,
|
||||
@@ -322,18 +309,7 @@ export function getEnquiryColumns({
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig & {
|
||||
workspace: 'lead' | 'enquiry';
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
const sharedConfig = {
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
};
|
||||
|
||||
return workspace === 'lead'
|
||||
? getLeadColumns(sharedConfig)
|
||||
: getEnquiryWorkspaceColumns(sharedConfig);
|
||||
}: SharedColumnsConfig & { workspace: 'lead' | 'enquiry' }): ColumnDef<EnquiryListItem>[] {
|
||||
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
||||
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getEnquiryWorkspaceColumns(sharedConfig);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ 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 type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
|
||||
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
@@ -18,6 +18,8 @@ import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
import { QuotationReadinessPanel } from './quotation-readiness-panel';
|
||||
import { SourceLeadCard, type SourceLeadSummary } from './source-lead-card';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
@@ -45,11 +47,14 @@ interface EnquiryDetailProps {
|
||||
canMarkWon: boolean;
|
||||
canMarkLost: boolean;
|
||||
canReopen: boolean;
|
||||
canCreateQuotation: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
sourceLead?: SourceLeadSummary | null;
|
||||
quotationReferenceData?: QuotationReferenceData | null;
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
@@ -65,13 +70,18 @@ export function EnquiryDetail({
|
||||
canMarkWon,
|
||||
canMarkLost,
|
||||
canReopen,
|
||||
canManageFollowups
|
||||
canCreateQuotation,
|
||||
canManageFollowups,
|
||||
sourceLead = null,
|
||||
quotationReferenceData = null
|
||||
}: EnquiryDetailProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||
const [quotationOpen, setQuotationOpen] = useState(false);
|
||||
const enquiry = data.enquiry;
|
||||
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
[referenceData]
|
||||
@@ -89,300 +99,238 @@ export function EnquiryDetail({
|
||||
[referenceData]
|
||||
);
|
||||
const productTypeMap = useMemo(
|
||||
() => new Map(referenceData.productTypes.map((item) => [item.id, item])),
|
||||
() => new Map(referenceData.productTypes.map((item) => [item.id, item.label])),
|
||||
[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])),
|
||||
() => new Map(referenceData.priorities.map((item) => [item.id, item.label])),
|
||||
[referenceData]
|
||||
);
|
||||
|
||||
const customer = customerMap.get(enquiry.customerId);
|
||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
||||
const status = statusMap.get(enquiry.status);
|
||||
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
||||
const isLeadWorkspace = workspace === 'lead';
|
||||
const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries';
|
||||
const recordLabel = isLeadWorkspace ? 'ลีด' : 'โอกาสขาย';
|
||||
const ownerLabel = isLeadWorkspace ? 'ฝ่ายขายที่รับผิดชอบ' : 'ผู้ดูแลโอกาสขาย';
|
||||
const infoCardTitle = isLeadWorkspace ? 'ข้อมูลลีด' : 'ข้อมูลโอกาสขาย';
|
||||
const infoCardDescription = isLeadWorkspace
|
||||
? 'ที่มาของลีด ข้อมูลการคัดกรอง และความพร้อมในการส่งต่อให้ฝ่ายขาย'
|
||||
: 'ขอบเขตงาน ข้อมูลผู้รับผิดชอบ และรายละเอียดการดูแลของฝ่ายขาย';
|
||||
const assignmentButtonLabel =
|
||||
assignmentMode === 'assign'
|
||||
? isLeadWorkspace
|
||||
? 'ส่งต่อให้ฝ่ายขาย'
|
||||
: 'มอบหมายผู้ดูแลโอกาสขาย'
|
||||
: isLeadWorkspace
|
||||
? 'เปลี่ยนฝ่ายขายที่รับผิดชอบ'
|
||||
: 'เปลี่ยนผู้ดูแลโอกาสขาย';
|
||||
const projectSectionLabel = isLeadWorkspace ? 'ขอบเขตลีด' : 'ข้อมูลโครงการ';
|
||||
const followupTabLabel = isLeadWorkspace ? 'งานติดตามลีด' : 'งานติดตาม';
|
||||
const relatedTabLabel = isLeadWorkspace ? 'ใบเสนอราคาที่เกี่ยวข้อง' : 'ใบเสนอราคา';
|
||||
const snapshotDescription = isLeadWorkspace
|
||||
? 'ข้อมูลระบบและสถานะการส่งต่อของลีดรายการนี้'
|
||||
: 'ข้อมูลระบบและสถานะการรับผิดชอบของโอกาสขายรายการนี้';
|
||||
|
||||
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={backHref}>
|
||||
<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'>
|
||||
{pipelineStageLabel} | {customer?.name ?? 'ไม่ทราบชื่อลูกค้า'}
|
||||
{contact ? ` | ${contact.name}` : ''}
|
||||
</p>
|
||||
<EnquiryFormSheet
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
enquiry={enquiry}
|
||||
/>
|
||||
<EnquiryAssignmentDialog
|
||||
enquiry={enquiry}
|
||||
referenceData={referenceData}
|
||||
mode={assignmentMode}
|
||||
open={assignmentOpen}
|
||||
onOpenChange={setAssignmentOpen}
|
||||
/>
|
||||
{quotationReferenceData ? (
|
||||
<QuotationFormSheet
|
||||
open={quotationOpen}
|
||||
onOpenChange={setQuotationOpen}
|
||||
referenceData={quotationReferenceData}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h2 className='text-2xl font-semibold'>{enquiry.code}</h2>
|
||||
<EnquiryStatusBadge code={status?.code} label={status?.label ?? enquiry.status} />
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canCreateQuotation ? (
|
||||
<Button onClick={() => setQuotationOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Create Quotation
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceLead ? (
|
||||
<Button variant='outline' asChild>
|
||||
<Link href={`/dashboard/crm/leads/${sourceLead.id}`}>Open Source Lead</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{assignmentButtonLabel}
|
||||
{assignmentMode === 'assign' ? 'Assign Opportunity' : 'Reassign Opportunity'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' />
|
||||
{`Edit ${recordLabel}`}
|
||||
Edit Opportunity
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<div className='space-y-6 xl:col-span-2'>
|
||||
<Card>
|
||||
<CardContent className='pt-6'>
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
||||
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
|
||||
<TabsTrigger value='activity'>กิจกรรม</TabsTrigger>
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{infoCardTitle}</CardTitle>
|
||||
<CardDescription>{infoCardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem
|
||||
label={isLeadWorkspace ? 'ลูกค้า' : 'ลูกค้าผู้รับใบเสนอราคา'}
|
||||
value={customer?.name}
|
||||
/>
|
||||
<FieldItem label='ผู้ติดต่อ' value={contact?.name} />
|
||||
<FieldItem
|
||||
label='สาขา'
|
||||
value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null}
|
||||
/>
|
||||
<FieldItem label='ใช้งานอยู่' value={enquiry.isActive ? 'ใช่' : 'ไม่ใช่'} />
|
||||
<FieldItem
|
||||
label='ประเภทสินค้า'
|
||||
value={productTypeMap.get(enquiry.productType)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='ความสำคัญ'
|
||||
value={priorityMap.get(enquiry.priority)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='ที่มาของลีด'
|
||||
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='วันที่คาดว่าจะปิดการขาย'
|
||||
value={
|
||||
enquiry.expectedCloseDate
|
||||
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='มูลค่าประมาณการ'
|
||||
value={
|
||||
enquiry.estimatedValue !== null
|
||||
? enquiry.estimatedValue.toLocaleString()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='โอกาสปิดการขาย %'
|
||||
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
|
||||
/>
|
||||
<FieldItem label='คู่แข่ง' value={enquiry.competitor} />
|
||||
<FieldItem label='ที่มา' value={enquiry.source} />
|
||||
<FieldItem label='ขั้นตอนใน Pipeline' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem
|
||||
label='มอบหมายเมื่อ'
|
||||
value={
|
||||
enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null
|
||||
}
|
||||
/>
|
||||
<FieldItem label='มอบหมายโดย' value={enquiry.assignedByName} />
|
||||
<FieldItem label='หมายเหตุการมอบหมาย' value={enquiry.assignmentRemark} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{isLeadWorkspace ? 'ผู้เกี่ยวข้องของลีด' : 'ผู้เกี่ยวข้องในโครงการ'}
|
||||
</div>
|
||||
{!projectPartiesData.items.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
{isLeadWorkspace
|
||||
? 'ยังไม่มีผู้เกี่ยวข้องของลีด'
|
||||
: 'ยังไม่มีผู้เกี่ยวข้องในโครงการ'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{projectPartiesData.items.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-4 text-sm'>
|
||||
<div className='font-medium'>{item.customerName}</div>
|
||||
<div className='text-muted-foreground'>
|
||||
บทบาท: {item.roleLabel ?? item.roleCode}
|
||||
</div>
|
||||
{item.remark ? <div className='mt-1'>{item.remark}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label={projectSectionLabel}
|
||||
value={
|
||||
[enquiry.projectName, enquiry.projectLocation].filter(Boolean).join(' | ') ||
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='รายละเอียด' value={enquiry.description} />
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='ความต้องการ' value={enquiry.requirement} />
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='หมายเหตุ' 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>{isLeadWorkspace ? 'กิจกรรมของลีด' : 'กิจกรรม'}</CardTitle>
|
||||
<CardDescription>ประวัติการเปลี่ยนแปลงของรายการนี้และงานติดตามที่เกี่ยวข้อง</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'>
|
||||
ยังไม่มีกิจกรรม
|
||||
</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>
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{relatedTabLabel}</CardTitle>
|
||||
<CardDescription>
|
||||
ใบเสนอราคาที่เชื่อมกับรายการนี้แล้ว
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
ยังไม่มีใบเสนอราคาที่เชื่อมกับรายการนี้
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{relatedQuotations.map((quotation) => (
|
||||
<Link
|
||||
key={quotation.id}
|
||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{quotation.code}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{quotation.totalAmount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
<CardHeader>
|
||||
<CardTitle>Opportunity Summary</CardTitle>
|
||||
<CardDescription>Core sales execution snapshot for this opportunity.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<FieldItem label='Customer' value={customer?.name} />
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem label='Project Name' value={enquiry.projectName} />
|
||||
<FieldItem label='Project Location' value={enquiry.projectLocation} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label='Sales Owner' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Status' value={status?.label ?? enquiry.status} />
|
||||
<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}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SourceLeadCard sourceLead={sourceLead} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customer / Contact</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldItem label='Customer Code' value={customer?.code} />
|
||||
<FieldItem label='Customer Owner' value={customer?.ownerName} />
|
||||
<FieldItem label='Contact Name' value={contact?.name} />
|
||||
<FieldItem label='Contact Email' value={contact?.email} />
|
||||
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
||||
<FieldItem label='Branch' value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Requirement</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Title' value={enquiry.title} />
|
||||
<FieldItem label='Description' value={enquiry.description} />
|
||||
<FieldItem label='Requirement' value={enquiry.requirement} />
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Qualification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<FieldItem label='Product Type' value={productTypeMap.get(enquiry.productType) ?? enquiry.productType} />
|
||||
<FieldItem label='Priority' value={priorityMap.get(enquiry.priority) ?? enquiry.priority} />
|
||||
<FieldItem
|
||||
label='Chance %'
|
||||
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
|
||||
/>
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue='followups' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
|
||||
<TabsTrigger value='quotations'>Linked Quotations</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='followups'>
|
||||
<EnquiryFollowupsTab
|
||||
enquiryId={enquiryId}
|
||||
customerId={enquiry.customerId}
|
||||
referenceData={referenceData}
|
||||
canCreate={canManageFollowups.create}
|
||||
canUpdate={canManageFollowups.update}
|
||||
canDelete={canManageFollowups.delete}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='quotations'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Quotations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{!canViewRelatedQuotations ? (
|
||||
<div className='text-muted-foreground text-sm'>Quotation access is restricted.</div>
|
||||
) : relatedQuotations.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No quotations created yet.</div>
|
||||
) : (
|
||||
relatedQuotations.map((quotation) => (
|
||||
<div key={quotation.id} className='border-border rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Link
|
||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{quotation.code}
|
||||
</Link>
|
||||
<Badge variant='outline'>{quotation.status}</Badge>
|
||||
</div>
|
||||
<div className='mt-1 text-sm'>{quotation.quotationType}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{data.activity.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No activity yet.</div>
|
||||
) : (
|
||||
data.activity.map((activity) => (
|
||||
<div key={activity.id} className='border-border rounded-md border p-3'>
|
||||
<div className='font-medium'>{activity.action}</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{activity.actorName ?? activity.userId} -{' '}
|
||||
{new Date(activity.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<QuotationReadinessPanel enquiry={enquiry} />
|
||||
|
||||
<EnquiryOutcomeCard
|
||||
enquiryId={enquiryId}
|
||||
referenceData={referenceData}
|
||||
@@ -391,42 +339,22 @@ export function EnquiryDetail({
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
<CardDescription>{snapshotDescription}</CardDescription>
|
||||
<CardTitle>Quotation Context</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='ขั้นตอนใน Pipeline' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem label='มอบหมายโดย' value={enquiry.assignedByName} />
|
||||
<FieldItem label='สร้างโดย' value={enquiry.createdBy} />
|
||||
<FieldItem label='อัปเดตโดย' value={enquiry.updatedBy} />
|
||||
<CardContent className='space-y-3'>
|
||||
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
|
||||
<FieldItem
|
||||
label='มอบหมายเมื่อ'
|
||||
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
|
||||
label='Project Parties'
|
||||
value={String(projectPartiesData.items.length)}
|
||||
/>
|
||||
<FieldItem label='สร้างเมื่อ' value={new Date(enquiry.createdAt).toLocaleString()} />
|
||||
<FieldItem label='อัปเดตเมื่อ' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
||||
<FieldItem label='Record Type' value={workspace === 'enquiry' ? 'Opportunity' : 'Lead'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnquiryAssignmentDialog
|
||||
enquiry={enquiry}
|
||||
referenceData={referenceData}
|
||||
mode={assignmentMode}
|
||||
open={assignmentOpen}
|
||||
onOpenChange={setAssignmentOpen}
|
||||
/>
|
||||
<EnquiryFormSheet
|
||||
enquiry={enquiry}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function EnquiryListing({
|
||||
const branch = searchParamsCache.get('branch');
|
||||
const customer = searchParamsCache.get('customer');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
@@ -41,8 +42,8 @@ export default function EnquiryListing({
|
||||
...(customer && { customer }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { EnquiryRecord } from '../api/types';
|
||||
|
||||
type ReadinessItem = {
|
||||
label: string;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord }) {
|
||||
const checks: ReadinessItem[] = [
|
||||
{ label: 'Customer selected', ready: Boolean(enquiry.customerId) },
|
||||
{ label: 'Contact selected', ready: Boolean(enquiry.contactId) },
|
||||
{ label: 'Project name present', ready: Boolean(enquiry.projectName?.trim()) },
|
||||
{ label: 'Product type present', ready: Boolean(enquiry.productType) },
|
||||
{ label: 'Requirement present', ready: Boolean(enquiry.requirement?.trim()) },
|
||||
{ label: 'Estimated value present', ready: enquiry.estimatedValue !== null }
|
||||
];
|
||||
|
||||
const readyCount = checks.filter((item) => item.ready).length;
|
||||
const isReady = readyCount === checks.length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quotation Readiness</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='text-sm font-medium'>
|
||||
{isReady ? 'Ready to create quotation' : 'Missing required information'}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
{checks.map((item) => (
|
||||
<div key={item.label} className='flex items-center justify-between gap-3 text-sm'>
|
||||
<span>{item.label}</span>
|
||||
<span className={item.ready ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{item.ready ? 'Ready' : 'Missing'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/features/crm/enquiries/components/source-lead-card.tsx
Normal file
46
src/features/crm/enquiries/components/source-lead-card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export type SourceLeadSummary = {
|
||||
id: string;
|
||||
code: string;
|
||||
projectName: string | null;
|
||||
status: string | null;
|
||||
awareness: string | null;
|
||||
};
|
||||
|
||||
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 SourceLeadCard({ sourceLead }: { sourceLead: SourceLeadSummary | null }) {
|
||||
if (!sourceLead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Source Lead</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldItem label='Lead Code' value={sourceLead.code} />
|
||||
<FieldItem label='Project Name' value={sourceLead.projectName} />
|
||||
<FieldItem label='Lead Status' value={sourceLead.status} />
|
||||
<FieldItem label='Awareness' value={sourceLead.awareness} />
|
||||
<div className='md:col-span-2'>
|
||||
<Link href={`/dashboard/crm/leads/${sourceLead.id}`} className='text-sm font-medium hover:underline'>
|
||||
Open Source Lead
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user