From f2c7156851787b7cad10eba5b67bb844695b9ee3 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Thu, 25 Jun 2026 08:27:20 +0700 Subject: [PATCH] task-d.5.5 --- ...-opportunity-sales-workspace-refinement.md | 92 ++++ plans/task-d.5.5.md | 432 +++++++++++++++ src/app/dashboard/crm/enquiries/[id]/page.tsx | 45 +- src/app/dashboard/crm/enquiries/page.tsx | 101 ++-- src/features/crm/enquiries/api/types.ts | 3 + .../enquiries/components/enquiries-table.tsx | 47 +- .../enquiries/components/enquiry-columns.tsx | 254 ++++----- .../enquiries/components/enquiry-detail.tsx | 510 ++++++++---------- .../enquiries/components/enquiry-listing.tsx | 3 +- .../components/quotation-readiness-panel.tsx | 46 ++ .../enquiries/components/source-lead-card.tsx | 46 ++ 11 files changed, 1049 insertions(+), 530 deletions(-) create mode 100644 docs/implementation/task-d55-opportunity-sales-workspace-refinement.md create mode 100644 plans/task-d.5.5.md create mode 100644 src/features/crm/enquiries/components/quotation-readiness-panel.tsx create mode 100644 src/features/crm/enquiries/components/source-lead-card.tsx diff --git a/docs/implementation/task-d55-opportunity-sales-workspace-refinement.md b/docs/implementation/task-d55-opportunity-sales-workspace-refinement.md new file mode 100644 index 0000000..401676b --- /dev/null +++ b/docs/implementation/task-d55-opportunity-sales-workspace-refinement.md @@ -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` diff --git a/plans/task-d.5.5.md b/plans/task-d.5.5.md new file mode 100644 index 0000000..2311fa4 --- /dev/null +++ b/plans/task-d.5.5.md @@ -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 +``` diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index 6fd48a9..b900ed0 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -1,9 +1,7 @@ import { auth } from '@/auth'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { notFound } from 'next/navigation'; -import Link from 'next/link'; import PageContainer from '@/components/layout/page-container'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { enquiryByIdOptions, enquiryFollowupsOptions, @@ -13,6 +11,7 @@ import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detai import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; import { getLeadById } from '@/features/crm/leads/server/service'; import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service'; +import { getQuotationReferenceData } from '@/features/crm/quotations/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -90,6 +89,12 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead))); + const canCreateQuotation = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationCreate))); + const canViewOutcomeCommercialData = canRead; if (!canRead || !session?.user?.activeOrganizationId || !session.user.id) { @@ -119,33 +124,20 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { ]); const enquiryDetail = await getEnquiryDetail(id, session.user.activeOrganizationId, accessContext); - const [referenceData, relatedQuotations, sourceLead] = await Promise.all([ + const [referenceData, relatedQuotations, sourceLead, quotationReferenceData] = await Promise.all([ getEnquiryReferenceData(session.user.activeOrganizationId), listEnquiryQuotationRelations(id, session.user.activeOrganizationId), enquiryDetail.leadId ? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null) - : Promise.resolve(null) + : Promise.resolve(null), + canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : Promise.resolve(null) ]); return ( - {sourceLead ? ( - - - Source Lead - - - - {sourceLead.code} - -
{sourceLead.projectName ?? 'No project name'}
-
-
- ) : null} -
diff --git a/src/app/dashboard/crm/enquiries/page.tsx b/src/app/dashboard/crm/enquiries/page.tsx index 26c50b0..2ddbd41 100644 --- a/src/app/dashboard/crm/enquiries/page.tsx +++ b/src/app/dashboard/crm/enquiries/page.tsx @@ -1,14 +1,13 @@ -import { auth } from "@/auth"; -import PageContainer from "@/components/layout/page-container"; -import EnquiryListing from "@/features/crm/enquiries/components/enquiry-listing"; -import { EnquiryFormSheetTrigger } from "@/features/crm/enquiries/components/enquiry-form-sheet"; -import { getEnquiryReferenceData } from "@/features/crm/enquiries/server/service"; -import { PERMISSIONS } from "@/lib/auth/rbac"; -import { searchParamsCache } from "@/lib/searchparams"; -import type { SearchParams } from "nuqs/server"; +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing'; +import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet'; +import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import type { SearchParams } from 'nuqs/server'; export const metadata = { - title: "Dashboard: CRM Enquiries", + title: 'Dashboard: CRM Opportunities' }; type PageProps = { @@ -16,75 +15,69 @@ type PageProps = { }; export default async function EnquiriesRoute(props: PageProps) { - const searchParams = await props.searchParams; + await props.searchParams; const session = await auth(); + const canRead = - session?.user?.systemRole === "super_admin" || + session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === "admin" || + (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead))); + const canCreate = - session?.user?.systemRole === "super_admin" || + session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === "admin" || + (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate))); + const canUpdate = - session?.user?.systemRole === "super_admin" || + session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === "admin" || + (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate))); + const canDelete = - session?.user?.systemRole === "super_admin" || + session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === "admin" || + (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete))); + const canAssign = - session?.user?.systemRole === "super_admin" || + session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === "admin" || + (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign))); + const canReassign = - session?.user?.systemRole === "super_admin" || + session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === "admin" || - session.user.activePermissions.includes( - PERMISSIONS.crmEnquiryReassign, - ))); - const businessRole = session?.user?.activeBusinessRole ?? null; + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign))); - searchParamsCache.parse(searchParams); + if (!canRead || !session?.user?.activeOrganizationId) { + return ( + + {null} + + ); + } - const referenceData = - canRead && session?.user?.activeOrganizationId - ? await getEnquiryReferenceData(session.user.activeOrganizationId) - : null; + const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId); return ( - You do not have access to CRM enquiries. - - } - pageHeaderAction={ - canCreate && referenceData ? ( - - ) : null - } + pageTitle='CRM Opportunities' + pageDescription='Sales execution workspace for opportunities created directly or handed off from marketing leads.' + pageHeaderAction={canCreate ? : null} > - {referenceData ? ( - - ) : null} + ); } diff --git a/src/features/crm/enquiries/api/types.ts b/src/features/crm/enquiries/api/types.ts index f34762f..770d871 100644 --- a/src/features/crm/enquiries/api/types.ts +++ b/src/features/crm/enquiries/api/types.ts @@ -132,6 +132,9 @@ export interface EnquiryRecord { export interface EnquiryListItem extends EnquiryRecord { customerName: string; contactName: string | null; + sourceLeadCode?: string | null; + sourceLeadProjectName?: string | null; + lastFollowupDate?: string | null; followupCount: number; quotationCount: number; nextFollowupDate: string | null; diff --git a/src/features/crm/enquiries/components/enquiries-table.tsx b/src/features/crm/enquiries/components/enquiries-table.tsx index bba61fb..b0e0a18 100644 --- a/src/features/crm/enquiries/components/enquiries-table.tsx +++ b/src/features/crm/enquiries/components/enquiries-table.tsx @@ -1,15 +1,15 @@ -"use client"; +'use client'; -import { useMemo } from "react"; -import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { DataTable } from "@/components/ui/table/data-table"; -import { DataTableToolbar } from "@/components/ui/table/data-table-toolbar"; -import { useDataTable } from "@/hooks/use-data-table"; -import { getSortingStateParser } from "@/lib/parsers"; -import { enquiriesQueryOptions } from "../api/queries"; -import type { EnquiryReferenceData } from "../api/types"; -import { getEnquiryColumns } from "./enquiry-columns"; +import { useMemo } from 'react'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { DataTable } from '@/components/ui/table/data-table'; +import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar'; +import { useDataTable } from '@/hooks/use-data-table'; +import { getSortingStateParser } from '@/lib/parsers'; +import { enquiriesQueryOptions } from '../api/queries'; +import type { EnquiryReferenceData } from '../api/types'; +import { getEnquiryColumns } from './enquiry-columns'; export function EnquiriesTable({ referenceData, @@ -17,10 +17,10 @@ export function EnquiriesTable({ canUpdate, canDelete, canAssign, - canReassign, + canReassign }: { referenceData: EnquiryReferenceData; - workspace: "lead" | "enquiry"; + workspace: 'lead' | 'enquiry'; canUpdate: boolean; canDelete: boolean; canAssign: boolean; @@ -34,13 +34,12 @@ export function EnquiriesTable({ canUpdate, canDelete, canAssign, - canReassign, + canReassign }), - [referenceData, workspace, canUpdate, canDelete, canAssign, canReassign], + [referenceData, workspace, canUpdate, canDelete, canAssign, canReassign] ); - const columnIds = columns - .map((column) => column.id) - .filter(Boolean) as string[]; + + const columnIds = columns.map((column) => column.id).filter(Boolean) as string[]; const [params] = useQueryStates({ page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), @@ -50,7 +49,7 @@ export function EnquiriesTable({ priority: parseAsString, branch: parseAsString, customer: parseAsString, - sort: getSortingStateParser(columnIds).withDefault([]), + sort: getSortingStateParser(columnIds).withDefault([]) }); const filters = { @@ -63,21 +62,19 @@ export function EnquiriesTable({ ...(params.priority && { priority: params.priority }), ...(params.branch && { branch: params.branch }), ...(params.customer && { customer: params.customer }), - ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }), + ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) }; const { data } = useSuspenseQuery(enquiriesQueryOptions(filters)); - console.log("data", data); - const pageCount = Math.ceil(data.totalItems / params.perPage); + const pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage)); + const { table } = useDataTable({ data: data.items, columns, pageCount, shallow: true, debounceMs: 500, - initialState: { - columnPinning: { right: ["actions"] }, - }, + clearOnDefault: true }); return ( diff --git a/src/features/crm/enquiries/components/enquiry-columns.tsx b/src/features/crm/enquiries/components/enquiry-columns.tsx index 6be94b9..2b204fb 100644 --- a/src/features/crm/enquiries/components/enquiry-columns.tsx +++ b/src/features/crm/enquiries/components/enquiry-columns.tsx @@ -2,13 +2,21 @@ import Link from 'next/link'; import type { Column, ColumnDef } from '@tanstack/react-table'; +import { Icons } from '@/components/icons'; import { Badge } from '@/components/ui/badge'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; -import { Icons } from '@/components/icons'; import type { EnquiryListItem, EnquiryReferenceData } from '../api/types'; import { EnquiryCellAction } from './enquiry-cell-action'; import { EnquiryStatusBadge } from './enquiry-status-badge'; +type SharedColumnsConfig = { + referenceData: EnquiryReferenceData; + canUpdate: boolean; + canDelete: boolean; + canAssign: boolean; + canReassign: boolean; +}; + function getLeadColumns({ referenceData, canUpdate, @@ -28,10 +36,7 @@ function getLeadColumns({ ), cell: ({ row }) => (
- + {row.original.code} {row.original.title} @@ -57,68 +62,19 @@ function getLeadColumns({ id: 'customer', accessorFn: (row) => row.customerId, header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => {row.original.customerName}, meta: { - label: 'Company', - variant: 'multiSelect' as const, + label: 'Customer', + variant: 'select' as const, options: referenceData.customers.map((item) => ({ value: item.id, - label: item.name + label: `${item.name} (${item.code})` })) }, enableColumnFilter: true }, - { - id: 'contact', - accessorFn: (row) => row.contactId ?? '', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => {row.original.contactName ?? '-'} - }, - { - id: 'assignedSales', - accessorFn: (row) => row.assignedToName ?? '', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => {row.original.assignedToName ?? 'Unassigned'} - }, - { - id: 'createdBy', - accessorFn: (row) => row.createdByName ?? row.createdBy, - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => {row.original.createdByName ?? row.original.createdBy} - }, - { - id: 'createdAt', - accessorKey: 'createdAt', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()} - }, - { - id: 'age', - accessorFn: (row) => row.createdAt, - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => { - const ageInDays = Math.max( - 0, - Math.floor( - (Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24) - ) - ); - - return {ageInDays} days; - } - }, { id: 'status', accessorKey: 'status', @@ -164,13 +120,14 @@ function getEnquiryWorkspaceColumns({ canReassign }: SharedColumnsConfig): ColumnDef[] { const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item])); + const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label])); return [ { id: 'code', accessorKey: 'code', header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => (
@@ -184,92 +141,76 @@ function getEnquiryWorkspaceColumns({
), meta: { - label: 'Code', - placeholder: 'Search enquiries...', + label: 'Opportunity', + placeholder: 'Search opportunities...', variant: 'text' as const, icon: Icons.search }, enableColumnFilter: true }, { - id: 'projectName', - accessorFn: (row) => row.projectName ?? row.title, + id: 'sourceLead', + accessorFn: (row) => (row.leadId ? 'linked' : 'direct'), header: ({ column }: { column: Column }) => ( - + ), - cell: ({ row }) => {row.original.projectName ?? row.original.title} + cell: ({ row }) => + row.original.leadId ? ( + {row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'} + ) : ( + Direct sales + ) }, { id: 'customer', accessorFn: (row) => row.customerId, header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => {row.original.customerName}, meta: { - label: 'ลูกค้าผู้รับใบเสนอราคา', - variant: 'multiSelect' as const, + label: 'Customer', + variant: 'select' as const, options: referenceData.customers.map((item) => ({ value: item.id, - label: item.name + label: `${item.name} (${item.code})` })) }, enableColumnFilter: true }, { - id: 'assignedSales', + id: 'projectName', + accessorFn: (row) => row.projectName ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.projectName ?? '-'} + }, + { + id: 'productType', + accessorFn: (row) => row.productType, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {productTypeMap.get(row.original.productType) ?? row.original.productType}, + meta: { + label: 'Product Type', + variant: 'multiSelect' as const, + options: referenceData.productTypes.map((item) => ({ + value: item.id, + label: item.label + })) + }, + enableColumnFilter: true + }, + { + id: 'assignedToName', accessorFn: (row) => row.assignedToName ?? '', header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => {row.original.assignedToName ?? '-'} }, - { - id: 'chancePercent', - accessorKey: 'chancePercent', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => ( - {row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'} - ) - }, - { - id: 'estimatedValue', - accessorKey: 'estimatedValue', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.estimatedValue !== null - ? row.original.estimatedValue.toLocaleString() - : '-'} - - ) - }, - { - id: 'quotationCount', - accessorKey: 'quotationCount', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => {row.original.quotationCount} - }, - { - id: 'nextFollowupDate', - accessorKey: 'nextFollowupDate', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.nextFollowupDate - ? new Date(row.original.nextFollowupDate).toLocaleDateString() - : '-'} - - ) - }, { id: 'status', accessorKey: 'status', @@ -290,6 +231,60 @@ function getEnquiryWorkspaceColumns({ }, enableColumnFilter: true }, + { + id: 'estimatedValue', + accessorKey: 'estimatedValue', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'} + + ) + }, + { + id: 'chancePercent', + accessorKey: 'chancePercent', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'} + }, + { + id: 'expectedCloseDate', + accessorKey: 'expectedCloseDate', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.expectedCloseDate + ? new Date(row.original.expectedCloseDate).toLocaleDateString() + : '-'} + + ) + }, + { + id: 'nextFollowupDate', + accessorKey: 'nextFollowupDate', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.nextFollowupDate ? new Date(row.original.nextFollowupDate).toLocaleDateString() : '-'} + + ) + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()} + }, { id: 'actions', cell: ({ row }) => ( @@ -307,14 +302,6 @@ function getEnquiryWorkspaceColumns({ ]; } -type SharedColumnsConfig = { - referenceData: EnquiryReferenceData; - canUpdate: boolean; - canDelete: boolean; - canAssign: boolean; - canReassign: boolean; -}; - export function getEnquiryColumns({ referenceData, workspace, @@ -322,18 +309,7 @@ export function getEnquiryColumns({ canDelete, canAssign, canReassign -}: SharedColumnsConfig & { - workspace: 'lead' | 'enquiry'; -}): ColumnDef[] { - const sharedConfig = { - referenceData, - canUpdate, - canDelete, - canAssign, - canReassign - }; - - return workspace === 'lead' - ? getLeadColumns(sharedConfig) - : getEnquiryWorkspaceColumns(sharedConfig); +}: SharedColumnsConfig & { workspace: 'lead' | 'enquiry' }): ColumnDef[] { + const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign }; + return workspace === 'lead' ? getLeadColumns(sharedConfig) : getEnquiryWorkspaceColumns(sharedConfig); } diff --git a/src/features/crm/enquiries/components/enquiry-detail.tsx b/src/features/crm/enquiries/components/enquiry-detail.tsx index 3eb9aae..b448e14 100644 --- a/src/features/crm/enquiries/components/enquiry-detail.tsx +++ b/src/features/crm/enquiries/components/enquiry-detail.tsx @@ -7,9 +7,9 @@ import { Icons } from '@/components/icons'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; +import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types'; +import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet'; import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology'; import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries'; import type { EnquiryRecord, EnquiryReferenceData } from '../api/types'; @@ -18,6 +18,8 @@ import { EnquiryFollowupsTab } from './enquiry-followups-tab'; import { EnquiryFormSheet } from './enquiry-form-sheet'; import { EnquiryOutcomeCard } from './enquiry-outcome-card'; import { EnquiryStatusBadge } from './enquiry-status-badge'; +import { QuotationReadinessPanel } from './quotation-readiness-panel'; +import { SourceLeadCard, type SourceLeadSummary } from './source-lead-card'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { return ( @@ -45,11 +47,14 @@ interface EnquiryDetailProps { canMarkWon: boolean; canMarkLost: boolean; canReopen: boolean; + canCreateQuotation: boolean; canManageFollowups: { create: boolean; update: boolean; delete: boolean; }; + sourceLead?: SourceLeadSummary | null; + quotationReferenceData?: QuotationReferenceData | null; } export function EnquiryDetail({ @@ -65,13 +70,18 @@ export function EnquiryDetail({ canMarkWon, canMarkLost, canReopen, - canManageFollowups + canCreateQuotation, + canManageFollowups, + sourceLead = null, + quotationReferenceData = null }: EnquiryDetailProps) { const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId)); const [editOpen, setEditOpen] = useState(false); const [assignmentOpen, setAssignmentOpen] = useState(false); + const [quotationOpen, setQuotationOpen] = useState(false); const enquiry = data.enquiry; + const branchMap = useMemo( () => new Map(referenceData.branches.map((item) => [item.id, item.name])), [referenceData] @@ -89,300 +99,238 @@ export function EnquiryDetail({ [referenceData] ); const productTypeMap = useMemo( - () => new Map(referenceData.productTypes.map((item) => [item.id, item])), + () => new Map(referenceData.productTypes.map((item) => [item.id, item.label])), [referenceData] ); const priorityMap = useMemo( - () => new Map(referenceData.priorities.map((item) => [item.id, item])), - [referenceData] - ); - const leadChannelMap = useMemo( - () => new Map(referenceData.leadChannels.map((item) => [item.id, item])), + () => new Map(referenceData.priorities.map((item) => [item.id, item.label])), [referenceData] ); + const customer = customerMap.get(enquiry.customerId); const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null; const status = statusMap.get(enquiry.status); + const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage); const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign; const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign'; - const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage); - const isLeadWorkspace = workspace === 'lead'; - const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries'; - const recordLabel = isLeadWorkspace ? 'ลีด' : 'โอกาสขาย'; - const ownerLabel = isLeadWorkspace ? 'ฝ่ายขายที่รับผิดชอบ' : 'ผู้ดูแลโอกาสขาย'; - const infoCardTitle = isLeadWorkspace ? 'ข้อมูลลีด' : 'ข้อมูลโอกาสขาย'; - const infoCardDescription = isLeadWorkspace - ? 'ที่มาของลีด ข้อมูลการคัดกรอง และความพร้อมในการส่งต่อให้ฝ่ายขาย' - : 'ขอบเขตงาน ข้อมูลผู้รับผิดชอบ และรายละเอียดการดูแลของฝ่ายขาย'; - const assignmentButtonLabel = - assignmentMode === 'assign' - ? isLeadWorkspace - ? 'ส่งต่อให้ฝ่ายขาย' - : 'มอบหมายผู้ดูแลโอกาสขาย' - : isLeadWorkspace - ? 'เปลี่ยนฝ่ายขายที่รับผิดชอบ' - : 'เปลี่ยนผู้ดูแลโอกาสขาย'; - const projectSectionLabel = isLeadWorkspace ? 'ขอบเขตลีด' : 'ข้อมูลโครงการ'; - const followupTabLabel = isLeadWorkspace ? 'งานติดตามลีด' : 'งานติดตาม'; - const relatedTabLabel = isLeadWorkspace ? 'ใบเสนอราคาที่เกี่ยวข้อง' : 'ใบเสนอราคา'; - const snapshotDescription = isLeadWorkspace - ? 'ข้อมูลระบบและสถานะการส่งต่อของลีดรายการนี้' - : 'ข้อมูลระบบและสถานะการรับผิดชอบของโอกาสขายรายการนี้'; return (
-
-
-
- - {enquiry.code} - - {enquiry.isHotProject ? Hot : null} -
-
-

{enquiry.title}

-

- {pipelineStageLabel} | {customer?.name ?? 'ไม่ทราบชื่อลูกค้า'} - {contact ? ` | ${contact.name}` : ''} -

+ + + {quotationReferenceData ? ( + + ) : null} + +
+
+
+

{enquiry.code}

+
+

+ Opportunity detail for sales execution, follow-up continuity, and quotation readiness. +

+
+ {canCreateQuotation ? ( + + ) : null} + {sourceLead ? ( + + ) : null} {canManageAssignment ? ( ) : null} {canUpdate ? ( ) : null}
-
-
+
+
- - - - ภาพรวม - {followupTabLabel} - กิจกรรม - {canViewRelatedQuotations ? ( - {relatedTabLabel} - ) : null} - - - - - {infoCardTitle} - {infoCardDescription} - - - - - - - - - - - - - - - - - - - -
-
-
- {isLeadWorkspace ? 'ผู้เกี่ยวข้องของลีด' : 'ผู้เกี่ยวข้องในโครงการ'} -
- {!projectPartiesData.items.length ? ( -
- {isLeadWorkspace - ? 'ยังไม่มีผู้เกี่ยวข้องของลีด' - : 'ยังไม่มีผู้เกี่ยวข้องในโครงการ'} -
- ) : ( -
- {projectPartiesData.items.map((item) => ( -
-
{item.customerName}
-
- บทบาท: {item.roleLabel ?? item.roleCode} -
- {item.remark ?
{item.remark}
: null} -
- ))} -
- )} -
-
-
- -
-
- -
-
- -
-
- -
-
-
-
- - - - - - - {isLeadWorkspace ? 'กิจกรรมของลีด' : 'กิจกรรม'} - ประวัติการเปลี่ยนแปลงของรายการนี้และงานติดตามที่เกี่ยวข้อง - - - {!data.activity.length ? ( -
- ยังไม่มีกิจกรรม -
- ) : ( - data.activity.map((item, index) => ( -
-
-
-
-
- - {item.action} - - - {item.entityType.replaceAll('_', ' ')} - - - {item.actorName ?? item.userId} - -
-
- {new Date(item.createdAt).toLocaleString()} -
-
-
- {index < data.activity.length - 1 ? : null} -
- )) - )} - - - - {canViewRelatedQuotations ? ( - - - - {relatedTabLabel} - - ใบเสนอราคาที่เชื่อมกับรายการนี้แล้ว - - - - {!relatedQuotations.length ? ( -
- ยังไม่มีใบเสนอราคาที่เชื่อมกับรายการนี้ -
- ) : ( -
- {relatedQuotations.map((quotation) => ( - -
-
-
{quotation.code}
-
- {quotation.totalAmount.toLocaleString()} -
-
- -
- - ))} -
- )} -
-
-
- ) : null} - + + Opportunity Summary + Core sales execution snapshot for this opportunity. + + + + + + + + + + + + + + + + + Customer / Contact + + + + + + + + + + + + + + Requirement + + + + + +
+ + +
+
+
+ + + + Sales Qualification + + + + + + + + + + + + + + Follow-up Timeline + Linked Quotations + Activity + + + + + + + + + + Linked Quotations + + + {!canViewRelatedQuotations ? ( +
Quotation access is restricted.
+ ) : relatedQuotations.length === 0 ? ( +
No quotations created yet.
+ ) : ( + relatedQuotations.map((quotation) => ( +
+
+ + {quotation.code} + + {quotation.status} +
+
{quotation.quotationType}
+
+ )) + )} +
+
+
+ + + + + Activity + + + {data.activity.length === 0 ? ( +
No activity yet.
+ ) : ( + data.activity.map((activity) => ( +
+
{activity.action}
+
+ {activity.actorName ?? activity.userId} -{' '} + {new Date(activity.createdAt).toLocaleString()} +
+
+ )) + )} +
+
+
+
+ + + - ข้อมูลระบบ - {snapshotDescription} + Quotation Context - - - - - - + + - - +
- - -
); } diff --git a/src/features/crm/enquiries/components/enquiry-listing.tsx b/src/features/crm/enquiries/components/enquiry-listing.tsx index 32dab7f..c51698f 100644 --- a/src/features/crm/enquiries/components/enquiry-listing.tsx +++ b/src/features/crm/enquiries/components/enquiry-listing.tsx @@ -29,6 +29,7 @@ export default function EnquiryListing({ const branch = searchParamsCache.get('branch'); const customer = searchParamsCache.get('customer'); const sort = searchParamsCache.get('sort'); + const filters = { page, limit, @@ -41,8 +42,8 @@ export default function EnquiryListing({ ...(customer && { customer }), ...(sort && { sort }) }; - const queryClient = getQueryClient(); + const queryClient = getQueryClient(); void queryClient.prefetchQuery(enquiriesQueryOptions(filters)); return ( diff --git a/src/features/crm/enquiries/components/quotation-readiness-panel.tsx b/src/features/crm/enquiries/components/quotation-readiness-panel.tsx new file mode 100644 index 0000000..8df7d90 --- /dev/null +++ b/src/features/crm/enquiries/components/quotation-readiness-panel.tsx @@ -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 ( + + + Quotation Readiness + + +
+ {isReady ? 'Ready to create quotation' : 'Missing required information'} +
+
+ {checks.map((item) => ( +
+ {item.label} + + {item.ready ? 'Ready' : 'Missing'} + +
+ ))} +
+
+
+ ); +} diff --git a/src/features/crm/enquiries/components/source-lead-card.tsx b/src/features/crm/enquiries/components/source-lead-card.tsx new file mode 100644 index 0000000..d77c631 --- /dev/null +++ b/src/features/crm/enquiries/components/source-lead-card.tsx @@ -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 ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +export function SourceLeadCard({ sourceLead }: { sourceLead: SourceLeadSummary | null }) { + if (!sourceLead) { + return null; + } + + return ( + + + Source Lead + + + + + + +
+ + Open Source Lead + +
+
+
+ ); +}