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