This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { approvalsQueryOptions, crmReferenceQueryOptions } from '../api/queries';
import { approveQuotationMutation, rejectQuotationMutation } from '../api/mutations';
import { BranchBadge, SectionCard, TimelineList } from './shared';
import { formatCurrency } from '../utils/format';
function DecisionDialog({
label,
description,
isPending,
onConfirm
}: {
label: string;
description: string;
isPending: boolean;
onConfirm: (comment: string) => void;
}) {
const [comment, setComment] = useState('');
return (
<Dialog>
<DialogTrigger asChild>
<Button variant='outline' size='sm'>
{label}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{label}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input value={comment} onChange={(event) => setComment(event.target.value)} placeholder='Comment' />
<DialogFooter>
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApprovalsPage() {
const { data } = useSuspenseQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const baseApproveMutation = approveQuotationMutation;
const baseRejectMutation = rejectQuotationMutation;
const approveMutation = useMutation({
...baseApproveMutation,
onSuccess: async (...args) => {
await baseApproveMutation.onSuccess?.(...args);
toast.success('Approve mock สำเร็จ');
}
});
const rejectMutation = useMutation({
...baseRejectMutation,
onSuccess: async (...args) => {
await baseRejectMutation.onSuccess?.(...args);
toast.success('Reject mock สำเร็จ');
}
});
return (
<div className='grid gap-4 xl:grid-cols-12'>
<SectionCard
title='Pending Approval List'
description='Sequential approval MVP'
className='xl:col-span-7'
>
<div className='space-y-3'>
{data.items.map((item) => {
const branch = reference.branches.find((value) => value.id === item.branchId);
return (
<div key={item.id} className='rounded-xl border bg-muted/10 p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<div className='flex items-center gap-2'>
<p className='font-semibold'>{item.quotationCode}</p>
{branch ? <BranchBadge branch={branch} /> : null}
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{item.productType} / Submitter: {item.submitterName}
</p>
<p className='mt-2 text-sm font-medium'>
Level {item.approverLevel} / {formatCurrency(item.amount)}
</p>
</div>
<div className='flex gap-2'>
<DecisionDialog
label='Approve'
description='อนุมัติ quotation mock นี้'
isPending={approveMutation.isPending}
onConfirm={(comment) =>
approveMutation.mutate({ quotationId: item.quotationId, comment })
}
/>
<DecisionDialog
label='Reject'
description='Reject quotation mock นี้'
isPending={rejectMutation.isPending}
onConfirm={(comment) =>
rejectMutation.mutate({ quotationId: item.quotationId, comment })
}
/>
</div>
</div>
</div>
);
})}
</div>
</SectionCard>
<SectionCard
title='Approval Timeline'
description='ภาพรวมเส้นทางการอนุมัติ'
className='xl:col-span-5'
>
<TimelineList
items={data.items.map((item) => ({
id: item.id,
title: `${item.quotationCode} / Level ${item.approverLevel}`,
detail: `${item.submitterName} ส่งอนุมัติสำหรับ ${item.productType}`,
timestamp: '2026-06-11'
}))}
/>
</SectionCard>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
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';
import { KpiCard, QuotationStatusBadge, RelatedQuotationList, SectionCard } from './shared';
import { formatCurrency, formatDate } from '../utils/format';
const chartConfig = {
primary: { label: 'Primary', color: 'var(--chart-1)' },
secondary: { label: 'Secondary', color: 'var(--chart-2)' },
tertiary: { label: 'Tertiary', color: 'var(--chart-3)' }
};
export function CrmDashboardPage() {
const { data } = useSuspenseQuery(crmDashboardQueryOptions());
return (
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5'>
{data.metrics.map((metric) => (
<KpiCard
key={metric.id}
label={metric.label}
value={metric.value}
change={metric.change}
trend={metric.trend}
/>
))}
</div>
<div className='grid gap-4 xl:grid-cols-12'>
<Card className='xl:col-span-5'>
<CardHeader>
<CardTitle>Enquiry Pipeline</CardTitle>
<CardDescription> opportunity </CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart data={data.enquiryPipeline}>
<CartesianGrid vertical={false} />
<XAxis dataKey='label' tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey='value' radius={8} fill='var(--chart-1)' />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card className='xl:col-span-4'>
<CardHeader>
<CardTitle>Quotation Pipeline</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<PieChart>
<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]}
/>
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent />} />
</PieChart>
</ChartContainer>
</CardContent>
</Card>
<Card className='xl:col-span-3'>
<CardHeader>
<CardTitle>Competitor Analysis</CardTitle>
<CardDescription> pipeline</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{data.competitorAnalysis.map((item) => (
<div key={item.label} className='space-y-1'>
<div className='flex items-center justify-between text-sm'>
<span>{item.label}</span>
<span className='font-medium'>{item.value}</span>
</div>
<div className='bg-muted h-2 rounded-full'>
<div
className='bg-primary h-2 rounded-full'
style={{ width: `${(item.value / 4) * 100}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
</div>
<div className='grid gap-4 xl:grid-cols-12'>
<Card className='xl:col-span-6'>
<CardHeader>
<CardTitle>Sales Performance</CardTitle>
<CardDescription> quotation salesperson</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart data={data.salesPerformance}>
<CartesianGrid vertical={false} />
<XAxis dataKey='label' tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey='value' fill='var(--chart-2)' radius={8} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card className='xl:col-span-6'>
<CardHeader>
<CardTitle>Won / Lost Trend</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<AreaChart data={data.wonLostTrend}>
<CartesianGrid vertical={false} />
<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} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
<div className='grid gap-4 xl:grid-cols-12'>
<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'>
<div className='flex items-center justify-between gap-3'>
<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='mt-2 text-sm font-medium'>{formatCurrency(item.amount)}</p>
</div>
))}
</div>
</SectionCard>
<SectionCard title='Hot Projects' description='ดีลที่ต้องติดตามใกล้ชิด' className='xl:col-span-5'>
<RelatedQuotationList quotations={data.hotQuotations} />
</SectionCard>
<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'>
<p className='font-medium'>{item.title}</p>
<p className='text-muted-foreground mt-1 text-sm'>Owner: {item.ownerName}</p>
<p className='text-muted-foreground text-sm'>Due: {formatDate(item.dueDate)}</p>
</div>
))}
</div>
</SectionCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { customerByIdOptions } from '../api/queries';
import { revokeContactShareMutation, shareContactMutation } from '../api/mutations';
import {
AuditTimeline,
EmptyState,
InfoGrid,
RelatedQuotationList,
SectionCard,
TimelineList
} from './shared';
import { formatDate } from '../utils/format';
export function CustomerDetailPage({ id }: { id: string }) {
const { data } = useSuspenseQuery(customerByIdOptions(id));
const [targetUserName, setTargetUserName] = useState('');
const baseShareMutation = shareContactMutation;
const baseRevokeMutation = revokeContactShareMutation;
const shareMutation = useMutation({
...baseShareMutation,
onSuccess: async (...args) => {
await baseShareMutation.onSuccess?.(...args);
toast.success('แชร์ contact mock สำเร็จ');
setTargetUserName('');
}
});
const revokeMutation = useMutation({
...baseRevokeMutation,
onSuccess: async (...args) => {
await baseRevokeMutation.onSuccess?.(...args);
toast.success('ยกเลิกแชร์ contact mock สำเร็จ');
}
});
const primaryContact = data.contacts.find((contact) => contact.isPrimary) ?? data.contacts[0];
return (
<div className='space-y-4'>
<InfoGrid
cols={4}
items={[
{ label: 'Customer Code', value: data.customer.code },
{ label: 'Primary Contact', value: primaryContact?.name ?? '-' },
{ label: 'Branch', value: data.branch.name },
{
label: 'Last Activity',
value: data.auditEvents[0] ? formatDate(data.auditEvents[0].createdAt) : '-'
}
]}
/>
<Tabs defaultValue='overview' className='space-y-4'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
<TabsTrigger value='shared'>Shared Contacts</TabsTrigger>
<TabsTrigger value='enquiries'>Enquiries</TabsTrigger>
<TabsTrigger value='quotations'>Quotations</TabsTrigger>
<TabsTrigger value='activity'>Activity Log</TabsTrigger>
</TabsList>
<TabsContent value='overview'>
<SectionCard title='Customer Overview'>
<InfoGrid
cols={3}
items={[
{ label: 'Tax ID', value: data.customer.taxId },
{ label: 'Email', value: data.customer.email },
{ label: 'Phone', value: data.customer.phone },
{
label: 'Address',
value: `${data.customer.address}, ${data.customer.subDistrict}`
},
{ label: 'District', value: data.customer.district },
{ label: 'Province', value: data.customer.province }
]}
/>
</SectionCard>
</TabsContent>
<TabsContent value='contacts'>
<SectionCard title='Customer Contacts'>
<div className='grid gap-3 md:grid-cols-2'>
{data.contacts.map((contact) => (
<div key={contact.id} className='rounded-lg border p-4'>
<div className='flex items-center justify-between gap-2'>
<p className='font-medium'>{contact.name}</p>
{contact.isPrimary ? (
<span className='text-primary text-xs'>Primary</span>
) : null}
</div>
<p className='text-muted-foreground text-sm'>{contact.position}</p>
<p className='mt-2 text-sm'>{contact.email}</p>
<p className='text-sm'>{contact.phone}</p>
</div>
))}
</div>
</SectionCard>
</TabsContent>
<TabsContent value='shared'>
<SectionCard title='Share Contact' description='Mock contact sharing UI'>
<div className='flex flex-col gap-3 md:flex-row'>
<Input
placeholder='ชื่อผู้ใช้ที่ต้องการแชร์'
value={targetUserName}
onChange={(event) => setTargetUserName(event.target.value)}
/>
<Button
isLoading={shareMutation.isPending}
onClick={() =>
primaryContact &&
targetUserName &&
shareMutation.mutate({
contactId: primaryContact.id,
sharedWithUserId: `mock-${targetUserName}`,
sharedWithUserName: targetUserName,
permission: 'view'
})
}
>
Contact
</Button>
</div>
<div className='mt-4 space-y-3'>
{data.shares.length === 0 ? (
<EmptyState
title='ยังไม่มีการแชร์ contact'
description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน'
/>
) : (
data.shares.map((share) => (
<div
key={share.id}
className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'
>
<div>
<p className='font-medium'>{share.sharedWithUserName}</p>
<p className='text-muted-foreground text-sm'>
Permission: {share.permission}
</p>
</div>
<Button
variant='outline'
size='sm'
isLoading={revokeMutation.isPending}
onClick={() =>
revokeMutation.mutate({
contactId: share.contactId,
shareId: share.id
})
}
>
Revoke
</Button>
</div>
))
)}
</div>
<div className='mt-6'>
<TimelineList
items={data.shareLogs.map((log) => ({
id: log.id,
title: log.action,
detail: `${log.targetUserName} โดย ${log.actorName}`,
timestamp: log.createdAt,
tone: log.action === 'SHARE' ? 'success' : 'danger'
}))}
/>
</div>
</SectionCard>
</TabsContent>
<TabsContent value='enquiries'>
<SectionCard title='Customer Enquiries'>
<div className='space-y-3'>
{data.enquiries.map((enquiry) => (
<div key={enquiry.id} className='rounded-lg border p-3'>
<p className='font-medium'>{enquiry.code}</p>
<p className='text-muted-foreground text-sm'>{enquiry.title}</p>
</div>
))}
</div>
</SectionCard>
</TabsContent>
<TabsContent value='quotations'>
<RelatedQuotationList quotations={data.quotations} />
</TabsContent>
<TabsContent value='activity'>
<SectionCard title='Activity Log'>
<AuditTimeline events={data.auditEvents} />
</SectionCard>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import { useEffect } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { crmReferenceQueryOptions } from '../api/queries';
import { createCustomerMutation, updateCustomerMutation } from '../api/mutations';
import type { Customer } from '../api/types';
import { customerStatusOptions } from '../data/options';
import { customerSchema, type CustomerFormValues } from '../schemas/customer';
const customerTypeOptions = [
{ value: 'developer', label: 'Developer' },
{ value: 'contractor', label: 'Contractor' },
{ value: 'owner', label: 'Owner' },
{ value: 'consultant', label: 'Consultant' }
];
export function CustomerFormSheet({
open,
onOpenChange,
customer
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
customer?: Customer;
}) {
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
const { FormTextField, FormSelectField } = useFormFields<CustomerFormValues>();
const isEdit = !!customer;
const baseCreateMutation = createCustomerMutation;
const baseUpdateMutation = updateCustomerMutation;
const createMutation = useMutation({
...baseCreateMutation,
onSuccess: async (...args) => {
await baseCreateMutation.onSuccess?.(...args);
toast.success('สร้าง customer mock สำเร็จ');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'บันทึกไม่สำเร็จ')
});
const updateMutation = useMutation({
...baseUpdateMutation,
onSuccess: async (...args) => {
await baseUpdateMutation.onSuccess?.(...args);
toast.success('อัปเดต customer mock สำเร็จ');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'บันทึกไม่สำเร็จ')
});
const form = useAppForm({
defaultValues: {
code: customer?.code ?? '',
name: customer?.name ?? '',
taxId: customer?.taxId ?? '',
email: customer?.email ?? '',
phone: customer?.phone ?? '',
customerType: customer?.customerType ?? 'developer',
customerStatus: customer?.customerStatus ?? 'active',
branchId: customer?.branchId ?? reference.branches[0]?.id ?? '',
address: customer?.address ?? '',
province: customer?.province ?? '',
district: customer?.district ?? '',
subDistrict: customer?.subDistrict ?? '',
postalCode: customer?.postalCode ?? ''
} as CustomerFormValues,
validators: {
onSubmit: customerSchema
},
onSubmit: async ({ value }) => {
if (isEdit && customer) {
await updateMutation.mutateAsync({ id: customer.id, values: value });
return;
}
await createMutation.mutateAsync(value);
}
});
useEffect(() => {
if (!open) return;
form.reset({
code: customer?.code ?? '',
name: customer?.name ?? '',
taxId: customer?.taxId ?? '',
email: customer?.email ?? '',
phone: customer?.phone ?? '',
customerType: customer?.customerType ?? 'developer',
customerStatus: customer?.customerStatus ?? 'active',
branchId: customer?.branchId ?? reference.branches[0]?.id ?? '',
address: customer?.address ?? '',
province: customer?.province ?? '',
district: customer?.district ?? '',
subDistrict: customer?.subDistrict ?? '',
postalCode: customer?.postalCode ?? ''
});
}, [customer, form, open, reference.branches]);
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'แก้ไข Customer' : 'สร้าง Customer'}</SheetTitle>
<SheetDescription>
mock form pattern repo service
</SheetDescription>
</SheetHeader>
<div className='max-h-[calc(100vh-10rem)] overflow-auto pr-2'>
<form.AppForm>
<form.Form id='customer-form' className='grid gap-2 md:grid-cols-2'>
<FormTextField name='code' label='Customer Code' required placeholder='CUS-BKK-006' />
<FormTextField
name='name'
label='Customer Name'
required
placeholder='ALLA Distribution'
/>
<FormTextField name='taxId' label='Tax ID' required placeholder='0105556...' />
<FormTextField
name='email'
label='Email'
required
type='email'
placeholder='contact@example.com'
/>
<FormTextField name='phone' label='Phone' required placeholder='02-000-0000' />
<FormSelectField
name='customerType'
label='Customer Type'
required
options={customerTypeOptions}
/>
<FormSelectField
name='customerStatus'
label='Status'
required
options={customerStatusOptions.map((item) => ({
value: item.value,
label: item.label
}))}
/>
<FormSelectField
name='branchId'
label='Branch'
required
options={reference.branches.map((branch) => ({
value: branch.id,
label: branch.name
}))}
/>
<div className='md:col-span-2'>
<FormTextField
name='address'
label='Address'
required
placeholder='123 Main Road'
/>
</div>
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
<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'
/>
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
</Button>
<Button type='submit' form='customer-form' isLoading={isPending}>
{isEdit ? 'บันทึกการแก้ไข' : 'สร้าง Customer'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,207 @@
"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

@@ -0,0 +1,178 @@
"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

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { enquiryByIdOptions } from '../api/queries';
import { convertEnquiryMutation } from '../api/mutations';
import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared';
import { formatCurrency } from '../utils/format';
export function EnquiryDetailPage({ id }: { id: string }) {
const { data } = useSuspenseQuery(enquiryByIdOptions(id));
const baseMutation = convertEnquiryMutation;
const mutation = useMutation({
...baseMutation,
onSuccess: async (...args) => {
await baseMutation.onSuccess?.(...args);
toast.success('สร้าง quotation mock เรียบร้อย');
}
});
return (
<div className='space-y-4'>
<SectionCard
title={data.enquiry.title}
description={`${data.enquiry.code} / ${data.branch.name}`}
action={
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.enquiry.id)}>
Convert to Quotation
</Button>
}
>
<InfoGrid
cols={4}
items={[
{ label: 'Customer', value: data.customer.name },
{ label: 'Contact', value: data.contact.name },
{ label: 'Chance', value: `${data.enquiry.chancePercent}%` },
{ label: 'Potential', value: formatCurrency(data.enquiry.expectedValue) },
{ label: 'Project Location', value: data.enquiry.projectLocation },
{ label: 'Competitor', value: data.enquiry.competitor },
{ label: 'Salesman', value: data.salesman.name },
{ label: 'Requirement', value: data.enquiry.requirementSummary }
]}
/>
</SectionCard>
<SectionCard title='Timeline / Activity'>
<TimelineList
items={data.enquiry.activities.map((activity) => ({
id: activity.id,
title: activity.title,
detail: activity.detail,
actorName: activity.actorName,
timestamp: activity.createdAt
}))}
/>
</SectionCard>
<SectionCard title='Related Quotations'>
<RelatedQuotationList quotations={data.quotations} />
</SectionCard>
</div>
);
}

View File

@@ -0,0 +1,347 @@
'use client';
import { useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { quotationByIdOptions } from '../api/queries';
import {
approveQuotationMutation,
createQuotationRevisionMutation,
markQuotationStatusMutation,
rejectQuotationMutation,
submitApprovalMutation
} from '../api/mutations';
import {
AuditTimeline,
CustomerRoleCards,
InfoGrid,
QuotationPreviewPanel,
QuotationStatusBadge,
SectionCard,
TimelineList
} from './shared';
import { formatCurrency } from '../utils/format';
function ApprovalDialog({
title,
description,
actionLabel,
onConfirm,
isPending
}: {
title: string;
description: string;
actionLabel: string;
onConfirm: (comment: string) => void;
isPending: boolean;
}) {
const [comment, setComment] = useState('');
return (
<Dialog>
<DialogTrigger asChild>
<Button variant='outline'>{actionLabel}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder='ใส่หมายเหตุ (optional)'
/>
<DialogFooter>
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function QuotationDetailPage({ id }: { id: string }) {
const { data } = useSuspenseQuery(quotationByIdOptions(id));
const baseSubmitApproval = submitApprovalMutation;
const baseApproveMutation = approveQuotationMutation;
const baseRejectMutation = rejectQuotationMutation;
const baseReviseMutation = createQuotationRevisionMutation;
const baseMarkStatusMutation = markQuotationStatusMutation;
const submitApproval = useMutation({
...baseSubmitApproval,
onSuccess: async (...args) => {
await baseSubmitApproval.onSuccess?.(...args);
toast.success('ส่งเข้าสายอนุมัติแล้ว');
}
});
const approveMutation = useMutation({
...baseApproveMutation,
onSuccess: async (...args) => {
await baseApproveMutation.onSuccess?.(...args);
toast.success('อนุมัติ mock สำเร็จ');
}
});
const rejectMutation = useMutation({
...baseRejectMutation,
onSuccess: async (...args) => {
await baseRejectMutation.onSuccess?.(...args);
toast.success('Reject mock สำเร็จ');
}
});
const reviseMutation = useMutation({
...baseReviseMutation,
onSuccess: async (...args) => {
await baseReviseMutation.onSuccess?.(...args);
toast.success('สร้าง revision mock สำเร็จ');
}
});
const markStatusMutation = useMutation({
...baseMarkStatusMutation,
onSuccess: async (...args) => {
await baseMarkStatusMutation.onSuccess?.(...args);
toast.success('อัปเดตสถานะ mock สำเร็จ');
}
});
const ownerCustomer = data.customers.find((customer) =>
data.quotation.customerRoles.some(
(role) => role.role === 'owner' && role.customerId === customer.id
)
);
const ownerContact = data.contacts.find((contact) =>
data.quotation.customerRoles.some(
(role) => role.role === 'owner' && role.contactId === contact.id
)
);
return (
<div className='space-y-4'>
<SectionCard
title={data.quotation.project}
description={`${data.quotation.code} / ${data.branch.name}`}
action={
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
isLoading={submitApproval.isPending}
onClick={() => submitApproval.mutate(data.quotation.id)}
>
Submit approval
</Button>
<ApprovalDialog
title='Approve quotation'
description='Mock sequential approval for demo.'
actionLabel='Approve'
isPending={approveMutation.isPending}
onConfirm={(comment) =>
approveMutation.mutate({ quotationId: data.quotation.id, comment })
}
/>
<ApprovalDialog
title='Reject quotation'
description='Mock reject flow for demo.'
actionLabel='Reject'
isPending={rejectMutation.isPending}
onConfirm={(comment) =>
rejectMutation.mutate({ quotationId: data.quotation.id, comment })
}
/>
<Button
variant='outline'
isLoading={reviseMutation.isPending}
onClick={() => reviseMutation.mutate(data.quotation.id)}
>
Create revision
</Button>
<Button
variant='outline'
isLoading={markStatusMutation.isPending}
onClick={() =>
markStatusMutation.mutate({
quotationId: data.quotation.id,
status: 'sent'
})
}
>
Send to customer
</Button>
<Button
variant='outline'
isLoading={markStatusMutation.isPending}
onClick={() =>
markStatusMutation.mutate({
quotationId: data.quotation.id,
status: 'accepted'
})
}
>
Mark accepted
</Button>
<Button
variant='outline'
isLoading={markStatusMutation.isPending}
onClick={() =>
markStatusMutation.mutate({
quotationId: data.quotation.id,
status: 'lost'
})
}
>
Mark lost
</Button>
</div>
}
>
<div className='flex flex-wrap items-center gap-3'>
<QuotationStatusBadge status={data.quotation.status} />
<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>
</div>
<div className='mt-4'>
<InfoGrid
cols={4}
items={[
{ label: 'Quotation Date', value: data.quotation.quotationDate },
{ label: 'Valid Until', value: data.quotation.validUntil },
{ label: 'Type', value: data.quotation.quotationType },
{ label: 'Chance', value: `${data.quotation.chancePercent}%` },
{ label: 'Salesman', value: data.salesman.name },
{ label: 'Sales Admin', value: data.saleAdmin.name },
{ label: 'Branch', value: data.branch.name },
{ label: 'Site', value: data.quotation.siteLocation }
]}
/>
</div>
</SectionCard>
<SectionCard title='Customer Roles'>
<CustomerRoleCards
roles={data.quotation.customerRoles}
customers={data.customers}
contacts={data.contacts}
/>
</SectionCard>
<div className='grid gap-4 xl:grid-cols-12'>
<SectionCard title='Item Table' className='xl:col-span-7'>
<div className='space-y-3'>
{data.quotation.items.map((item) => (
<div
key={item.id}
className='grid gap-2 rounded-lg border p-3 md:grid-cols-[1fr_auto_auto]'
>
<div>
<p className='font-medium'>{item.description}</p>
<p className='text-muted-foreground text-sm'>{item.topic}</p>
</div>
<p className='text-sm'>
{item.quantity} {item.unit}
</p>
<p className='text-sm font-semibold'>{formatCurrency(item.amount)}</p>
</div>
))}
</div>
</SectionCard>
<SectionCard title='Total Summary' className='xl:col-span-5'>
<InfoGrid
cols={2}
items={[
{ label: 'Sub Total', value: formatCurrency(data.quotation.totalAmount) },
{ label: 'VAT 7%', value: formatCurrency(data.quotation.totalAmount * 0.07) },
{ label: 'Grand Total', value: formatCurrency(data.quotation.totalAmount * 1.07) },
{ label: 'Hot Project', value: data.quotation.isHotProject ? 'Yes' : 'No' }
]}
/>
</SectionCard>
</div>
<div className='grid gap-4 xl:grid-cols-12'>
<SectionCard title='Topics' className='xl:col-span-5'>
<div className='space-y-4'>
{data.quotation.topics.map((topic) => (
<div key={topic.id}>
<p className='font-medium capitalize'>{topic.label}</p>
<ul className='text-muted-foreground mt-2 space-y-1 text-sm'>
{topic.items.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
</div>
))}
</div>
</SectionCard>
<SectionCard title='Attachments' className='xl:col-span-3'>
<div className='space-y-3'>
{data.quotation.attachments.map((attachment) => (
<div key={attachment.id} className='rounded-lg border p-3 text-sm'>
<p className='font-medium'>{attachment.fileName}</p>
<p className='text-muted-foreground'>{attachment.fileType.toUpperCase()}</p>
</div>
))}
</div>
</SectionCard>
<SectionCard title='Follow-ups' className='xl:col-span-4'>
<TimelineList
items={data.quotation.followUps.map((followUp) => ({
id: followUp.id,
title: followUp.title,
detail: `Owner: ${followUp.ownerName}`,
timestamp: followUp.dueDate,
tone: followUp.status === 'done' ? 'success' : 'default'
}))}
/>
</SectionCard>
</div>
<div className='grid gap-4 xl:grid-cols-12'>
<SectionCard title='Approval Timeline' className='xl:col-span-5'>
<TimelineList
items={data.quotation.approvalSteps.map((step) => ({
id: step.id,
title: `Level ${step.level} - ${step.approverName}`,
detail: `${step.approverPosition}${step.comment ? ` / ${step.comment}` : ''}`,
timestamp: step.actedAt ?? data.quotation.quotationDate,
tone:
step.status === 'approved'
? 'success'
: step.status === 'rejected'
? 'danger'
: 'default'
}))}
/>
</SectionCard>
<SectionCard title='Audit Timeline' className='xl:col-span-7'>
<AuditTimeline events={data.auditEvents} />
</SectionCard>
</div>
<QuotationPreviewPanel
quotation={data.quotation}
ownerCustomer={ownerCustomer}
ownerContact={ownerContact}
approvalSteps={data.quotation.approvalSteps}
/>
</div>
);
}

View File

@@ -0,0 +1,252 @@
"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

@@ -0,0 +1,79 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { crmReferenceQueryOptions } from '../api/queries';
import { EmptyState, SectionCard } from './shared';
export function MasterOptionsPage() {
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
return (
<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'>
<p className='text-muted-foreground text-xs uppercase tracking-[0.2em]'>{item.group}</p>
<p className='mt-2 font-medium'>{item.label}</p>
<p className='text-muted-foreground text-sm'>{item.code}</p>
</div>
))}
</div>
</SectionCard>
);
}
export function DocumentSequencesPage() {
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
return (
<SectionCard title='Document Sequences' description='Mock document sequence by branch'>
<div className='space-y-3'>
{data.sequences.map((item) => (
<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')}
</p>
<p className='text-muted-foreground text-sm'>{item.documentType}</p>
</div>
<p className='text-muted-foreground mt-1 text-sm'>Branch: {item.branchId}</p>
</div>
))}
</div>
</SectionCard>
);
}
export function TemplatesPage() {
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
if (data.templates.length === 0) {
return <EmptyState title='No templates' description='ยังไม่มี quotation templates' />;
}
return (
<SectionCard title='Quotation Templates' description='Mock template and placeholder mappings'>
<div className='space-y-4'>
{data.templates.map((template) => (
<div key={template.id} className='rounded-xl border p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='font-semibold'>{template.name}</p>
<p className='text-muted-foreground text-sm'>{template.description}</p>
</div>
<p className='text-muted-foreground text-sm'>{template.version}</p>
</div>
<div className='mt-3 grid gap-3 md:grid-cols-2'>
{template.mappings.map((mapping) => (
<div key={mapping.id} className='rounded-lg bg-muted/20 p-3 text-sm'>
<p className='font-medium'>{mapping.placeholder}</p>
<p className='text-muted-foreground'>{mapping.sourceField}</p>
</div>
))}
</div>
</div>
))}
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,367 @@
'use client';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icons } from '@/components/icons';
import { cn } from '@/lib/utils';
import type {
ApprovalStep,
AuditEvent,
CrmBranch,
Customer,
CustomerContact,
Enquiry,
Quotation,
QuotationCustomerRole
} from '../api/types';
import { formatCurrency, formatDate, formatPercent } from '../utils/format';
import {
getCustomerStatusLabel,
getEnquiryStatusLabel,
getQuotationStatusLabel
} from '../utils/status';
export function SectionCard({
title,
description,
action,
children,
className
}: {
title: string;
description?: string;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<Card className={className}>
<CardHeader className='flex flex-row items-start justify-between space-y-0'>
<div className='space-y-1'>
<CardTitle className='text-base'>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{action}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function KpiCard({
label,
value,
change,
trend
}: {
label: string;
value: string;
change: string;
trend: 'up' | 'down' | 'flat';
}) {
const TrendIcon =
trend === 'up' ? Icons.trendingUp : trend === 'down' ? Icons.trendingDown : Icons.clock;
return (
<Card className='bg-gradient-to-br from-card via-card to-muted/40'>
<CardHeader className='pb-3'>
<div className='flex items-start justify-between gap-3'>
<CardDescription>{label}</CardDescription>
<Badge variant='outline'>
<TrendIcon className='mr-1 h-3.5 w-3.5' />
{change}
</Badge>
</div>
<CardTitle className='text-2xl'>{value}</CardTitle>
</CardHeader>
</Card>
);
}
export function BranchBadge({ branch }: { branch: CrmBranch }) {
return <Badge variant='secondary'>{branch.code}</Badge>;
}
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
return (
<Badge variant={status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'}>
{getCustomerStatusLabel(status)}
</Badge>
);
}
export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
return (
<Badge
variant={
status === 'cancelled' || status === 'closed_lost'
? 'destructive'
: status === 'converted'
? 'secondary'
: 'outline'
}
>
{getEnquiryStatusLabel(status)}
</Badge>
);
}
export function QuotationStatusBadge({ status }: { status: Quotation['status'] }) {
return (
<Badge
variant={
status === 'accepted' || status === 'approved'
? 'secondary'
: status === 'lost' || status === 'rejected' || status === 'cancelled'
? 'destructive'
: 'outline'
}
>
{getQuotationStatusLabel(status)}
</Badge>
);
}
export function InfoGrid({
items,
cols = 2
}: {
items: Array<{ label: string; value: React.ReactNode }>;
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')}>
{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>
<div className='mt-1 text-sm font-medium'>{item.value}</div>
</div>
))}
</div>
);
}
export function TimelineList({
items
}: {
items: Array<{
id: string;
title: string;
detail: string;
actorName?: string;
timestamp: string;
tone?: 'default' | 'success' | 'danger';
}>;
}) {
return (
<div className='space-y-3'>
{items.map((item) => (
<div key={item.id} className='flex gap-3'>
<div className='mt-1 flex flex-col items-center'>
<span
className={cn(
'inline-flex h-2.5 w-2.5 rounded-full bg-primary',
item.tone === 'success' && 'bg-emerald-500',
item.tone === 'danger' && 'bg-destructive'
)}
/>
<span className='bg-border mt-1 h-full w-px' />
</div>
<div className='pb-3'>
<div className='flex flex-wrap items-center gap-2'>
<p className='text-sm font-medium'>{item.title}</p>
<p className='text-muted-foreground text-xs'>{formatDate(item.timestamp)}</p>
</div>
<p className='text-muted-foreground text-sm'>{item.detail}</p>
{item.actorName ? <p className='mt-1 text-xs'> {item.actorName}</p> : null}
</div>
</div>
))}
</div>
);
}
export function CustomerRoleCards({
roles,
customers,
contacts
}: {
roles: QuotationCustomerRole[];
customers: Customer[];
contacts: CustomerContact[];
}) {
return (
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
{roles.map((role) => {
const customer = customers.find((item) => item.id === role.customerId);
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 className='flex items-center justify-between gap-3'>
<p className='text-sm font-semibold capitalize'>{role.role}</p>
<Badge variant='outline'>{customer?.code}</Badge>
</div>
<p className='mt-2 text-sm font-medium'>{customer?.name}</p>
<p className='text-muted-foreground text-sm'>{contact?.name ?? 'No contact'}</p>
<p className='text-muted-foreground text-xs'>{contact?.position}</p>
</div>
);
})}
</div>
);
}
export function QuotationPreviewPanel({
quotation,
ownerCustomer,
ownerContact,
approvalSteps
}: {
quotation: Quotation;
ownerCustomer?: Customer;
ownerContact?: CustomerContact;
approvalSteps: ApprovalStep[];
}) {
return (
<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>
<p className='text-primary text-xs uppercase tracking-[0.3em]'>ALLA OS CRM vNext</p>
<h3 className='mt-2 text-xl font-semibold'>{quotation.code}</h3>
<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>
</div>
</div>
<div className='grid gap-4 py-4 md:grid-cols-2'>
<div className='space-y-1 text-sm'>
<p className='font-medium'>customer_name</p>
<p>{ownerCustomer?.name}</p>
<p className='text-muted-foreground'>{ownerCustomer?.address}</p>
<p className='text-muted-foreground'>{ownerCustomer?.phone}</p>
<p className='text-muted-foreground'>{ownerCustomer?.email}</p>
</div>
<div className='space-y-1 text-sm'>
<p className='font-medium'>customer_att</p>
<p>{ownerContact?.name ?? '-'}</p>
<p className='text-muted-foreground'>{ownerContact?.position ?? '-'}</p>
<p className='text-muted-foreground'>{quotation.siteLocation}</p>
</div>
</div>
<div className='space-y-3 border-t pt-4'>
<div>
<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>
<p className='font-medium'>{item.description}</p>
<p className='text-muted-foreground'>
{item.quantity} {item.unit} x {formatCurrency(item.unitPrice)}
</p>
</div>
<p className='font-semibold'>{formatCurrency(item.amount)}</p>
</div>
))}
</div>
</div>
<div className='rounded-lg bg-muted/30 p-3 text-sm'>
<p className='font-medium'>exclusion_data</p>
<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>)}
</ul>
</div>
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 text-sm'>
<p className='font-medium'>quotation_price</p>
<p className='text-lg font-semibold'>{formatCurrency(quotation.totalAmount)}</p>
</div>
<div className='rounded-lg bg-muted/30 p-3 text-sm'>
<p className='font-medium'>approver names and positions</p>
<ul className='text-muted-foreground mt-1 space-y-1'>
{approvalSteps.map((step) => (
<li key={step.id}>
{step.approverName} / {step.approverPosition}
</li>
))}
</ul>
</div>
</div>
</div>
</SectionCard>
);
}
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>
<div className='flex items-center gap-2'>
<p className='font-medium'>{quotation.code}</p>
<QuotationStatusBadge status={quotation.status} />
</div>
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
</div>
<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}`}>
<Icons.arrowRight className='mr-1 h-4 w-4' />
</Link>
</Button>
</div>
</div>
))}
</div>
);
}
export function HotProjectPill({ active }: { active: boolean }) {
return active ? <Badge>Hot</Badge> : <Badge variant='outline'>Normal</Badge>;
}
export function ChancePill({ value }: { value: number }) {
return <Badge variant='outline'>{formatPercent(value)}</Badge>;
}
export function AuditTimeline({ events }: { events: AuditEvent[] }) {
return (
<TimelineList
items={events.map((event) => ({
id: event.id,
title: event.action,
detail: event.detail,
actorName: event.actorName,
timestamp: event.createdAt,
tone:
event.action === 'APPROVE'
? 'success'
: event.action === 'REJECT'
? 'danger'
: 'default'
}))}
/>
);
}
export function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className='rounded-lg border border-dashed p-8 text-center'>
<p className='font-medium'>{title}</p>
<p className='text-muted-foreground mt-1 text-sm'>{description}</p>
</div>
);
}