task-b.1
complere
This commit is contained in:
phaichayon
2026-06-15 11:19:31 +07:00
parent 89b39cad38
commit b8cd39eaa4
55 changed files with 4295 additions and 1848 deletions

View File

@@ -10,12 +10,7 @@ import {
getQuotationById,
getQuotations
} from './service';
import type {
ApprovalFilters,
CustomerFilters,
EnquiryFilters,
QuotationFilters
} from './types';
import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types';
export const crmKeys = {
all: ['crm'] as const,

View File

@@ -86,33 +86,109 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
return clone({
metrics: [
{ id: 'm-1', label: 'Total Enquiries', value: String(crmState.enquiries.length), change: '+12%', trend: 'up' },
{ id: 'm-2', label: 'Open Enquiries', value: String(openEnquiries.length), change: '+3', trend: 'up' },
{ id: 'm-3', label: 'Pending Quotations', value: String(pendingQuotations.length), change: '+2', trend: 'up' },
{ id: 'm-4', label: 'Pending Approval', value: String(crmState.approvals.filter((item) => item.status === 'pending').length), change: 'Needs review', trend: 'flat' },
{ id: 'm-5', label: 'Won Amount', value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '+18%', trend: 'up' },
{ id: 'm-6', label: 'Lost Amount', value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '-6%', trend: 'down' },
{ id: 'm-7', label: 'Conversion Rate', value: `${conversionRate}%`, change: '+4pts', trend: 'up' },
{ id: 'm-8', label: 'Hot Projects', value: String(hotQuotations.length), change: 'Focus', trend: 'flat' },
{ id: 'm-9', label: 'Follow-up Due Today', value: String(dueFollowUps.length), change: 'Today', trend: 'flat' }
{
id: 'm-1',
label: 'Total Enquiries',
value: String(crmState.enquiries.length),
change: '+12%',
trend: 'up'
},
{
id: 'm-2',
label: 'Open Enquiries',
value: String(openEnquiries.length),
change: '+3',
trend: 'up'
},
{
id: 'm-3',
label: 'Pending Quotations',
value: String(pendingQuotations.length),
change: '+2',
trend: 'up'
},
{
id: 'm-4',
label: 'Pending Approval',
value: String(crmState.approvals.filter((item) => item.status === 'pending').length),
change: 'Needs review',
trend: 'flat'
},
{
id: 'm-5',
label: 'Won Amount',
value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`,
change: '+18%',
trend: 'up'
},
{
id: 'm-6',
label: 'Lost Amount',
value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`,
change: '-6%',
trend: 'down'
},
{
id: 'm-7',
label: 'Conversion Rate',
value: `${conversionRate}%`,
change: '+4pts',
trend: 'up'
},
{
id: 'm-8',
label: 'Hot Projects',
value: String(hotQuotations.length),
change: 'Focus',
trend: 'flat'
},
{
id: 'm-9',
label: 'Follow-up Due Today',
value: String(dueFollowUps.length),
change: 'Today',
trend: 'flat'
}
],
enquiryPipeline: [
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
{ label: 'Qualifying', value: crmState.enquiries.filter((item) => item.status === 'qualifying').length },
{ label: 'Requirement', value: crmState.enquiries.filter((item) => item.status === 'requirement').length },
{ label: 'Follow Up', value: crmState.enquiries.filter((item) => item.status === 'follow_up').length },
{ label: 'Converted', value: crmState.enquiries.filter((item) => item.status === 'converted').length }
{
label: 'Qualifying',
value: crmState.enquiries.filter((item) => item.status === 'qualifying').length
},
{
label: 'Requirement',
value: crmState.enquiries.filter((item) => item.status === 'requirement').length
},
{
label: 'Follow Up',
value: crmState.enquiries.filter((item) => item.status === 'follow_up').length
},
{
label: 'Converted',
value: crmState.enquiries.filter((item) => item.status === 'converted').length
}
],
quotationPipeline: [
{ label: 'Draft', value: crmState.quotations.filter((item) => item.status === 'draft').length },
{ label: 'Pending', value: crmState.quotations.filter((item) => item.status === 'pending_approval').length },
{
label: 'Draft',
value: crmState.quotations.filter((item) => item.status === 'draft').length
},
{
label: 'Pending',
value: crmState.quotations.filter((item) => item.status === 'pending_approval').length
},
{ label: 'Sent', value: crmState.quotations.filter((item) => item.status === 'sent').length },
{ label: 'Accepted', value: crmState.quotations.filter((item) => item.status === 'accepted').length },
{
label: 'Accepted',
value: crmState.quotations.filter((item) => item.status === 'accepted').length
},
{ label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length }
],
salesPerformance: crmState.salespersons.map((salesperson) => ({
label: salesperson.nickname,
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id).length,
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id)
.length,
secondaryValue: crmState.quotations
.filter((quotation) => quotation.salesmanId === salesperson.id)
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
@@ -170,7 +246,9 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
customer,
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
shares: crmState.contactShares.filter((share) =>
crmState.contacts.some((contact) => contact.id === share.contactId && contact.customerId === id)
crmState.contacts.some(
(contact) => contact.id === share.contactId && contact.customerId === id
)
),
shareLogs: crmState.contactShareLogs.filter((log) =>
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
@@ -190,14 +268,16 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedRe
if (filters.search) {
const search = filters.search.toLowerCase();
items = items.filter(
(item) => item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search)
(item) =>
item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search)
);
}
if (filters.status) items = items.filter((item) => item.status === filters.status);
if (filters.productType) items = items.filter((item) => item.productType === filters.productType);
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!);
if (filters.dateTo) items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
if (filters.dateTo)
items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
items = sortItems(items, filters.sort);
return clone(paginate(items, filters.page, filters.limit));
@@ -235,11 +315,18 @@ export async function getQuotations(
);
}
if (filters.status) items = items.filter((quotation) => quotation.status === filters.status);
if (filters.quotationType) items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
if (filters.salesman) items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
if (filters.hot) items = items.filter((quotation) => (filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject));
if (filters.dateFrom) items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
if (filters.dateTo) items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
if (filters.quotationType)
items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
if (filters.salesman)
items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
if (filters.hot)
items = items.filter((quotation) =>
filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject
);
if (filters.dateFrom)
items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
if (filters.dateTo)
items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
items = sortItems(items, filters.sort);
return clone(paginate(items, filters.page, filters.limit));
@@ -253,7 +340,9 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
const contactIds = [...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))] as string[];
const contactIds = [
...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))
] as string[];
return clone({
quotation,
@@ -301,7 +390,9 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
quotationType: 'official',
project: enquiry.title,
siteLocation: enquiry.projectLocation,
customerRoles: [{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }],
customerRoles: [
{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }
],
salesmanId: enquiry.salesmanId,
saleAdminId: 'sp-2',
branchId: enquiry.branchId,
@@ -310,10 +401,30 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
totalAmount: enquiry.expectedValue,
chancePercent: enquiry.chancePercent,
isHotProject: enquiry.chancePercent >= 70,
items: [{ id: `${nextId}-item-1`, topic: 'Scope', description: enquiry.requirementSummary, quantity: 1, unit: 'lot', unitPrice: enquiry.expectedValue, amount: enquiry.expectedValue }],
items: [
{
id: `${nextId}-item-1`,
topic: 'Scope',
description: enquiry.requirementSummary,
quantity: 1,
unit: 'lot',
unitPrice: enquiry.expectedValue,
amount: enquiry.expectedValue
}
],
topics: [
{ id: `${nextId}-topic-1`, type: 'scope', label: 'Scope', items: [enquiry.requirementSummary] },
{ id: `${nextId}-topic-2`, type: 'exclusion', label: 'Exclusion', items: ['To be confirmed'] },
{
id: `${nextId}-topic-1`,
type: 'scope',
label: 'Scope',
items: [enquiry.requirementSummary]
},
{
id: `${nextId}-topic-2`,
type: 'exclusion',
label: 'Exclusion',
items: ['To be confirmed']
},
{ id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] }
],
followUps: [],
@@ -357,7 +468,13 @@ export async function submitQuotationApproval(quotationId: string) {
quotation.approvalSteps.length > 0
? quotation.approvalSteps
: [
{ id: `${quotationId}-ap-1`, level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }
{
id: `${quotationId}-ap-1`,
level: 1,
approverName: 'Sales Director',
approverPosition: 'Director',
status: 'pending'
}
]
}
: quotation
@@ -368,11 +485,11 @@ export async function approveQuotation(payload: ApprovalActionPayload) {
crmState.quotations = crmState.quotations.map((quotation) => {
if (quotation.id !== payload.quotationId) return quotation;
const nextSteps = quotation.approvalSteps.map((step, index) =>
const nextSteps: typeof quotation.approvalSteps = quotation.approvalSteps.map((step, index) =>
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
? {
...step,
status: 'approved',
status: 'approved' as const,
actedAt: '2026-06-11T11:00:00.000Z',
comment: payload.comment
}
@@ -384,7 +501,9 @@ export async function approveQuotation(payload: ApprovalActionPayload) {
...quotation,
approvalSteps: nextSteps,
status: hasPending ? 'pending_approval' : 'approved',
approvedPdfUrl: hasPending ? quotation.approvedPdfUrl : `/mock/${quotation.code.toLowerCase()}.pdf`
approvedPdfUrl: hasPending
? quotation.approvedPdfUrl
: `/mock/${quotation.code.toLowerCase()}.pdf`
};
});
@@ -398,12 +517,12 @@ export async function rejectQuotation(payload: ApprovalActionPayload) {
quotation.id === payload.quotationId
? {
...quotation,
status: 'rejected',
status: 'rejected' as const,
approvalSteps: quotation.approvalSteps.map((step, index) =>
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
? {
...step,
status: 'rejected',
status: 'rejected' as const,
actedAt: '2026-06-11T11:30:00.000Z',
comment: payload.comment
}

View File

@@ -44,7 +44,11 @@ function DecisionDialog({
<DialogTitle>{label}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input value={comment} onChange={(event) => setComment(event.target.value)} placeholder='Comment' />
<Input
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder='Comment'
/>
<DialogFooter>
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
Confirm

View File

@@ -1,7 +1,18 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
XAxis,
YAxis
} from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { crmDashboardQueryOptions } from '../api/queries';
@@ -58,11 +69,25 @@ export function CrmDashboardPage() {
<CardContent>
<ChartContainer config={chartConfig}>
<PieChart>
<Pie data={data.quotationPipeline} dataKey='value' nameKey='label' innerRadius={56} outerRadius={92}>
<Pie
data={data.quotationPipeline}
dataKey='value'
nameKey='label'
innerRadius={56}
outerRadius={92}
>
{data.quotationPipeline.map((entry, index) => (
<Cell
key={entry.label}
fill={['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'][index % 5]}
fill={
[
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)'
][index % 5]
}
/>
))}
</Pie>
@@ -127,8 +152,20 @@ export function CrmDashboardPage() {
<XAxis dataKey='label' tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type='monotone' dataKey='value' stroke='var(--chart-1)' fill='var(--chart-1)' fillOpacity={0.2} />
<Area type='monotone' dataKey='secondaryValue' stroke='var(--chart-3)' fill='var(--chart-3)' fillOpacity={0.12} />
<Area
type='monotone'
dataKey='value'
stroke='var(--chart-1)'
fill='var(--chart-1)'
fillOpacity={0.2}
/>
<Area
type='monotone'
dataKey='secondaryValue'
stroke='var(--chart-3)'
fill='var(--chart-3)'
fillOpacity={0.12}
/>
</AreaChart>
</ChartContainer>
</CardContent>
@@ -136,7 +173,11 @@ export function CrmDashboardPage() {
</div>
<div className='grid gap-4 xl:grid-cols-12'>
<SectionCard title='Pending Approvals' description='รายการที่ต้องตัดสินใจวันนี้' className='xl:col-span-4'>
<SectionCard
title='Pending Approvals'
description='รายการที่ต้องตัดสินใจวันนี้'
className='xl:col-span-4'
>
<div className='space-y-3'>
{data.pendingApprovals.map((item) => (
<div key={item.id} className='rounded-lg border p-3'>
@@ -144,7 +185,9 @@ export function CrmDashboardPage() {
<p className='font-medium'>{item.quotationCode}</p>
<QuotationStatusBadge status='pending_approval' />
</div>
<p className='text-muted-foreground mt-1 text-sm'>{item.productType} / Approver Level {item.approverLevel}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{item.productType} / Approver Level {item.approverLevel}
</p>
<p className='mt-2 text-sm font-medium'>{formatCurrency(item.amount)}</p>
</div>
))}
@@ -155,7 +198,11 @@ export function CrmDashboardPage() {
<RelatedQuotationList quotations={data.hotQuotations} />
</SectionCard>
<SectionCard title='Follow-ups Due Today' description='กิจกรรมติดตามที่ครบกำหนด' className='xl:col-span-3'>
<SectionCard
title='Follow-ups Due Today'
description='กิจกรรมติดตามที่ครบกำหนด'
className='xl:col-span-3'
>
<div className='space-y-3'>
{data.dueFollowUps.map((item) => (
<div key={item.id} className='rounded-lg border p-3'>

View File

@@ -133,10 +133,7 @@ export function CustomerDetailPage({ id }: { id: string }) {
</div>
<div className='mt-4 space-y-3'>
{data.shares.length === 0 ? (
<EmptyState
title='ยังไม่มีการแชร์ contact'
description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน'
/>
<EmptyState title='ยังไม่มีการแชร์ contact' description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน' />
) : (
data.shares.map((share) => (
<div

View File

@@ -173,24 +173,14 @@ export function CustomerFormSheet({
/>
</div>
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
<FormTextField
name='district'
label='District'
required
placeholder='Chatuchak'
/>
<FormTextField name='district' label='District' required placeholder='Chatuchak' />
<FormTextField
name='subDistrict'
label='Sub District'
required
placeholder='Chom Phon'
/>
<FormTextField
name='postalCode'
label='Postal Code'
required
placeholder='10900'
/>
<FormTextField name='postalCode' label='Postal Code' required placeholder='10900' />
</form.Form>
</form.AppForm>
</div>

View File

@@ -0,0 +1,186 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useSuspenseQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { useDataTable } from '@/hooks/use-data-table';
import { DataTable } from '@/components/ui/table/data-table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icons } from '@/components/icons';
import { customersQueryOptions, crmReferenceQueryOptions } from '../api/queries';
import type { Customer } from '../api/types';
import { CustomerFormSheet } from './customer-form-sheet';
import { BranchBadge, CustomerStatusBadge } from './shared';
export function CustomersTablePage() {
const [editingCustomer, setEditingCustomer] = useState<Customer | undefined>();
const [sheetOpen, setSheetOpen] = useState(false);
const columns = useMemo<ColumnDef<Customer>[]>(
() => [
{
accessorKey: 'code',
header: 'Code',
cell: ({ row }) => <span className='font-medium'>{row.original.code}</span>
},
{
accessorKey: 'name',
header: 'Customer',
cell: ({ row }) => (
<div>
<p className='font-medium'>{row.original.name}</p>
<p className='text-muted-foreground text-sm'>{row.original.email}</p>
</div>
)
},
{
accessorKey: 'customerStatus',
header: 'Status',
cell: ({ row }) => <CustomerStatusBadge status={row.original.customerStatus} />
},
{
accessorKey: 'branchId',
header: 'Branch',
cell: ({ row, table }) => {
const branches = (
table.options.meta as {
branches: Array<{
id: string;
code: string;
name: string;
region: string;
}>;
}
).branches;
const branch = branches.find((item) => item.id === row.original.branchId);
return branch ? <BranchBadge branch={branch} /> : null;
}
},
{
accessorKey: 'contactIds',
header: 'Contacts',
cell: ({ row }) => row.original.contactIds.length
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setEditingCustomer(row.original);
setSheetOpen(true);
}}
>
Edit
</Button>
<Button variant='outline' size='sm' asChild>
<Link href={`/dashboard/crm-demo/customers/${row.original.id}`}>View</Link>
</Button>
</div>
)
}
],
[]
);
const columnIds = ['code', 'name', 'customerStatus', 'branchId', 'contactIds', 'actions'];
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
customerStatus: parseAsString,
branch: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.customerStatus && { status: params.customerStatus }),
...(params.branch && { branch: params.branch }),
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {})
};
const { data } = useSuspenseQuery(customersQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
meta: { branches: reference.branches },
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='grid gap-3 md:grid-cols-3 lg:w-full'>
<Input
placeholder='ค้นหา customer หรือ code'
value={params.name ?? ''}
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
/>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.customerStatus ?? ''}
onChange={(event) =>
void setParams({
customerStatus: event.target.value || null,
page: 1
})
}
>
<option value=''></option>
<option value='active'>Active</option>
<option value='prospect'>Prospect</option>
<option value='inactive'>Inactive</option>
</select>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.branch ?? ''}
onChange={(event) => void setParams({ branch: event.target.value || null, page: 1 })}
>
<option value=''></option>
{reference.branches.map((branch) => (
<option key={branch.id} value={branch.id}>
{branch.name}
</option>
))}
</select>
</div>
<Button
onClick={() => {
setEditingCustomer(undefined);
setSheetOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' />
Create Customer
</Button>
</div>
<DataTable table={table} />
<CustomerFormSheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpen(open);
if (!open) setEditingCustomer(undefined);
}}
customer={editingCustomer}
/>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import Link from 'next/link';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { useDataTable } from '@/hooks/use-data-table';
import { DataTable } from '@/components/ui/table/data-table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '../api/queries';
import { convertEnquiryMutation } from '../api/mutations';
import type { Enquiry } from '../api/types';
import { ChancePill, EnquiryStatusBadge } from './shared';
const columns: ColumnDef<Enquiry>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'title', header: 'Enquiry' },
{ accessorKey: 'productType', header: 'Product' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />
},
{
accessorKey: 'chancePercent',
header: 'Chance',
cell: ({ row }) => <ChancePill value={row.original.chancePercent} />
},
{
id: 'actions',
header: '',
cell: ({ row }) => <RowActions enquiry={row.original} />
}
];
const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions'];
function RowActions({ enquiry }: { enquiry: Enquiry }) {
const mutation = useMutation({
...convertEnquiryMutation,
onSuccess: () => toast.success('สร้าง quotation mock จาก enquiry แล้ว')
});
return (
<div className='flex justify-end gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href={`/dashboard/crm-demo/enquiries/${enquiry.id}`}>View</Link>
</Button>
<Button size='sm' isLoading={mutation.isPending} onClick={() => mutation.mutate(enquiry.id)}>
Convert
</Button>
</div>
);
}
export function EnquiriesTablePage() {
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
productType: parseAsString,
salesman: parseAsString,
dateFrom: parseAsString,
dateTo: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.productType && { productType: params.productType }),
...(params.salesman && { salesman: params.salesman }),
...(params.dateFrom && { dateFrom: params.dateFrom }),
...(params.dateTo && { dateTo: params.dateTo }),
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {})
};
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<div className='flex flex-1 flex-col space-y-4'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
<Input
placeholder='ค้นหา enquiry หรือ code'
value={params.name ?? ''}
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
/>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.status ?? ''}
onChange={(event) => void setParams({ status: event.target.value || null, page: 1 })}
>
<option value=''></option>
<option value='new'>New</option>
<option value='qualifying'>Qualifying</option>
<option value='requirement'>Requirement</option>
<option value='follow_up'>Follow Up</option>
<option value='converted'>Converted</option>
</select>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.productType ?? ''}
onChange={(event) => void setParams({ productType: event.target.value || null, page: 1 })}
>
<option value=''></option>
<option value='crane'>Crane</option>
<option value='dockdoor'>Dock Door</option>
<option value='solarcell'>Solar Cell</option>
</select>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.salesman ?? ''}
onChange={(event) => void setParams({ salesman: event.target.value || null, page: 1 })}
>
<option value=''> salesperson</option>
{reference.salespersons.map((sales) => (
<option key={sales.id} value={sales.id}>
{sales.name}
</option>
))}
</select>
<Input
type='date'
value={params.dateFrom ?? ''}
onChange={(event) => void setParams({ dateFrom: event.target.value || null, page: 1 })}
/>
<Input
type='date'
value={params.dateTo ?? ''}
onChange={(event) => void setParams({ dateTo: event.target.value || null, page: 1 })}
/>
</div>
<div className='flex justify-end'>
<Button variant='outline' asChild>
<Link href='/dashboard/crm-demo/customers'>Create Enquiry</Link>
</Button>
</div>
<DataTable table={table} />
</div>
);
}

View File

@@ -211,9 +211,7 @@ export function QuotationDetailPage({ id }: { id: string }) {
<span className='text-muted-foreground text-sm'>
Revision R{String(data.quotation.revision).padStart(2, '0')}
</span>
<span className='text-sm font-medium'>
{formatCurrency(data.quotation.totalAmount)}
</span>
<span className='text-sm font-medium'>{formatCurrency(data.quotation.totalAmount)}</span>
</div>
<div className='mt-4'>
<InfoGrid

View File

@@ -0,0 +1,225 @@
'use client';
import Link from 'next/link';
import { useSuspenseQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { useDataTable } from '@/hooks/use-data-table';
import { DataTable } from '@/components/ui/table/data-table';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { HotProjectPill, QuotationStatusBadge } from './shared';
import { crmReferenceQueryOptions, quotationsQueryOptions } from '../api/queries';
import type { Quotation } from '../api/types';
import { formatCurrency } from '../utils/format';
const columns: ColumnDef<Quotation>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'project', header: 'Project' },
{ accessorKey: 'quotationType', header: 'Type' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <QuotationStatusBadge status={row.original.status} />
},
{ accessorKey: 'revision', header: 'Rev' },
{
accessorKey: 'totalAmount',
header: 'Amount',
cell: ({ row }) => formatCurrency(row.original.totalAmount)
},
{
accessorKey: 'isHotProject',
header: 'Hot',
cell: ({ row }) => <HotProjectPill active={row.original.isHotProject} />
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href={`/dashboard/crm-demo/quotations/${row.original.id}`}>View</Link>
</Button>
</div>
)
}
];
const columnIds = [
'code',
'project',
'quotationType',
'status',
'revision',
'totalAmount',
'isHotProject',
'actions'
];
function KanbanView({ items }: { items: Quotation[] }) {
const statuses = ['draft', 'pending_approval', 'approved', 'sent', 'accepted', 'lost'];
return (
<div className='grid gap-4 xl:grid-cols-6'>
{statuses.map((status) => (
<Card key={status} className='space-y-3 p-3'>
<div className='flex items-center justify-between'>
<p className='text-sm font-semibold capitalize'>{status.replace('_', ' ')}</p>
<span className='text-muted-foreground text-xs'>
{items.filter((item) => item.status === status).length}
</span>
</div>
{items
.filter((item) => item.status === status)
.map((item) => (
<Link
key={item.id}
href={`/dashboard/crm-demo/quotations/${item.id}`}
className='block rounded-lg border p-3 text-sm transition-colors hover:bg-muted/40'
>
<p className='font-medium'>{item.code}</p>
<p className='text-muted-foreground mt-1 line-clamp-2'>{item.project}</p>
<p className='mt-2 font-semibold'>{formatCurrency(item.totalAmount)}</p>
</Link>
))}
</Card>
))}
</div>
);
}
export function QuotationsTablePage() {
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
quotationType: parseAsString,
salesman: parseAsString,
hot: parseAsString,
dateFrom: parseAsString,
dateTo: parseAsString,
view: parseAsString.withDefault('table'),
sort: getSortingStateParser(columnIds).withDefault([])
});
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.quotationType && { quotationType: params.quotationType }),
...(params.salesman && { salesman: params.salesman }),
...(params.hot && { hot: params.hot }),
...(params.dateFrom && { dateFrom: params.dateFrom }),
...(params.dateTo && { dateTo: params.dateTo }),
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {})
};
const { data } = useSuspenseQuery(quotationsQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-7 xl:w-full'>
<Input
placeholder='ค้นหา code หรือ project'
value={params.name ?? ''}
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
/>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.status ?? ''}
onChange={(event) => void setParams({ status: event.target.value || null, page: 1 })}
>
<option value=''></option>
<option value='draft'>Draft</option>
<option value='pending_approval'>Pending Approval</option>
<option value='approved'>Approved</option>
<option value='sent'>Sent</option>
<option value='accepted'>Accepted</option>
</select>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.quotationType ?? ''}
onChange={(event) =>
void setParams({
quotationType: event.target.value || null,
page: 1
})
}
>
<option value=''></option>
<option value='official'>Official</option>
<option value='budgetary'>Budgetary</option>
<option value='service'>Service</option>
</select>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.salesman ?? ''}
onChange={(event) => void setParams({ salesman: event.target.value || null, page: 1 })}
>
<option value=''> salesperson</option>
{reference.salespersons.map((sales) => (
<option key={sales.id} value={sales.id}>
{sales.name}
</option>
))}
</select>
<select
className='border-input bg-background rounded-md border px-3 text-sm'
value={params.hot ?? ''}
onChange={(event) => void setParams({ hot: event.target.value || null, page: 1 })}
>
<option value=''></option>
<option value='yes'>Hot only</option>
<option value='no'>Non-hot</option>
</select>
<Input
type='date'
value={params.dateFrom ?? ''}
onChange={(event) => void setParams({ dateFrom: event.target.value || null, page: 1 })}
/>
<Input
type='date'
value={params.dateTo ?? ''}
onChange={(event) => void setParams({ dateTo: event.target.value || null, page: 1 })}
/>
</div>
<div className='flex items-center gap-2'>
<Button variant='outline' asChild>
<Link href='/dashboard/crm-demo/enquiries'>Create quotation</Link>
</Button>
<Button
variant={params.view === 'table' ? 'default' : 'outline'}
onClick={() => void setParams({ view: 'table' })}
>
Table
</Button>
<Button
variant={params.view === 'kanban' ? 'default' : 'outline'}
onClick={() => void setParams({ view: 'kanban' })}
>
Kanban
</Button>
</div>
</div>
{params.view === 'kanban' ? <KanbanView items={data.items} /> : <DataTable table={table} />}
</div>
);
}

View File

@@ -8,7 +8,10 @@ export function MasterOptionsPage() {
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
return (
<SectionCard title='Master Options' description='Status, product type, payment term, currency, tax rate'>
<SectionCard
title='Master Options'
description='Status, product type, payment term, currency, tax rate'
>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
{data.masterOptions.map((item) => (
<div key={item.id} className='rounded-lg border p-4'>
@@ -32,7 +35,8 @@ export function DocumentSequencesPage() {
<div key={item.id} className='rounded-lg border p-4'>
<div className='flex items-center justify-between gap-3'>
<p className='font-medium'>
{item.prefix}{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
{item.prefix}
{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
</p>
<p className='text-muted-foreground text-sm'>{item.documentType}</p>
</div>

View File

@@ -86,7 +86,11 @@ export function BranchBadge({ branch }: { branch: CrmBranch }) {
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
return (
<Badge variant={status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'}>
<Badge
variant={
status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'
}
>
{getCustomerStatusLabel(status)}
</Badge>
);
@@ -132,7 +136,14 @@ export function InfoGrid({
cols?: 2 | 3 | 4;
}) {
return (
<div className={cn('grid gap-4', cols === 2 && 'md:grid-cols-2', cols === 3 && 'md:grid-cols-3', cols === 4 && 'md:grid-cols-2 xl:grid-cols-4')}>
<div
className={cn(
'grid gap-4',
cols === 2 && 'md:grid-cols-2',
cols === 3 && 'md:grid-cols-3',
cols === 4 && 'md:grid-cols-2 xl:grid-cols-4'
)}
>
{items.map((item) => (
<div key={item.label} className='rounded-lg border bg-muted/20 p-3'>
<p className='text-muted-foreground text-xs uppercase tracking-[0.18em]'>{item.label}</p>
@@ -199,7 +210,10 @@ export function CustomerRoleCards({
const contact = contacts.find((item) => item.id === role.contactId);
return (
<div key={`${role.role}-${role.customerId}`} className='rounded-lg border bg-muted/20 p-4'>
<div
key={`${role.role}-${role.customerId}`}
className='rounded-lg border bg-muted/20 p-4'
>
<div className='flex items-center justify-between gap-3'>
<p className='text-sm font-semibold capitalize'>{role.role}</p>
<Badge variant='outline'>{customer?.code}</Badge>
@@ -226,7 +240,10 @@ export function QuotationPreviewPanel({
approvalSteps: ApprovalStep[];
}) {
return (
<SectionCard title='Quotation Preview' description='Mock preview พร้อมต่อยอดไป pdfme/template mapping'>
<SectionCard
title='Quotation Preview'
description='Mock preview พร้อมต่อยอดไป pdfme/template mapping'
>
<div className='rounded-xl border bg-background p-5 shadow-sm'>
<div className='flex flex-wrap items-start justify-between gap-4 border-b pb-4'>
<div>
@@ -235,9 +252,16 @@ export function QuotationPreviewPanel({
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
</div>
<div className='space-y-1 text-sm'>
<p><span className='text-muted-foreground'>quotation_date:</span> {quotation.quotationDate}</p>
<p><span className='text-muted-foreground'>valid_until:</span> {quotation.validUntil}</p>
<p><span className='text-muted-foreground'>currency:</span> THB</p>
<p>
<span className='text-muted-foreground'>quotation_date:</span>{' '}
{quotation.quotationDate}
</p>
<p>
<span className='text-muted-foreground'>valid_until:</span> {quotation.validUntil}
</p>
<p>
<span className='text-muted-foreground'>currency:</span> THB
</p>
</div>
</div>
@@ -262,7 +286,10 @@ export function QuotationPreviewPanel({
<p className='text-sm font-medium'>item_topic</p>
<div className='mt-2 space-y-2'>
{quotation.items.map((item) => (
<div key={item.id} className='flex items-center justify-between gap-4 rounded-lg border p-3 text-sm'>
<div
key={item.id}
className='flex items-center justify-between gap-4 rounded-lg border p-3 text-sm'
>
<div>
<p className='font-medium'>{item.description}</p>
<p className='text-muted-foreground'>
@@ -279,7 +306,9 @@ export function QuotationPreviewPanel({
<ul className='text-muted-foreground mt-1 space-y-1'>
{quotation.topics
.find((topic) => topic.type === 'exclusion')
?.items.map((item) => <li key={item}>- {item}</li>)}
?.items.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
</div>
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 text-sm'>
@@ -306,7 +335,10 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }
return (
<div className='space-y-3'>
{quotations.map((quotation) => (
<div key={quotation.id} className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'>
<div
key={quotation.id}
className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'
>
<div>
<div className='flex items-center gap-2'>
<p className='font-medium'>{quotation.code}</p>
@@ -317,7 +349,7 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }
<div className='flex items-center gap-3'>
<p className='text-sm font-medium'>{formatCurrency(quotation.totalAmount)}</p>
<Button variant='outline' asChild size='sm'>
<Link href={`/dashboard/crm/quotations/${quotation.id}`}>
<Link href={`/dashboard/crm-demo/quotations/${quotation.id}`}>
<Icons.arrowRight className='mr-1 h-4 w-4' />
</Link>
@@ -347,11 +379,7 @@ export function AuditTimeline({ events }: { events: AuditEvent[] }) {
actorName: event.actorName,
timestamp: event.createdAt,
tone:
event.action === 'APPROVE'
? 'success'
: event.action === 'REJECT'
? 'danger'
: 'default'
event.action === 'APPROVE' ? 'success' : event.action === 'REJECT' ? 'danger' : 'default'
}))}
/>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,207 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useSuspenseQuery } from "@tanstack/react-query";
import { type ColumnDef } from "@tanstack/react-table";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { getSortingStateParser } from "@/lib/parsers";
import { useDataTable } from "@/hooks/use-data-table";
import { DataTable } from "@/components/ui/table/data-table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons";
import {
customersQueryOptions,
crmReferenceQueryOptions,
} from "../api/queries";
import type { Customer } from "../api/types";
import { CustomerFormSheet } from "./customer-form-sheet";
import { BranchBadge, CustomerStatusBadge } from "./shared";
export function CustomersTablePage() {
const [editingCustomer, setEditingCustomer] = useState<
Customer | undefined
>();
const [sheetOpen, setSheetOpen] = useState(false);
const columns = useMemo<ColumnDef<Customer>[]>(
() => [
{
accessorKey: "code",
header: "Code",
cell: ({ row }) => (
<span className="font-medium">{row.original.code}</span>
),
},
{
accessorKey: "name",
header: "Customer",
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-muted-foreground text-sm">
{row.original.email}
</p>
</div>
),
},
{
accessorKey: "customerStatus",
header: "Status",
cell: ({ row }) => (
<CustomerStatusBadge status={row.original.customerStatus} />
),
},
{
accessorKey: "branchId",
header: "Branch",
cell: ({ row, table }) => {
const branches = (
table.options.meta as {
branches: Array<{
id: string;
code: string;
name: string;
region: string;
}>;
}
).branches;
const branch = branches.find(
(item) => item.id === row.original.branchId,
);
return branch ? <BranchBadge branch={branch} /> : null;
},
},
{
accessorKey: "contactIds",
header: "Contacts",
cell: ({ row }) => row.original.contactIds.length,
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomer(row.original);
setSheetOpen(true);
}}
>
Edit
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/crm/customers/${row.original.id}`}>
View
</Link>
</Button>
</div>
),
},
],
[],
);
const columnIds = columns
.map((column) => column.id ?? String(column.accessorKey))
.filter(Boolean);
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
customerStatus: parseAsString,
branch: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([]),
});
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.customerStatus && { status: params.customerStatus }),
...(params.branch && { branch: params.branch }),
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
};
const { data } = useSuspenseQuery(customersQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
meta: { branches: reference.branches },
initialState: {
columnPinning: { right: ["actions"] },
},
});
return (
<div className="flex flex-1 flex-col space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="grid gap-3 md:grid-cols-3 lg:w-full">
<Input
placeholder="ค้นหา customer หรือ code"
value={params.name ?? ""}
onChange={(event) =>
void setParams({ name: event.target.value || null, page: 1 })
}
/>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.customerStatus ?? ""}
onChange={(event) =>
void setParams({
customerStatus: event.target.value || null,
page: 1,
})
}
>
<option value=""></option>
<option value="active">Active</option>
<option value="prospect">Prospect</option>
<option value="inactive">Inactive</option>
</select>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.branch ?? ""}
onChange={(event) =>
void setParams({ branch: event.target.value || null, page: 1 })
}
>
<option value=""></option>
{reference.branches.map((branch) => (
<option key={branch.id} value={branch.id}>
{branch.name}
</option>
))}
</select>
</div>
<Button
onClick={() => {
setEditingCustomer(undefined);
setSheetOpen(true);
}}
>
<Icons.add className="mr-2 h-4 w-4" />
Create Customer
</Button>
</div>
<DataTable table={table} />
<CustomerFormSheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpen(open);
if (!open) setEditingCustomer(undefined);
}}
customer={editingCustomer}
/>
</div>
);
}

View File

@@ -1,178 +0,0 @@
"use client";
import Link from "next/link";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { type ColumnDef } from "@tanstack/react-table";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { getSortingStateParser } from "@/lib/parsers";
import { useDataTable } from "@/hooks/use-data-table";
import { DataTable } from "@/components/ui/table/data-table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import {
crmReferenceQueryOptions,
enquiriesQueryOptions,
} from "../api/queries";
import { convertEnquiryMutation } from "../api/mutations";
import type { Enquiry } from "../api/types";
import { ChancePill, EnquiryStatusBadge } from "./shared";
const columns: ColumnDef<Enquiry>[] = [
{ accessorKey: "code", header: "Code" },
{ accessorKey: "title", header: "Enquiry" },
{ accessorKey: "productType", header: "Product" },
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />,
},
{
accessorKey: "chancePercent",
header: "Chance",
cell: ({ row }) => <ChancePill value={row.original.chancePercent} />,
},
{
id: "actions",
header: "",
cell: ({ row }) => <RowActions enquiry={row.original} />,
},
];
const columnIds = columns
.map((column) => column.id ?? String(column.accessorKey))
.filter(Boolean);
function RowActions({ enquiry }: { enquiry: Enquiry }) {
const mutation = useMutation({
...convertEnquiryMutation,
onSuccess: () => toast.success("สร้าง quotation mock จาก enquiry แล้ว"),
});
return (
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/crm/enquiries/${enquiry.id}`}>View</Link>
</Button>
<Button
size="sm"
isLoading={mutation.isPending}
onClick={() => mutation.mutate(enquiry.id)}
>
Convert
</Button>
</div>
);
}
export function EnquiriesTablePage() {
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
productType: parseAsString,
salesman: parseAsString,
dateFrom: parseAsString,
dateTo: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([]),
});
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.productType && { productType: params.productType }),
...(params.salesman && { salesman: params.salesman }),
...(params.dateFrom && { dateFrom: params.dateFrom }),
...(params.dateTo && { dateTo: params.dateTo }),
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
};
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
initialState: {
columnPinning: { right: ["actions"] },
},
});
return (
<div className="flex flex-1 flex-col space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<Input
placeholder="ค้นหา enquiry หรือ code"
value={params.name ?? ""}
onChange={(event) =>
void setParams({ name: event.target.value || null, page: 1 })
}
/>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.status ?? ""}
onChange={(event) =>
void setParams({ status: event.target.value || null, page: 1 })
}
>
<option value=""></option>
<option value="new">New</option>
<option value="qualifying">Qualifying</option>
<option value="requirement">Requirement</option>
<option value="follow_up">Follow Up</option>
<option value="converted">Converted</option>
</select>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.productType ?? ""}
onChange={(event) =>
void setParams({ productType: event.target.value || null, page: 1 })
}
>
<option value=""></option>
<option value="crane">Crane</option>
<option value="dockdoor">Dock Door</option>
<option value="solarcell">Solar Cell</option>
</select>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.salesman ?? ""}
onChange={(event) =>
void setParams({ salesman: event.target.value || null, page: 1 })
}
>
<option value=""> salesperson</option>
{reference.salespersons.map((sales) => (
<option key={sales.id} value={sales.id}>
{sales.name}
</option>
))}
</select>
<Input
type="date"
value={params.dateFrom ?? ""}
onChange={(event) =>
void setParams({ dateFrom: event.target.value || null, page: 1 })
}
/>
<Input
type="date"
value={params.dateTo ?? ""}
onChange={(event) =>
void setParams({ dateTo: event.target.value || null, page: 1 })
}
/>
</div>
<div className="flex justify-end">
<Button variant="outline" asChild>
<Link href="/dashboard/crm/customers">Create Enquiry</Link>
</Button>
</div>
<DataTable table={table} />
</div>
);
}

View File

@@ -1,252 +0,0 @@
"use client";
import Link from "next/link";
import { useSuspenseQuery } from "@tanstack/react-query";
import { type ColumnDef } from "@tanstack/react-table";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { getSortingStateParser } from "@/lib/parsers";
import { useDataTable } from "@/hooks/use-data-table";
import { DataTable } from "@/components/ui/table/data-table";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { HotProjectPill, QuotationStatusBadge } from "./shared";
import {
crmReferenceQueryOptions,
quotationsQueryOptions,
} from "../api/queries";
import type { Quotation } from "../api/types";
import { formatCurrency } from "../utils/format";
const columns: ColumnDef<Quotation>[] = [
{ accessorKey: "code", header: "Code" },
{ accessorKey: "project", header: "Project" },
{ accessorKey: "quotationType", header: "Type" },
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <QuotationStatusBadge status={row.original.status} />,
},
{ accessorKey: "revision", header: "Rev" },
{
accessorKey: "totalAmount",
header: "Amount",
cell: ({ row }) => formatCurrency(row.original.totalAmount),
},
{
accessorKey: "isHotProject",
header: "Hot",
cell: ({ row }) => <HotProjectPill active={row.original.isHotProject} />,
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/crm/quotations/${row.original.id}`}>
View
</Link>
</Button>
</div>
),
},
];
const columnIds = columns
.map((column) => column.id ?? String(column.accessorKey))
.filter(Boolean);
function KanbanView({ items }: { items: Quotation[] }) {
const statuses = [
"draft",
"pending_approval",
"approved",
"sent",
"accepted",
"lost",
];
return (
<div className="grid gap-4 xl:grid-cols-6">
{statuses.map((status) => (
<Card key={status} className="space-y-3 p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold capitalize">
{status.replace("_", " ")}
</p>
<span className="text-muted-foreground text-xs">
{items.filter((item) => item.status === status).length}
</span>
</div>
{items
.filter((item) => item.status === status)
.map((item) => (
<Link
key={item.id}
href={`/dashboard/crm/quotations/${item.id}`}
className="block rounded-lg border p-3 text-sm transition-colors hover:bg-muted/40"
>
<p className="font-medium">{item.code}</p>
<p className="text-muted-foreground mt-1 line-clamp-2">
{item.project}
</p>
<p className="mt-2 font-semibold">
{formatCurrency(item.totalAmount)}
</p>
</Link>
))}
</Card>
))}
</div>
);
}
export function QuotationsTablePage() {
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
quotationType: parseAsString,
salesman: parseAsString,
hot: parseAsString,
dateFrom: parseAsString,
dateTo: parseAsString,
view: parseAsString.withDefault("table"),
sort: getSortingStateParser(columnIds).withDefault([]),
});
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.quotationType && { quotationType: params.quotationType }),
...(params.salesman && { salesman: params.salesman }),
...(params.hot && { hot: params.hot }),
...(params.dateFrom && { dateFrom: params.dateFrom }),
...(params.dateTo && { dateTo: params.dateTo }),
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
};
const { data } = useSuspenseQuery(quotationsQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
initialState: {
columnPinning: { right: ["actions"] },
},
});
return (
<div className="flex flex-1 flex-col space-y-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7 xl:w-full">
<Input
placeholder="ค้นหา code หรือ project"
value={params.name ?? ""}
onChange={(event) =>
void setParams({ name: event.target.value || null, page: 1 })
}
/>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.status ?? ""}
onChange={(event) =>
void setParams({ status: event.target.value || null, page: 1 })
}
>
<option value=""></option>
<option value="draft">Draft</option>
<option value="pending_approval">Pending Approval</option>
<option value="approved">Approved</option>
<option value="sent">Sent</option>
<option value="accepted">Accepted</option>
</select>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.quotationType ?? ""}
onChange={(event) =>
void setParams({
quotationType: event.target.value || null,
page: 1,
})
}
>
<option value=""></option>
<option value="official">Official</option>
<option value="budgetary">Budgetary</option>
<option value="service">Service</option>
</select>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.salesman ?? ""}
onChange={(event) =>
void setParams({ salesman: event.target.value || null, page: 1 })
}
>
<option value=""> salesperson</option>
{reference.salespersons.map((sales) => (
<option key={sales.id} value={sales.id}>
{sales.name}
</option>
))}
</select>
<select
className="border-input bg-background rounded-md border px-3 text-sm"
value={params.hot ?? ""}
onChange={(event) =>
void setParams({ hot: event.target.value || null, page: 1 })
}
>
<option value=""></option>
<option value="yes">Hot only</option>
<option value="no">Non-hot</option>
</select>
<Input
type="date"
value={params.dateFrom ?? ""}
onChange={(event) =>
void setParams({ dateFrom: event.target.value || null, page: 1 })
}
/>
<Input
type="date"
value={params.dateTo ?? ""}
onChange={(event) =>
void setParams({ dateTo: event.target.value || null, page: 1 })
}
/>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<Link href="/dashboard/crm/enquiries">Create quotation</Link>
</Button>
<Button
variant={params.view === "table" ? "default" : "outline"}
onClick={() => void setParams({ view: "table" })}
>
Table
</Button>
<Button
variant={params.view === "kanban" ? "default" : "outline"}
onClick={() => void setParams({ view: "kanban" })}
>
Kanban
</Button>
</div>
</div>
{params.view === "kanban" ? (
<KanbanView items={data.items} />
) : (
<DataTable table={table} />
)}
</div>
);
}

View File

@@ -1,731 +0,0 @@
import type {
ApprovalItem,
AuditEvent,
ContactShare,
ContactShareLog,
CrmBranch,
CrmSalesperson,
Customer,
CustomerContact,
DocumentSequence,
Enquiry,
MasterOption,
Quotation,
QuotationTemplate
} from '../api/types';
export interface CrmMockState {
branches: CrmBranch[];
salespersons: CrmSalesperson[];
customers: Customer[];
contacts: CustomerContact[];
contactShares: ContactShare[];
contactShareLogs: ContactShareLog[];
enquiries: Enquiry[];
quotations: Quotation[];
approvals: ApprovalItem[];
auditEvents: AuditEvent[];
sequences: DocumentSequence[];
masterOptions: MasterOption[];
templates: QuotationTemplate[];
}
export const initialCrmState: CrmMockState = {
branches: [
{ id: 'bkk', code: 'BKK', name: 'Bangkok HQ', region: 'Central' },
{ id: 'chn', code: 'CHN', name: 'Chiang Mai Branch', region: 'North' }
],
salespersons: [
{ id: 'sp-1', name: 'Krit S.', nickname: 'Krit', branchId: 'bkk', role: 'sales' },
{ id: 'sp-2', name: 'Nicha P.', nickname: 'Nicha', branchId: 'bkk', role: 'sale_admin' },
{ id: 'sp-3', name: 'Ton A.', nickname: 'Ton', branchId: 'chn', role: 'sales' }
],
customers: [
{
id: 'cus-1',
code: 'CUS-BKK-001',
name: 'Siam Metro Development',
taxId: '0105556100011',
address: '88 Rama 9 Road',
province: 'Bangkok',
district: 'Huai Khwang',
subDistrict: 'Bang Kapi',
postalCode: '10310',
phone: '02-555-1001',
fax: '02-555-1999',
email: 'procurement@siammetro.co.th',
customerType: 'developer',
customerStatus: 'active',
branchId: 'bkk',
contactIds: ['ct-1', 'ct-2']
},
{
id: 'cus-2',
code: 'CUS-BKK-002',
name: 'Prime Lift Engineering',
taxId: '0105556100012',
address: '19 Vibhavadi Rangsit',
province: 'Bangkok',
district: 'Chatuchak',
subDistrict: 'Chom Phon',
postalCode: '10900',
phone: '02-555-2002',
email: 'sales@primelift.co.th',
customerType: 'contractor',
customerStatus: 'active',
branchId: 'bkk',
contactIds: ['ct-3', 'ct-4']
},
{
id: 'cus-3',
code: 'CUS-CHN-003',
name: 'Northern Cold Chain',
taxId: '0505556100013',
address: '125 Super Highway',
province: 'Chiang Mai',
district: 'Mueang',
subDistrict: 'Wat Ket',
postalCode: '50000',
phone: '053-555-3003',
email: 'project@ncc.co.th',
customerType: 'owner',
customerStatus: 'prospect',
branchId: 'chn',
contactIds: ['ct-5', 'ct-6']
},
{
id: 'cus-4',
code: 'CUS-BKK-004',
name: 'Urban Dock Solution',
taxId: '0105556100014',
address: '77 Bangna-Trad KM.8',
province: 'Samut Prakan',
district: 'Bang Phli',
subDistrict: 'Bang Kaeo',
postalCode: '10540',
phone: '02-555-4004',
email: 'admin@urbandock.asia',
customerType: 'consultant',
customerStatus: 'active',
branchId: 'bkk',
contactIds: ['ct-7', 'ct-8']
},
{
id: 'cus-5',
code: 'CUS-CHN-005',
name: 'Lanna Solar Estate',
taxId: '0505556100015',
address: '199 Ring Road',
province: 'Chiang Mai',
district: 'San Sai',
subDistrict: 'Nong Chom',
postalCode: '50210',
phone: '053-555-5005',
email: 'energy@lannasolar.co.th',
customerType: 'developer',
customerStatus: 'inactive',
branchId: 'chn',
contactIds: ['ct-9', 'ct-10']
}
],
contacts: [
{ id: 'ct-1', customerId: 'cus-1', name: 'Ploy Tantip', position: 'Procurement Manager', email: 'ploy@siammetro.co.th', phone: '081-111-1111', isPrimary: true },
{ id: 'ct-2', customerId: 'cus-1', name: 'Vee Chan', position: 'Project Engineer', email: 'vee@siammetro.co.th', phone: '081-111-1112' },
{ id: 'ct-3', customerId: 'cus-2', name: 'Boss K.', position: 'Managing Director', email: 'boss@primelift.co.th', phone: '082-222-2221', isPrimary: true },
{ id: 'ct-4', customerId: 'cus-2', name: 'Jane R.', position: 'Estimator', email: 'jane@primelift.co.th', phone: '082-222-2222' },
{ id: 'ct-5', customerId: 'cus-3', name: 'Aon M.', position: 'Operations Head', email: 'aon@ncc.co.th', phone: '083-333-3331', isPrimary: true },
{ id: 'ct-6', customerId: 'cus-3', name: 'Max P.', position: 'Warehouse Lead', email: 'max@ncc.co.th', phone: '083-333-3332' },
{ id: 'ct-7', customerId: 'cus-4', name: 'Mint T.', position: 'Design Consultant', email: 'mint@urbandock.asia', phone: '084-444-4441', isPrimary: true },
{ id: 'ct-8', customerId: 'cus-4', name: 'Ken D.', position: 'Project Coordinator', email: 'ken@urbandock.asia', phone: '084-444-4442' },
{ id: 'ct-9', customerId: 'cus-5', name: 'Fah N.', position: 'Energy Planning Lead', email: 'fah@lannasolar.co.th', phone: '085-555-5551', isPrimary: true },
{ id: 'ct-10', customerId: 'cus-5', name: 'Palm J.', position: 'Plant Director', email: 'palm@lannasolar.co.th', phone: '085-555-5552' }
],
contactShares: [
{ id: 'share-1', contactId: 'ct-1', sharedWithUserId: 'u-1', sharedWithUserName: 'Nicha P.', permission: 'view', createdAt: '2026-06-01T09:00:00.000Z' },
{ id: 'share-2', contactId: 'ct-7', sharedWithUserId: 'u-2', sharedWithUserName: 'Ton A.', permission: 'edit', createdAt: '2026-06-03T10:30:00.000Z' }
],
contactShareLogs: [
{ id: 'share-log-1', contactId: 'ct-1', action: 'SHARE', targetUserName: 'Nicha P.', actorName: 'Krit S.', createdAt: '2026-06-01T09:00:00.000Z' },
{ id: 'share-log-2', contactId: 'ct-7', action: 'SHARE', targetUserName: 'Ton A.', actorName: 'Krit S.', createdAt: '2026-06-03T10:30:00.000Z' },
{ id: 'share-log-3', contactId: 'ct-7', action: 'REVOKE', targetUserName: 'Ton A.', actorName: 'Nicha P.', createdAt: '2026-06-07T14:45:00.000Z' }
],
enquiries: [
{
id: 'enq-1',
code: 'ENQ2606-001',
title: 'Dock leveler replacement for Phase 3 warehouse',
customerId: 'cus-1',
contactId: 'ct-1',
branchId: 'bkk',
salesmanId: 'sp-1',
productType: 'dockdoor',
status: 'converted',
requirementSummary: 'Replace 8 dock levelers with high cycle hydraulic model.',
projectLocation: 'Bangkok Logistics Park',
chancePercent: 82,
competitor: 'Apex Dock',
expectedValue: 2750000,
dueDate: '2026-06-14',
createdAt: '2026-05-28T09:00:00.000Z',
updatedAt: '2026-06-09T15:20:00.000Z',
quotationIds: ['qt-1', 'qt-2'],
activities: [
{ id: 'enq-1-act-1', type: 'CALL', title: 'รับ requirement เบื้องต้น', detail: 'ลูกค้าต้องการเปลี่ยนภายใน Q3', actorName: 'Krit S.', createdAt: '2026-05-28T09:00:00.000Z' },
{ id: 'enq-1-act-2', type: 'VISIT', title: 'สำรวจหน้างาน', detail: 'หน้างานพร้อม shutdown 3 วัน', actorName: 'Krit S.', createdAt: '2026-05-30T13:30:00.000Z' },
{ id: 'enq-1-act-3', type: 'CONVERT', title: 'Convert to quotation', detail: 'สร้าง QT2606-001', actorName: 'Nicha P.', createdAt: '2026-06-02T08:15:00.000Z' }
]
},
{
id: 'enq-2',
code: 'ENQ2606-002',
title: 'Overhead crane upgrade for fabrication line',
customerId: 'cus-2',
contactId: 'ct-3',
branchId: 'bkk',
salesmanId: 'sp-1',
productType: 'crane',
status: 'requirement',
requirementSummary: '10 ton overhead crane with remote monitoring.',
projectLocation: 'Samut Sakhon Plant',
chancePercent: 68,
competitor: 'LiftPro',
expectedValue: 5200000,
dueDate: '2026-06-20',
createdAt: '2026-05-29T10:00:00.000Z',
updatedAt: '2026-06-10T11:00:00.000Z',
quotationIds: ['qt-3'],
activities: [
{ id: 'enq-2-act-1', type: 'MEETING', title: 'Kickoff meeting', detail: 'หารือ requirement ทางเทคนิค', actorName: 'Krit S.', createdAt: '2026-05-29T10:00:00.000Z' }
]
},
{
id: 'enq-3',
code: 'ENQ2606-003',
title: 'Solar rooftop for cold storage lot C',
customerId: 'cus-3',
contactId: 'ct-5',
branchId: 'chn',
salesmanId: 'sp-3',
productType: 'solarcell',
status: 'follow_up',
requirementSummary: '500 kWp rooftop with energy monitoring.',
projectLocation: 'Chiang Mai DC',
chancePercent: 49,
competitor: 'SunNorth',
expectedValue: 6100000,
dueDate: '2026-06-18',
createdAt: '2026-05-27T09:20:00.000Z',
updatedAt: '2026-06-08T16:10:00.000Z',
quotationIds: ['qt-4'],
activities: [
{ id: 'enq-3-act-1', type: 'NOTE', title: 'ติดตาม BOQ', detail: 'รอฝั่งลูกค้าส่งโหลดไฟฟ้า', actorName: 'Ton A.', createdAt: '2026-06-08T16:10:00.000Z' }
]
},
{
id: 'enq-4',
code: 'ENQ2606-004',
title: 'Dock shelter package for retrofit',
customerId: 'cus-4',
contactId: 'ct-7',
branchId: 'bkk',
salesmanId: 'sp-1',
productType: 'dockdoor',
status: 'new',
requirementSummary: 'Retrofit existing dock shelter 12 bays.',
projectLocation: 'Bangna Logistics Hub',
chancePercent: 35,
competitor: 'Apex Dock',
expectedValue: 1850000,
dueDate: '2026-06-21',
createdAt: '2026-06-05T14:00:00.000Z',
updatedAt: '2026-06-05T14:00:00.000Z',
quotationIds: [],
activities: [
{ id: 'enq-4-act-1', type: 'CALL', title: 'Lead intake', detail: 'รับ lead จาก consultant', actorName: 'Krit S.', createdAt: '2026-06-05T14:00:00.000Z' }
]
},
{
id: 'enq-5',
code: 'ENQ2606-005',
title: 'Crane preventive maintenance agreement',
customerId: 'cus-2',
contactId: 'ct-4',
branchId: 'bkk',
salesmanId: 'sp-1',
productType: 'crane',
status: 'qualifying',
requirementSummary: 'Annual PM contract for 6 cranes.',
projectLocation: 'Ayutthaya Plant',
chancePercent: 53,
competitor: 'LiftPro',
expectedValue: 980000,
dueDate: '2026-06-19',
createdAt: '2026-06-02T11:00:00.000Z',
updatedAt: '2026-06-06T12:00:00.000Z',
quotationIds: ['qt-5'],
activities: [
{ id: 'enq-5-act-1', type: 'UPDATE', title: 'ผ่านขั้น qualifying', detail: 'ลูกค้ามี budget แล้ว', actorName: 'Krit S.', createdAt: '2026-06-06T12:00:00.000Z' }
]
},
{
id: 'enq-6',
code: 'ENQ2606-006',
title: 'Solar canopy concept study',
customerId: 'cus-5',
contactId: 'ct-9',
branchId: 'chn',
salesmanId: 'sp-3',
productType: 'solarcell',
status: 'closed_lost',
requirementSummary: 'Concept study for parking canopy solar.',
projectLocation: 'Chiang Rai Service Center',
chancePercent: 0,
competitor: 'GreenBeam',
expectedValue: 2400000,
dueDate: '2026-06-08',
createdAt: '2026-05-20T09:00:00.000Z',
updatedAt: '2026-06-08T18:00:00.000Z',
quotationIds: ['qt-6'],
activities: [
{ id: 'enq-6-act-1', type: 'UPDATE', title: 'Lost to competitor', detail: 'ราคา competitor ต่ำกว่า', actorName: 'Ton A.', createdAt: '2026-06-08T18:00:00.000Z' }
]
},
{
id: 'enq-7',
code: 'ENQ2606-007',
title: 'High-speed door package',
customerId: 'cus-1',
contactId: 'ct-2',
branchId: 'bkk',
salesmanId: 'sp-1',
productType: 'dockdoor',
status: 'cancelled',
requirementSummary: 'High-speed doors for food-grade area.',
projectLocation: 'Pathum Thani',
chancePercent: 0,
competitor: 'FastDoor',
expectedValue: 1600000,
dueDate: '2026-06-11',
createdAt: '2026-05-25T10:10:00.000Z',
updatedAt: '2026-06-04T11:00:00.000Z',
quotationIds: [],
activities: [
{ id: 'enq-7-act-1', type: 'UPDATE', title: 'Project cancelled', detail: 'Owner เลื่อน CAPEX', actorName: 'Nicha P.', createdAt: '2026-06-04T11:00:00.000Z' }
]
},
{
id: 'enq-8',
code: 'ENQ2606-008',
title: 'Monorail crane for packaging line',
customerId: 'cus-3',
contactId: 'ct-6',
branchId: 'chn',
salesmanId: 'sp-3',
productType: 'crane',
status: 'new',
requirementSummary: '1 ton monorail crane with quick delivery.',
projectLocation: 'Lamphun Plant',
chancePercent: 28,
competitor: 'NorthHoist',
expectedValue: 740000,
dueDate: '2026-06-24',
createdAt: '2026-06-09T13:00:00.000Z',
updatedAt: '2026-06-09T13:00:00.000Z',
quotationIds: [],
activities: [
{ id: 'enq-8-act-1', type: 'CALL', title: 'รับ enquiry ใหม่', detail: 'ต้องการเสนอราคาใน 7 วัน', actorName: 'Ton A.', createdAt: '2026-06-09T13:00:00.000Z' }
]
}
],
quotations: [
{
id: 'qt-1',
code: 'QT2606-001',
enquiryId: 'enq-1',
quotationDate: '2026-06-02',
validUntil: '2026-07-02',
quotationType: 'official',
project: 'Warehouse Dock Modernization',
siteLocation: 'Bangkok Logistics Park',
customerRoles: [
{ role: 'owner', customerId: 'cus-1', contactId: 'ct-1' },
{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' },
{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }
],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'pending_approval',
revision: 0,
totalAmount: 2750000,
chancePercent: 82,
isHotProject: true,
approvalSnapshot: 'Awaiting Level 2 approval',
items: [
{ id: 'qt-1-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 },
{ id: 'qt-1-item-2', topic: 'Installation', description: 'Site install and commissioning', quantity: 1, unit: 'lot', unitPrice: 510000, amount: 510000 }
],
topics: [
{ id: 'qt-1-topic-1', type: 'scope', label: 'Scope', items: ['Supply equipment', 'Install and test', 'Operator training'] },
{ id: 'qt-1-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Civil work by customer', 'Night shift overtime'] },
{ id: 'qt-1-topic-3', type: 'payment', label: 'Payment', items: ['40% down payment', '50% upon delivery', '10% after handover'] }
],
followUps: [
{ id: 'qt-1-fu-1', title: 'Prepare approval summary', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' },
{ id: 'qt-1-fu-2', title: 'Confirm shutdown window', dueDate: '2026-06-13', ownerName: 'Krit S.', status: 'open' }
],
attachments: [
{ id: 'qt-1-att-1', fileName: 'technical-spec.pdf', fileType: 'pdf', uploadedAt: '2026-06-02T10:00:00.000Z' }
],
approvalSteps: [
{ id: 'qt-1-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-03T09:30:00.000Z' },
{ id: 'qt-1-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'pending' }
],
auditEventIds: ['audit-1', 'audit-2']
},
{
id: 'qt-2',
code: 'QT2606-001-R01',
enquiryId: 'enq-1',
quotationDate: '2026-06-07',
validUntil: '2026-07-07',
quotationType: 'official',
project: 'Warehouse Dock Modernization',
siteLocation: 'Bangkok Logistics Park',
customerRoles: [
{ role: 'owner', customerId: 'cus-1', contactId: 'ct-1' },
{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }
],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'revised',
revision: 1,
totalAmount: 2875000,
chancePercent: 78,
isHotProject: true,
approvedPdfUrl: '/mock/qt2606-001-r01.pdf',
items: [
{ id: 'qt-2-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 },
{ id: 'qt-2-item-2', topic: 'Safety', description: 'Additional dock light and safety package', quantity: 1, unit: 'lot', unitPrice: 635000, amount: 635000 }
],
topics: [
{ id: 'qt-2-topic-1', type: 'scope', label: 'Scope', items: ['Updated safety package', 'Delivery within 30 days'] },
{ id: 'qt-2-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Permit by customer'] },
{ id: 'qt-2-topic-3', type: 'payment', label: 'Payment', items: ['40/50/10 milestone'] }
],
followUps: [
{ id: 'qt-2-fu-1', title: 'Send revised file to customer', dueDate: '2026-06-12', ownerName: 'Krit S.', status: 'open' }
],
attachments: [
{ id: 'qt-2-att-1', fileName: 'rev1-comparison.pdf', fileType: 'pdf', uploadedAt: '2026-06-07T16:00:00.000Z' }
],
approvalSteps: [
{ id: 'qt-2-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-07T15:30:00.000Z' },
{ id: 'qt-2-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'approved', actedAt: '2026-06-08T09:00:00.000Z' }
],
auditEventIds: ['audit-3']
},
{
id: 'qt-3',
code: 'QT2606-002',
enquiryId: 'enq-2',
quotationDate: '2026-06-05',
validUntil: '2026-07-05',
quotationType: 'budgetary',
project: 'Fabrication Crane Upgrade',
siteLocation: 'Samut Sakhon Plant',
customerRoles: [
{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' },
{ role: 'contractor', customerId: 'cus-4', contactId: 'ct-8' }
],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'draft',
revision: 0,
totalAmount: 5200000,
chancePercent: 68,
isHotProject: false,
items: [
{ id: 'qt-3-item-1', topic: 'Crane', description: '10 ton overhead crane', quantity: 1, unit: 'set', unitPrice: 4500000, amount: 4500000 },
{ id: 'qt-3-item-2', topic: 'IoT', description: 'Remote monitoring package', quantity: 1, unit: 'set', unitPrice: 700000, amount: 700000 }
],
topics: [
{ id: 'qt-3-topic-1', type: 'scope', label: 'Scope', items: ['Supply', 'Install', 'Commission'] },
{ id: 'qt-3-topic-2', type: 'payment', label: 'Payment', items: ['50% deposit', '40% delivery', '10% handover'] },
{ id: 'qt-3-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building strengthening'] }
],
followUps: [{ id: 'qt-3-fu-1', title: 'Finalize civil scope', dueDate: '2026-06-15', ownerName: 'Krit S.', status: 'open' }],
attachments: [],
approvalSteps: [],
auditEventIds: []
},
{
id: 'qt-4',
code: 'QT2606-003',
enquiryId: 'enq-3',
quotationDate: '2026-06-03',
validUntil: '2026-07-03',
quotationType: 'official',
project: 'Cold Chain Rooftop Solar',
siteLocation: 'Chiang Mai DC',
customerRoles: [
{ role: 'owner', customerId: 'cus-3', contactId: 'ct-5' },
{ role: 'consultant', customerId: 'cus-5', contactId: 'ct-9' }
],
salesmanId: 'sp-3',
saleAdminId: 'sp-2',
branchId: 'chn',
status: 'sent',
revision: 0,
totalAmount: 6100000,
chancePercent: 49,
isHotProject: true,
approvedPdfUrl: '/mock/qt2606-003.pdf',
items: [
{ id: 'qt-4-item-1', topic: 'Solar', description: '500 kWp rooftop system', quantity: 1, unit: 'lot', unitPrice: 5900000, amount: 5900000 },
{ id: 'qt-4-item-2', topic: 'Monitoring', description: 'EMS dashboard', quantity: 1, unit: 'lot', unitPrice: 200000, amount: 200000 }
],
topics: [
{ id: 'qt-4-topic-1', type: 'scope', label: 'Scope', items: ['EPC turnkey'] },
{ id: 'qt-4-topic-2', type: 'payment', label: 'Payment', items: ['30/60/10'] },
{ id: 'qt-4-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Transformer upgrade'] }
],
followUps: [{ id: 'qt-4-fu-1', title: 'Follow up board decision', dueDate: '2026-06-11', ownerName: 'Ton A.', status: 'open' }],
attachments: [{ id: 'qt-4-att-1', fileName: 'financial-model.xls', fileType: 'xls', uploadedAt: '2026-06-03T12:00:00.000Z' }],
approvalSteps: [
{ id: 'qt-4-ap-1', level: 1, approverName: 'Regional Sales Manager', approverPosition: 'Regional Manager', status: 'approved', actedAt: '2026-06-03T11:00:00.000Z' }
],
auditEventIds: ['audit-4']
},
{
id: 'qt-5',
code: 'QT2606-004',
enquiryId: 'enq-5',
quotationDate: '2026-06-06',
validUntil: '2026-07-06',
quotationType: 'service',
project: 'Annual Crane PM',
siteLocation: 'Ayutthaya Plant',
customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-4' }],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'approved',
revision: 0,
totalAmount: 980000,
chancePercent: 53,
isHotProject: false,
approvedPdfUrl: '/mock/qt2606-004.pdf',
items: [{ id: 'qt-5-item-1', topic: 'Service', description: 'PM contract 12 months', quantity: 1, unit: 'lot', unitPrice: 980000, amount: 980000 }],
topics: [
{ id: 'qt-5-topic-1', type: 'scope', label: 'Scope', items: ['Quarterly PM', 'Emergency call support'] },
{ id: 'qt-5-topic-2', type: 'payment', label: 'Payment', items: ['100% after PO'] },
{ id: 'qt-5-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Spare parts'] }
],
followUps: [{ id: 'qt-5-fu-1', title: 'Await PO', dueDate: '2026-06-17', ownerName: 'Nicha P.', status: 'open' }],
attachments: [],
approvalSteps: [{ id: 'qt-5-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-07T10:00:00.000Z' }],
auditEventIds: []
},
{
id: 'qt-6',
code: 'QT2606-005',
enquiryId: 'enq-6',
quotationDate: '2026-05-26',
validUntil: '2026-06-25',
quotationType: 'budgetary',
project: 'Solar Canopy Feasibility',
siteLocation: 'Chiang Rai Service Center',
customerRoles: [{ role: 'owner', customerId: 'cus-5', contactId: 'ct-9' }],
salesmanId: 'sp-3',
saleAdminId: 'sp-2',
branchId: 'chn',
status: 'lost',
revision: 0,
totalAmount: 2400000,
chancePercent: 0,
isHotProject: false,
items: [{ id: 'qt-6-item-1', topic: 'Solar', description: 'Canopy concept and EPC budget', quantity: 1, unit: 'lot', unitPrice: 2400000, amount: 2400000 }],
topics: [
{ id: 'qt-6-topic-1', type: 'scope', label: 'Scope', items: ['Concept study'] },
{ id: 'qt-6-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] },
{ id: 'qt-6-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Authority permit'] }
],
followUps: [],
attachments: [],
approvalSteps: [],
auditEventIds: ['audit-5']
},
{
id: 'qt-7',
code: 'QT2606-006',
enquiryId: 'enq-4',
quotationDate: '2026-06-09',
validUntil: '2026-07-09',
quotationType: 'official',
project: 'Dock Shelter Retrofit',
siteLocation: 'Bangna Logistics Hub',
customerRoles: [{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'pending_approval',
revision: 0,
totalAmount: 1850000,
chancePercent: 35,
isHotProject: false,
items: [{ id: 'qt-7-item-1', topic: 'Retrofit', description: 'Dock shelter 12 bays', quantity: 12, unit: 'bay', unitPrice: 154166.67, amount: 1850000 }],
topics: [
{ id: 'qt-7-topic-1', type: 'scope', label: 'Scope', items: ['Supply and install 12 shelters'] },
{ id: 'qt-7-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] },
{ id: 'qt-7-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Scaffold by others'] }
],
followUps: [{ id: 'qt-7-fu-1', title: 'Submit approval', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' }],
attachments: [],
approvalSteps: [{ id: 'qt-7-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }],
auditEventIds: []
},
{
id: 'qt-8',
code: 'QT2606-007',
enquiryId: 'enq-8',
quotationDate: '2026-06-10',
validUntil: '2026-07-10',
quotationType: 'official',
project: 'Monorail Crane Fast Track',
siteLocation: 'Lamphun Plant',
customerRoles: [{ role: 'owner', customerId: 'cus-3', contactId: 'ct-6' }],
salesmanId: 'sp-3',
saleAdminId: 'sp-2',
branchId: 'chn',
status: 'draft',
revision: 0,
totalAmount: 740000,
chancePercent: 28,
isHotProject: false,
items: [{ id: 'qt-8-item-1', topic: 'Crane', description: '1 ton monorail crane', quantity: 1, unit: 'set', unitPrice: 740000, amount: 740000 }],
topics: [
{ id: 'qt-8-topic-1', type: 'scope', label: 'Scope', items: ['Supply only'] },
{ id: 'qt-8-topic-2', type: 'payment', label: 'Payment', items: ['100% before delivery'] },
{ id: 'qt-8-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Install by customer'] }
],
followUps: [],
attachments: [],
approvalSteps: [],
auditEventIds: []
},
{
id: 'qt-9',
code: 'QT2606-008',
enquiryId: 'enq-2',
quotationDate: '2026-06-08',
validUntil: '2026-07-08',
quotationType: 'official',
project: 'Crane Upgrade Option B',
siteLocation: 'Samut Sakhon Plant',
customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' }],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'rejected',
revision: 0,
totalAmount: 5450000,
chancePercent: 22,
isHotProject: false,
items: [{ id: 'qt-9-item-1', topic: 'Crane', description: 'Higher spec option', quantity: 1, unit: 'set', unitPrice: 5450000, amount: 5450000 }],
topics: [
{ id: 'qt-9-topic-1', type: 'scope', label: 'Scope', items: ['Higher duty cycle spec'] },
{ id: 'qt-9-topic-2', type: 'payment', label: 'Payment', items: ['40/50/10'] },
{ id: 'qt-9-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building modification'] }
],
followUps: [],
attachments: [],
approvalSteps: [{ id: 'qt-9-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'rejected', actedAt: '2026-06-09T13:00:00.000Z', comment: 'Margin below target' }],
auditEventIds: []
},
{
id: 'qt-10',
code: 'QT2606-009',
enquiryId: 'enq-5',
quotationDate: '2026-06-10',
validUntil: '2026-07-10',
quotationType: 'service',
project: 'Spare Parts Bundle',
siteLocation: 'Ayutthaya Plant',
customerRoles: [{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }],
salesmanId: 'sp-1',
saleAdminId: 'sp-2',
branchId: 'bkk',
status: 'accepted',
revision: 0,
totalAmount: 420000,
chancePercent: 100,
isHotProject: false,
approvedPdfUrl: '/mock/qt2606-009.pdf',
items: [{ id: 'qt-10-item-1', topic: 'Spare Parts', description: 'Critical spare bundle', quantity: 1, unit: 'lot', unitPrice: 420000, amount: 420000 }],
topics: [
{ id: 'qt-10-topic-1', type: 'scope', label: 'Scope', items: ['Spare bundle supply'] },
{ id: 'qt-10-topic-2', type: 'payment', label: 'Payment', items: ['100% with PO'] },
{ id: 'qt-10-topic-3', type: 'exclusion', label: 'Exclusion', items: ['On-site labor'] }
],
followUps: [{ id: 'qt-10-fu-1', title: 'Coordinate delivery slot', dueDate: '2026-06-16', ownerName: 'Nicha P.', status: 'done' }],
attachments: [{ id: 'qt-10-att-1', fileName: 'spare-list.doc', fileType: 'doc', uploadedAt: '2026-06-10T09:00:00.000Z' }],
approvalSteps: [{ id: 'qt-10-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-10T08:00:00.000Z' }],
auditEventIds: []
}
],
approvals: [
{ id: 'approval-1', quotationId: 'qt-1', quotationCode: 'QT2606-001', amount: 2750000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 2, status: 'pending' },
{ id: 'approval-2', quotationId: 'qt-7', quotationCode: 'QT2606-006', amount: 1850000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 1, status: 'pending' },
{ id: 'approval-3', quotationId: 'qt-3', quotationCode: 'QT2606-002', amount: 5200000, productType: 'crane', branchId: 'bkk', submitterName: 'Krit S.', approverLevel: 1, status: 'pending' }
],
auditEvents: [
{ id: 'audit-1', entityType: 'quotation', entityId: 'qt-1', action: 'CREATE', actorName: 'Nicha P.', detail: 'สร้างใบเสนอราคา QT2606-001', createdAt: '2026-06-02T08:15:00.000Z' },
{ id: 'audit-2', entityType: 'approval', entityId: 'qt-1', action: 'APPROVE', actorName: 'Head of Sales', detail: 'อนุมัติ level 1', createdAt: '2026-06-03T09:30:00.000Z' },
{ id: 'audit-3', entityType: 'quotation', entityId: 'qt-2', action: 'REVISE', actorName: 'Krit S.', detail: 'สร้าง revision R01 เพิ่ม safety package', createdAt: '2026-06-07T16:00:00.000Z' },
{ id: 'audit-4', entityType: 'quotation', entityId: 'qt-4', action: 'SEND', actorName: 'Ton A.', detail: 'ส่งใบเสนอราคาให้ลูกค้าทางอีเมล', createdAt: '2026-06-04T10:10:00.000Z' },
{ id: 'audit-5', entityType: 'quotation', entityId: 'qt-6', action: 'REJECT', actorName: 'Customer Board', detail: 'ลูกค้าเลือกคู่แข่งเนื่องจากราคา', createdAt: '2026-06-08T18:00:00.000Z' }
],
sequences: [
{ id: 'seq-1', documentType: 'enquiry', prefix: 'ENQ', period: '2606', branchId: 'bkk', currentNumber: 8, paddingLength: 3 },
{ id: 'seq-2', documentType: 'quotation', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 9, paddingLength: 3 },
{ id: 'seq-3', documentType: 'quotation_revision', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 1, paddingLength: 2 }
],
masterOptions: [
{ id: 'mo-1', group: 'status', code: 'new', label: 'New', active: true },
{ id: 'mo-2', group: 'status', code: 'pending_approval', label: 'Pending Approval', active: true },
{ id: 'mo-3', group: 'product_type', code: 'crane', label: 'Crane', active: true },
{ id: 'mo-4', group: 'product_type', code: 'dockdoor', label: 'Dock Door', active: true },
{ id: 'mo-5', group: 'product_type', code: 'solarcell', label: 'Solar Cell', active: true },
{ id: 'mo-6', group: 'payment_term', code: '40_50_10', label: '40/50/10', active: true },
{ id: 'mo-7', group: 'currency', code: 'THB', label: 'Thai Baht', active: true },
{ id: 'mo-8', group: 'tax_rate', code: 'VAT7', label: 'VAT 7%', active: true }
],
templates: [
{
id: 'tpl-1',
name: 'Standard Commercial Proposal',
version: 'v1.2',
branchId: 'bkk',
description: 'Template for standard equipment quotation.',
mappings: [
{ id: 'tpl-1-map-1', key: 'customer_name', placeholder: '{{customer_name}}', sourceField: 'customer.name' },
{ id: 'tpl-1-map-2', key: 'project_name', placeholder: '{{project_name}}', sourceField: 'quotation.project' },
{ id: 'tpl-1-map-3', key: 'quotation_price', placeholder: '{{quotation_price}}', sourceField: 'quotation.totalAmount' }
]
},
{
id: 'tpl-2',
name: 'Solar EPC Proposal',
version: 'v0.9',
branchId: 'chn',
description: 'Template for solar EPC quotation preview.',
mappings: [
{ id: 'tpl-2-map-1', key: 'site_location', placeholder: '{{site_location}}', sourceField: 'quotation.siteLocation' },
{ id: 'tpl-2-map-2', key: 'approver_names', placeholder: '{{approver_names}}', sourceField: 'quotation.approvalSteps[].approverName' }
]
}
]
};

View File

@@ -0,0 +1,56 @@
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export function CrmProductionPlaceholder({
title,
summary,
foundationItems,
nextStep,
demoHref
}: {
title: string;
summary: string;
foundationItems: string[];
nextStep: string;
demoHref?: string;
}) {
return (
<div className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{summary}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='text-sm font-medium'>Production path status</div>
<div className='text-muted-foreground mt-1 text-sm'>
This route is isolated from legacy CRM mock services and is now reserved for
foundation-backed implementation only.
</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-sm font-medium'>Reusable foundation ready</div>
<ul className='text-muted-foreground mt-2 space-y-1 text-sm'>
{foundationItems.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
</div>
<div className='rounded-lg border border-dashed p-4'>
<div className='text-sm font-medium'>Next implementation step</div>
<div className='text-muted-foreground mt-1 text-sm'>{nextStep}</div>
</div>
{demoHref ? (
<div className='flex justify-end'>
<Button variant='outline' asChild>
<Link href={demoHref}>Open legacy demo route</Link>
</Button>
</div>
) : null}
</CardContent>
</Card>
</div>
);
}