task-d.5.5

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { EnquiryRecord } from '../api/types';
type ReadinessItem = {
label: string;
ready: boolean;
};
export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord }) {
const checks: ReadinessItem[] = [
{ label: 'Customer selected', ready: Boolean(enquiry.customerId) },
{ label: 'Contact selected', ready: Boolean(enquiry.contactId) },
{ label: 'Project name present', ready: Boolean(enquiry.projectName?.trim()) },
{ label: 'Product type present', ready: Boolean(enquiry.productType) },
{ label: 'Requirement present', ready: Boolean(enquiry.requirement?.trim()) },
{ label: 'Estimated value present', ready: enquiry.estimatedValue !== null }
];
const readyCount = checks.filter((item) => item.ready).length;
const isReady = readyCount === checks.length;
return (
<Card>
<CardHeader>
<CardTitle>Quotation Readiness</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='text-sm font-medium'>
{isReady ? 'Ready to create quotation' : 'Missing required information'}
</div>
<div className='space-y-2'>
{checks.map((item) => (
<div key={item.label} className='flex items-center justify-between gap-3 text-sm'>
<span>{item.label}</span>
<span className={item.ready ? 'text-green-600' : 'text-muted-foreground'}>
{item.ready ? 'Ready' : 'Missing'}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export type SourceLeadSummary = {
id: string;
code: string;
projectName: string | null;
status: string | null;
awareness: string | null;
};
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='text-sm'>{value || '-'}</div>
</div>
);
}
export function SourceLeadCard({ sourceLead }: { sourceLead: SourceLeadSummary | null }) {
if (!sourceLead) {
return null;
}
return (
<Card>
<CardHeader>
<CardTitle>Source Lead</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
<FieldItem label='Lead Code' value={sourceLead.code} />
<FieldItem label='Project Name' value={sourceLead.projectName} />
<FieldItem label='Lead Status' value={sourceLead.status} />
<FieldItem label='Awareness' value={sourceLead.awareness} />
<div className='md:col-span-2'>
<Link href={`/dashboard/crm/leads/${sourceLead.id}`} className='text-sm font-medium hover:underline'>
Open Source Lead
</Link>
</div>
</CardContent>
</Card>
);
}