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 { auth } from '@/auth';
|
||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
import {
|
||||||
enquiryByIdOptions,
|
enquiryByIdOptions,
|
||||||
enquiryFollowupsOptions,
|
enquiryFollowupsOptions,
|
||||||
@@ -13,6 +11,7 @@ import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detai
|
|||||||
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||||
import { getLeadById } from '@/features/crm/leads/server/service';
|
import { getLeadById } from '@/features/crm/leads/server/service';
|
||||||
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
@@ -90,6 +89,12 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
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;
|
const canViewOutcomeCommercialData = canRead;
|
||||||
|
|
||||||
if (!canRead || !session?.user?.activeOrganizationId || !session.user.id) {
|
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 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),
|
getEnquiryReferenceData(session.user.activeOrganizationId),
|
||||||
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
|
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
|
||||||
enquiryDetail.leadId
|
enquiryDetail.leadId
|
||||||
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
|
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
|
||||||
: Promise.resolve(null)
|
: Promise.resolve(null),
|
||||||
|
canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : Promise.resolve(null)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='รายละเอียดโอกาสขาย'
|
pageTitle='Opportunity Detail'
|
||||||
pageDescription='พื้นที่ทำงานของฝ่ายขายสำหรับติดตาม execution ของ enquiry และเชื่อมกับ quotation'
|
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)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiryDetail
|
<EnquiryDetail
|
||||||
enquiryId={id}
|
enquiryId={id}
|
||||||
@@ -160,7 +152,20 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
canMarkWon={canMarkWon}
|
canMarkWon={canMarkWon}
|
||||||
canMarkLost={canMarkLost}
|
canMarkLost={canMarkLost}
|
||||||
canReopen={canReopen}
|
canReopen={canReopen}
|
||||||
|
canCreateQuotation={canCreateQuotation}
|
||||||
canManageFollowups={canManageFollowups}
|
canManageFollowups={canManageFollowups}
|
||||||
|
sourceLead={
|
||||||
|
sourceLead
|
||||||
|
? {
|
||||||
|
id: sourceLead.id,
|
||||||
|
code: sourceLead.code,
|
||||||
|
projectName: sourceLead.projectName,
|
||||||
|
status: sourceLead.statusLabel,
|
||||||
|
awareness: sourceLead.awarenessLabel
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
quotationReferenceData={quotationReferenceData}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from '@/auth';
|
||||||
import PageContainer from "@/components/layout/page-container";
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import EnquiryListing from "@/features/crm/enquiries/components/enquiry-listing";
|
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
|
||||||
import { EnquiryFormSheetTrigger } from "@/features/crm/enquiries/components/enquiry-form-sheet";
|
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
|
||||||
import { getEnquiryReferenceData } from "@/features/crm/enquiries/server/service";
|
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||||
import { PERMISSIONS } from "@/lib/auth/rbac";
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { searchParamsCache } from "@/lib/searchparams";
|
import type { SearchParams } from 'nuqs/server';
|
||||||
import type { SearchParams } from "nuqs/server";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Dashboard: CRM Enquiries",
|
title: 'Dashboard: CRM Opportunities'
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
@@ -16,66 +15,61 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function EnquiriesRoute(props: PageProps) {
|
export default async function EnquiriesRoute(props: PageProps) {
|
||||||
const searchParams = await props.searchParams;
|
await props.searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
const canRead =
|
const canRead =
|
||||||
session?.user?.systemRole === "super_admin" ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === "admin" ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
||||||
|
|
||||||
const canCreate =
|
const canCreate =
|
||||||
session?.user?.systemRole === "super_admin" ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === "admin" ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
|
||||||
|
|
||||||
const canUpdate =
|
const canUpdate =
|
||||||
session?.user?.systemRole === "super_admin" ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === "admin" ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
||||||
|
|
||||||
const canDelete =
|
const canDelete =
|
||||||
session?.user?.systemRole === "super_admin" ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === "admin" ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
||||||
|
|
||||||
const canAssign =
|
const canAssign =
|
||||||
session?.user?.systemRole === "super_admin" ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === "admin" ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
||||||
|
|
||||||
const canReassign =
|
const canReassign =
|
||||||
session?.user?.systemRole === "super_admin" ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === "admin" ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
|
||||||
)));
|
|
||||||
const businessRole = session?.user?.activeBusinessRole ?? null;
|
|
||||||
|
|
||||||
searchParamsCache.parse(searchParams);
|
if (!canRead || !session?.user?.activeOrganizationId) {
|
||||||
|
return (
|
||||||
|
<PageContainer access={false} pageTitle='CRM Opportunities'>
|
||||||
|
{null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const referenceData =
|
const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId);
|
||||||
canRead && session?.user?.activeOrganizationId
|
|
||||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle="โอกาสขาย"
|
pageTitle='CRM Opportunities'
|
||||||
pageDescription="พื้นที่ทำงานของฝ่ายขายสำหรับดูแลโอกาสขาย ติดตามความคืบหน้า และเตรียมใบเสนอราคา"
|
pageDescription='Sales execution workspace for opportunities created directly or handed off from marketing leads.'
|
||||||
access={canRead}
|
pageHeaderAction={canCreate ? <EnquiryFormSheetTrigger referenceData={referenceData} /> : null}
|
||||||
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
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{referenceData ? (
|
|
||||||
<EnquiryListing
|
<EnquiryListing
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
workspace='enquiry'
|
workspace='enquiry'
|
||||||
@@ -84,7 +78,6 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
canReassign={canReassign}
|
canReassign={canReassign}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ export interface EnquiryRecord {
|
|||||||
export interface EnquiryListItem extends EnquiryRecord {
|
export interface EnquiryListItem extends EnquiryRecord {
|
||||||
customerName: string;
|
customerName: string;
|
||||||
contactName: string | null;
|
contactName: string | null;
|
||||||
|
sourceLeadCode?: string | null;
|
||||||
|
sourceLeadProjectName?: string | null;
|
||||||
|
lastFollowupDate?: string | null;
|
||||||
followupCount: number;
|
followupCount: number;
|
||||||
quotationCount: number;
|
quotationCount: number;
|
||||||
nextFollowupDate: string | null;
|
nextFollowupDate: string | null;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { DataTable } from "@/components/ui/table/data-table";
|
import { DataTable } from '@/components/ui/table/data-table';
|
||||||
import { DataTableToolbar } from "@/components/ui/table/data-table-toolbar";
|
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||||
import { useDataTable } from "@/hooks/use-data-table";
|
import { useDataTable } from '@/hooks/use-data-table';
|
||||||
import { getSortingStateParser } from "@/lib/parsers";
|
import { getSortingStateParser } from '@/lib/parsers';
|
||||||
import { enquiriesQueryOptions } from "../api/queries";
|
import { enquiriesQueryOptions } from '../api/queries';
|
||||||
import type { EnquiryReferenceData } from "../api/types";
|
import type { EnquiryReferenceData } from '../api/types';
|
||||||
import { getEnquiryColumns } from "./enquiry-columns";
|
import { getEnquiryColumns } from './enquiry-columns';
|
||||||
|
|
||||||
export function EnquiriesTable({
|
export function EnquiriesTable({
|
||||||
referenceData,
|
referenceData,
|
||||||
@@ -17,10 +17,10 @@ export function EnquiriesTable({
|
|||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign,
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
workspace: "lead" | "enquiry";
|
workspace: 'lead' | 'enquiry';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -34,13 +34,12 @@ export function EnquiriesTable({
|
|||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign,
|
canReassign
|
||||||
}),
|
}),
|
||||||
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign],
|
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
|
||||||
);
|
);
|
||||||
const columnIds = columns
|
|
||||||
.map((column) => column.id)
|
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||||
.filter(Boolean) as string[];
|
|
||||||
const [params] = useQueryStates({
|
const [params] = useQueryStates({
|
||||||
page: parseAsInteger.withDefault(1),
|
page: parseAsInteger.withDefault(1),
|
||||||
perPage: parseAsInteger.withDefault(10),
|
perPage: parseAsInteger.withDefault(10),
|
||||||
@@ -50,7 +49,7 @@ export function EnquiriesTable({
|
|||||||
priority: parseAsString,
|
priority: parseAsString,
|
||||||
branch: parseAsString,
|
branch: parseAsString,
|
||||||
customer: parseAsString,
|
customer: parseAsString,
|
||||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
sort: getSortingStateParser(columnIds).withDefault([])
|
||||||
});
|
});
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
@@ -63,21 +62,19 @@ export function EnquiriesTable({
|
|||||||
...(params.priority && { priority: params.priority }),
|
...(params.priority && { priority: params.priority }),
|
||||||
...(params.branch && { branch: params.branch }),
|
...(params.branch && { branch: params.branch }),
|
||||||
...(params.customer && { customer: params.customer }),
|
...(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));
|
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
|
||||||
console.log("data", data);
|
const pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage));
|
||||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
data: data.items,
|
data: data.items,
|
||||||
columns,
|
columns,
|
||||||
pageCount,
|
pageCount,
|
||||||
shallow: true,
|
shallow: true,
|
||||||
debounceMs: 500,
|
debounceMs: 500,
|
||||||
initialState: {
|
clearOnDefault: true
|
||||||
columnPinning: { right: ["actions"] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
import { Icons } from '@/components/icons';
|
|
||||||
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
||||||
import { EnquiryCellAction } from './enquiry-cell-action';
|
import { EnquiryCellAction } from './enquiry-cell-action';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||||
|
|
||||||
|
type SharedColumnsConfig = {
|
||||||
|
referenceData: EnquiryReferenceData;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canReassign: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function getLeadColumns({
|
function getLeadColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
@@ -28,10 +36,7 @@ function getLeadColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link
|
<Link href={`/dashboard/crm/leads/${row.original.id}`} className='font-medium hover:underline'>
|
||||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
|
||||||
className='font-medium hover:underline'
|
|
||||||
>
|
|
||||||
{row.original.code}
|
{row.original.code}
|
||||||
</Link>
|
</Link>
|
||||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||||
@@ -57,68 +62,19 @@ function getLeadColumns({
|
|||||||
id: 'customer',
|
id: 'customer',
|
||||||
accessorFn: (row) => row.customerId,
|
accessorFn: (row) => row.customerId,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Company' />
|
<DataTableColumnHeader column={column} title='Customer' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Company',
|
label: 'Customer',
|
||||||
variant: 'multiSelect' as const,
|
variant: 'select' as const,
|
||||||
options: referenceData.customers.map((item) => ({
|
options: referenceData.customers.map((item) => ({
|
||||||
value: item.id,
|
value: item.id,
|
||||||
label: item.name
|
label: `${item.name} (${item.code})`
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
enableColumnFilter: true
|
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',
|
id: 'status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -164,13 +120,14 @@ function getEnquiryWorkspaceColumns({
|
|||||||
canReassign
|
canReassign
|
||||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||||
|
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'code',
|
id: 'code',
|
||||||
accessorKey: 'code',
|
accessorKey: 'code',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Code' />
|
<DataTableColumnHeader column={column} title='Opportunity Code' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
@@ -184,92 +141,76 @@ function getEnquiryWorkspaceColumns({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Code',
|
label: 'Opportunity',
|
||||||
placeholder: 'Search enquiries...',
|
placeholder: 'Search opportunities...',
|
||||||
variant: 'text' as const,
|
variant: 'text' as const,
|
||||||
icon: Icons.search
|
icon: Icons.search
|
||||||
},
|
},
|
||||||
enableColumnFilter: true
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'projectName',
|
id: 'sourceLead',
|
||||||
accessorFn: (row) => row.projectName ?? row.title,
|
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'),
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
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',
|
id: 'customer',
|
||||||
accessorFn: (row) => row.customerId,
|
accessorFn: (row) => row.customerId,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='ลูกค้าผู้รับใบเสนอราคา' />
|
<DataTableColumnHeader column={column} title='Customer' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||||
meta: {
|
meta: {
|
||||||
label: 'ลูกค้าผู้รับใบเสนอราคา',
|
label: 'Customer',
|
||||||
variant: 'multiSelect' as const,
|
variant: 'select' as const,
|
||||||
options: referenceData.customers.map((item) => ({
|
options: referenceData.customers.map((item) => ({
|
||||||
value: item.id,
|
value: item.id,
|
||||||
label: item.name
|
label: `${item.name} (${item.code})`
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
enableColumnFilter: true
|
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 ?? '',
|
accessorFn: (row) => row.assignedToName ?? '',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
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>
|
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',
|
id: 'status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -290,6 +231,60 @@ function getEnquiryWorkspaceColumns({
|
|||||||
},
|
},
|
||||||
enableColumnFilter: true
|
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',
|
id: 'actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -307,14 +302,6 @@ function getEnquiryWorkspaceColumns({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedColumnsConfig = {
|
|
||||||
referenceData: EnquiryReferenceData;
|
|
||||||
canUpdate: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
canAssign: boolean;
|
|
||||||
canReassign: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEnquiryColumns({
|
export function getEnquiryColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -322,18 +309,7 @@ export function getEnquiryColumns({
|
|||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: SharedColumnsConfig & {
|
}: SharedColumnsConfig & { workspace: 'lead' | 'enquiry' }): ColumnDef<EnquiryListItem>[] {
|
||||||
workspace: 'lead' | 'enquiry';
|
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
||||||
}): ColumnDef<EnquiryListItem>[] {
|
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getEnquiryWorkspaceColumns(sharedConfig);
|
||||||
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { 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 { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
||||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||||
@@ -18,6 +18,8 @@ import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
|||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||||
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
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 }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
return (
|
return (
|
||||||
@@ -45,11 +47,14 @@ interface EnquiryDetailProps {
|
|||||||
canMarkWon: boolean;
|
canMarkWon: boolean;
|
||||||
canMarkLost: boolean;
|
canMarkLost: boolean;
|
||||||
canReopen: boolean;
|
canReopen: boolean;
|
||||||
|
canCreateQuotation: boolean;
|
||||||
canManageFollowups: {
|
canManageFollowups: {
|
||||||
create: boolean;
|
create: boolean;
|
||||||
update: boolean;
|
update: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
};
|
};
|
||||||
|
sourceLead?: SourceLeadSummary | null;
|
||||||
|
quotationReferenceData?: QuotationReferenceData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryDetail({
|
export function EnquiryDetail({
|
||||||
@@ -65,13 +70,18 @@ export function EnquiryDetail({
|
|||||||
canMarkWon,
|
canMarkWon,
|
||||||
canMarkLost,
|
canMarkLost,
|
||||||
canReopen,
|
canReopen,
|
||||||
canManageFollowups
|
canCreateQuotation,
|
||||||
|
canManageFollowups,
|
||||||
|
sourceLead = null,
|
||||||
|
quotationReferenceData = null
|
||||||
}: EnquiryDetailProps) {
|
}: EnquiryDetailProps) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
|
const [quotationOpen, setQuotationOpen] = useState(false);
|
||||||
const enquiry = data.enquiry;
|
const enquiry = data.enquiry;
|
||||||
|
|
||||||
const branchMap = useMemo(
|
const branchMap = useMemo(
|
||||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||||
[referenceData]
|
[referenceData]
|
||||||
@@ -89,128 +99,98 @@ export function EnquiryDetail({
|
|||||||
[referenceData]
|
[referenceData]
|
||||||
);
|
);
|
||||||
const productTypeMap = useMemo(
|
const productTypeMap = useMemo(
|
||||||
() => new Map(referenceData.productTypes.map((item) => [item.id, item])),
|
() => new Map(referenceData.productTypes.map((item) => [item.id, item.label])),
|
||||||
[referenceData]
|
[referenceData]
|
||||||
);
|
);
|
||||||
const priorityMap = useMemo(
|
const priorityMap = useMemo(
|
||||||
() => new Map(referenceData.priorities.map((item) => [item.id, item])),
|
() => new Map(referenceData.priorities.map((item) => [item.id, item.label])),
|
||||||
[referenceData]
|
|
||||||
);
|
|
||||||
const leadChannelMap = useMemo(
|
|
||||||
() => new Map(referenceData.leadChannels.map((item) => [item.id, item])),
|
|
||||||
[referenceData]
|
[referenceData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const customer = customerMap.get(enquiry.customerId);
|
const customer = customerMap.get(enquiry.customerId);
|
||||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
||||||
const status = statusMap.get(enquiry.status);
|
const status = statusMap.get(enquiry.status);
|
||||||
|
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
||||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<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'>
|
<EnquiryFormSheet
|
||||||
<div className='space-y-3'>
|
open={editOpen}
|
||||||
<div className='flex items-center gap-2'>
|
onOpenChange={setEditOpen}
|
||||||
<Button asChild variant='ghost' size='icon'>
|
referenceData={referenceData}
|
||||||
<Link href={backHref}>
|
workspace={workspace}
|
||||||
<Icons.chevronLeft className='h-4 w-4' />
|
enquiry={enquiry}
|
||||||
</Link>
|
/>
|
||||||
</Button>
|
<EnquiryAssignmentDialog
|
||||||
<span className='text-muted-foreground font-mono text-sm'>{enquiry.code}</span>
|
enquiry={enquiry}
|
||||||
<EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
|
referenceData={referenceData}
|
||||||
{enquiry.isHotProject ? <Badge>Hot</Badge> : null}
|
mode={assignmentMode}
|
||||||
</div>
|
open={assignmentOpen}
|
||||||
|
onOpenChange={setAssignmentOpen}
|
||||||
|
/>
|
||||||
|
{quotationReferenceData ? (
|
||||||
|
<QuotationFormSheet
|
||||||
|
open={quotationOpen}
|
||||||
|
onOpenChange={setQuotationOpen}
|
||||||
|
referenceData={quotationReferenceData}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className='flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
<div className='flex items-center gap-3'>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<h2 className='text-2xl font-semibold'>{enquiry.code}</h2>
|
||||||
{pipelineStageLabel} | {customer?.name ?? 'ไม่ทราบชื่อลูกค้า'}
|
<EnquiryStatusBadge code={status?.code} label={status?.label ?? enquiry.status} />
|
||||||
{contact ? ` | ${contact.name}` : ''}
|
</div>
|
||||||
|
<p className='text-muted-foreground mt-2 text-sm'>
|
||||||
|
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
<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 ? (
|
{canManageAssignment ? (
|
||||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
{assignmentMode === 'assign' ? 'Assign Opportunity' : 'Reassign Opportunity'}
|
||||||
{assignmentButtonLabel}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canUpdate ? (
|
{canUpdate ? (
|
||||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||||
<Icons.edit className='mr-2 h-4 w-4' />
|
Edit Opportunity
|
||||||
{`Edit ${recordLabel}`}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-6 lg:grid-cols-3'>
|
<div className='grid gap-6 xl:grid-cols-3'>
|
||||||
<div className='space-y-6 lg:col-span-2'>
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{infoCardTitle}</CardTitle>
|
<CardTitle>Opportunity Summary</CardTitle>
|
||||||
<CardDescription>{infoCardDescription}</CardDescription>
|
<CardDescription>Core sales execution snapshot for this opportunity.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
<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
|
<FieldItem
|
||||||
label={isLeadWorkspace ? 'ลูกค้า' : 'ลูกค้าผู้รับใบเสนอราคา'}
|
label='Expected Close Date'
|
||||||
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={
|
value={
|
||||||
enquiry.expectedCloseDate
|
enquiry.expectedCloseDate
|
||||||
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
|
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
|
||||||
@@ -218,76 +198,70 @@ export function EnquiryDetail({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='มูลค่าประมาณการ'
|
label='Estimated Value'
|
||||||
value={
|
value={enquiry.estimatedValue !== null ? enquiry.estimatedValue.toLocaleString() : null}
|
||||||
enquiry.estimatedValue !== null
|
|
||||||
? enquiry.estimatedValue.toLocaleString()
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
</CardContent>
|
||||||
label='โอกาสปิดการขาย %'
|
</Card>
|
||||||
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
|
|
||||||
/>
|
<SourceLeadCard sourceLead={sourceLead} />
|
||||||
<FieldItem label='คู่แข่ง' value={enquiry.competitor} />
|
|
||||||
<FieldItem label='ที่มา' value={enquiry.source} />
|
<Card>
|
||||||
<FieldItem label='ขั้นตอนใน Pipeline' value={pipelineStageLabel} />
|
<CardHeader>
|
||||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
<CardTitle>Customer / Contact</CardTitle>
|
||||||
<FieldItem
|
</CardHeader>
|
||||||
label='มอบหมายเมื่อ'
|
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||||
value={
|
<FieldItem label='Customer Code' value={customer?.code} />
|
||||||
enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null
|
<FieldItem label='Customer Owner' value={customer?.ownerName} />
|
||||||
}
|
<FieldItem label='Contact Name' value={contact?.name} />
|
||||||
/>
|
<FieldItem label='Contact Email' value={contact?.email} />
|
||||||
<FieldItem label='มอบหมายโดย' value={enquiry.assignedByName} />
|
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
||||||
<FieldItem label='หมายเหตุการมอบหมาย' value={enquiry.assignmentRemark} />
|
<FieldItem label='Branch' value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null} />
|
||||||
<div className='md:col-span-2 xl:col-span-4'>
|
</CardContent>
|
||||||
<div className='space-y-3'>
|
</Card>
|
||||||
<div className='text-muted-foreground text-xs'>
|
|
||||||
{isLeadWorkspace ? 'ผู้เกี่ยวข้องของลีด' : 'ผู้เกี่ยวข้องในโครงการ'}
|
<Card>
|
||||||
</div>
|
<CardHeader>
|
||||||
{!projectPartiesData.items.length ? (
|
<CardTitle>Requirement</CardTitle>
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
</CardHeader>
|
||||||
{isLeadWorkspace
|
<CardContent className='space-y-4'>
|
||||||
? 'ยังไม่มีผู้เกี่ยวข้องของลีด'
|
<FieldItem label='Title' value={enquiry.title} />
|
||||||
: 'ยังไม่มีผู้เกี่ยวข้องในโครงการ'}
|
<FieldItem label='Description' value={enquiry.description} />
|
||||||
</div>
|
<FieldItem label='Requirement' value={enquiry.requirement} />
|
||||||
) : (
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<div className='space-y-3'>
|
<FieldItem label='Source' value={enquiry.source} />
|
||||||
{projectPartiesData.items.map((item) => (
|
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
|
||||||
|
<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'>
|
<TabsContent value='followups'>
|
||||||
<EnquiryFollowupsTab
|
<EnquiryFollowupsTab
|
||||||
enquiryId={enquiryId}
|
enquiryId={enquiryId}
|
||||||
@@ -298,91 +272,65 @@ export function EnquiryDetail({
|
|||||||
canDelete={canManageFollowups.delete}
|
canDelete={canManageFollowups.delete}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='activity'>
|
|
||||||
|
<TabsContent value='quotations'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{isLeadWorkspace ? 'กิจกรรมของลีด' : 'กิจกรรม'}</CardTitle>
|
<CardTitle>Linked Quotations</CardTitle>
|
||||||
<CardDescription>ประวัติการเปลี่ยนแปลงของรายการนี้และงานติดตามที่เกี่ยวข้อง</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-3'>
|
||||||
{!data.activity.length ? (
|
{!canViewRelatedQuotations ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<div className='text-muted-foreground text-sm'>Quotation access is restricted.</div>
|
||||||
ยังไม่มีกิจกรรม
|
) : relatedQuotations.length === 0 ? (
|
||||||
</div>
|
<div className='text-muted-foreground text-sm'>No quotations created yet.</div>
|
||||||
) : (
|
) : (
|
||||||
data.activity.map((item, index) => (
|
relatedQuotations.map((quotation) => (
|
||||||
<div key={item.id} className='space-y-4'>
|
<div key={quotation.id} className='border-border rounded-md border p-3'>
|
||||||
<div className='flex items-start gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
<Link
|
||||||
<div className='space-y-1'>
|
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||||
<div className='flex items-center gap-2'>
|
className='font-medium hover:underline'
|
||||||
<Badge variant='outline' className='capitalize'>
|
>
|
||||||
{item.action}
|
{quotation.code}
|
||||||
</Badge>
|
</Link>
|
||||||
<Badge variant='secondary'>
|
<Badge variant='outline'>{quotation.status}</Badge>
|
||||||
{item.entityType.replaceAll('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<span className='text-sm font-medium'>
|
|
||||||
{item.actorName ?? item.userId}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='mt-1 text-sm'>{quotation.quotationType}</div>
|
||||||
{new Date(item.createdAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{index < data.activity.length - 1 ? <Separator /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{canViewRelatedQuotations ? (
|
|
||||||
<TabsContent value='related'>
|
<TabsContent value='activity'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{relatedTabLabel}</CardTitle>
|
<CardTitle>Activity</CardTitle>
|
||||||
<CardDescription>
|
|
||||||
ใบเสนอราคาที่เชื่อมกับรายการนี้แล้ว
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className='space-y-3'>
|
||||||
{!relatedQuotations.length ? (
|
{data.activity.length === 0 ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<div className='text-muted-foreground text-sm'>No activity yet.</div>
|
||||||
ยังไม่มีใบเสนอราคาที่เชื่อมกับรายการนี้
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-3'>
|
data.activity.map((activity) => (
|
||||||
{relatedQuotations.map((quotation) => (
|
<div key={activity.id} className='border-border rounded-md border p-3'>
|
||||||
<Link
|
<div className='font-medium'>{activity.action}</div>
|
||||||
key={quotation.id}
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
{activity.actorName ?? activity.userId} -{' '}
|
||||||
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
{new Date(activity.createdAt).toLocaleString()}
|
||||||
>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
))
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
|
<QuotationReadinessPanel enquiry={enquiry} />
|
||||||
|
|
||||||
<EnquiryOutcomeCard
|
<EnquiryOutcomeCard
|
||||||
enquiryId={enquiryId}
|
enquiryId={enquiryId}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
@@ -391,42 +339,22 @@ export function EnquiryDetail({
|
|||||||
canMarkLost={canMarkLost}
|
canMarkLost={canMarkLost}
|
||||||
canReopen={canReopen}
|
canReopen={canReopen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
<CardTitle>Quotation Context</CardTitle>
|
||||||
<CardDescription>{snapshotDescription}</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-3'>
|
||||||
<FieldItem label='ขั้นตอนใน Pipeline' value={pipelineStageLabel} />
|
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
|
||||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
|
||||||
<FieldItem label='มอบหมายโดย' value={enquiry.assignedByName} />
|
|
||||||
<FieldItem label='สร้างโดย' value={enquiry.createdBy} />
|
|
||||||
<FieldItem label='อัปเดตโดย' value={enquiry.updatedBy} />
|
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='มอบหมายเมื่อ'
|
label='Project Parties'
|
||||||
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
|
value={String(projectPartiesData.items.length)}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='สร้างเมื่อ' value={new Date(enquiry.createdAt).toLocaleString()} />
|
<FieldItem label='Record Type' value={workspace === 'enquiry' ? 'Opportunity' : 'Lead'} />
|
||||||
<FieldItem label='อัปเดตเมื่อ' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function EnquiryListing({
|
|||||||
const branch = searchParamsCache.get('branch');
|
const branch = searchParamsCache.get('branch');
|
||||||
const customer = searchParamsCache.get('customer');
|
const customer = searchParamsCache.get('customer');
|
||||||
const sort = searchParamsCache.get('sort');
|
const sort = searchParamsCache.get('sort');
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -41,8 +42,8 @@ export default function EnquiryListing({
|
|||||||
...(customer && { customer }),
|
...(customer && { customer }),
|
||||||
...(sort && { sort })
|
...(sort && { sort })
|
||||||
};
|
};
|
||||||
const queryClient = getQueryClient();
|
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
|
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
|
||||||
|
|
||||||
return (
|
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