task-d.5.5

This commit is contained in:
phaichayon
2026-06-25 08:27:20 +07:00
parent 6093003b70
commit f2c7156851
11 changed files with 1049 additions and 530 deletions

View File

@@ -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
View 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
```

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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);
} }

View File

@@ -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>
); );
} }

View File

@@ -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 (

View File

@@ -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>
);
}

View 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>
);
}