This commit is contained in:
phaichayon
2026-07-15 15:43:28 +07:00
parent df1821982d
commit 69ae3d42c4
13 changed files with 1645 additions and 1052 deletions

View File

@@ -4,14 +4,14 @@ import { NavGroup } from "@/types";
{ {
label: "CRM", label: "CRM",
items: [ items: [
{ // {
title: "My Day", // title: "My Day",
url: "/dashboard/crm/my-day", // url: "/dashboard/crm/my-day",
icon: "calendar", // icon: "calendar",
isActive: false, // isActive: false,
items: [], // items: [],
access: { requireOrg: true, permission: "crm.activity.read" }, // access: { requireOrg: true, permission: "crm.activity.read" },
}, // },
{ {
title: "Dashboard", title: "Dashboard",
url: "/dashboard/crm", url: "/dashboard/crm",
@@ -20,14 +20,14 @@ import { NavGroup } from "@/types";
items: [], items: [],
access: { requireOrg: true, permission: "crm.dashboard.read" }, access: { requireOrg: true, permission: "crm.dashboard.read" },
}, },
{ // {
title: "Calendar", // title: "Calendar",
url: "/dashboard/crm/calendar", // url: "/dashboard/crm/calendar",
icon: "calendar", // icon: "calendar",
isActive: false, // isActive: false,
items: [], // items: [],
access: { requireOrg: true, permission: "crm.activity.read" }, // access: { requireOrg: true, permission: "crm.activity.read" },
}, // },
{ {
title: "ลูกค้า", title: "ลูกค้า",
url: "/dashboard/crm/customers", url: "/dashboard/crm/customers",

View File

@@ -1,9 +1,15 @@
'use client'; "use client";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { formatDateTime } from '@/lib/date-format'; Card,
import type { ActivityTimelineItem } from '../api/types'; CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { formatDateTime } from "@/lib/date-format";
import type { ActivityTimelineItem } from "../api/types";
interface RecentActivitiesPanelProps { interface RecentActivitiesPanelProps {
items: ActivityTimelineItem[]; items: ActivityTimelineItem[];
@@ -26,9 +32,9 @@ function MetaItem({ label, value }: { label: string; value: string | null }) {
export function RecentActivitiesPanel({ export function RecentActivitiesPanel({
items, items,
title = 'Recent Activities', title = "Recent Follow up",
description = 'Unified view of new activities and legacy follow-up records.', description = "Unified view of new activities and legacy follow-up records.",
emptyMessage = 'No recent activities yet.' emptyMessage = "No recent activities yet.",
}: RecentActivitiesPanelProps) { }: RecentActivitiesPanelProps) {
return ( return (
<Card> <Card>
@@ -36,34 +42,46 @@ export function RecentActivitiesPanel({
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className="space-y-3">
{items.length === 0 ? ( {items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'> <div className="text-muted-foreground rounded-lg border border-dashed p-4 text-sm">
{emptyMessage} {emptyMessage}
</div> </div>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<div key={`${item.source}-${item.id}`} className='rounded-lg border p-4'> <div
<div className='flex flex-wrap items-start justify-between gap-3'> key={`${item.source}-${item.id}`}
<div className='space-y-2'> className="rounded-lg border p-4"
<div className='flex flex-wrap items-center gap-2'> >
<div className='font-medium'>{item.subject}</div> <div className="flex flex-wrap items-start justify-between gap-3">
{item.activityCode ? <Badge variant='outline'>{item.activityCode}</Badge> : null} <div className="space-y-2">
<Badge variant={item.isLegacy ? 'secondary' : 'default'}> <div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.subject}</div>
{item.activityCode ? (
<Badge variant="outline">{item.activityCode}</Badge>
) : null}
<Badge variant={item.isLegacy ? "secondary" : "default"}>
{item.sourceLabel} {item.sourceLabel}
</Badge> </Badge>
<Badge variant='outline'>{item.statusLabel}</Badge> <Badge variant="outline">{item.statusLabel}</Badge>
</div> </div>
<div className='text-muted-foreground text-sm'> <div className="text-muted-foreground text-sm">
{item.activityTypeLabel} {item.activityTypeLabel}
{item.primaryRecordLabel ? `${item.primaryRecordLabel}` : ''} {item.primaryRecordLabel
? `${item.primaryRecordLabel}`
: ""}
</div> </div>
{item.description ? <div className='text-sm'>{item.description}</div> : null} {item.description ? (
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs'> <div className="text-sm">{item.description}</div>
<MetaItem label='When' value={formatDateTime(item.occurredAt)} /> ) : null}
<MetaItem label='Owner' value={item.ownerName} /> <div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs">
<MetaItem label='Created by' value={item.createdByName} /> <MetaItem
<MetaItem label='Next action' value={item.nextAction} /> label="When"
value={formatDateTime(item.occurredAt)}
/>
<MetaItem label="Owner" value={item.ownerName} />
<MetaItem label="Created by" value={item.createdByName} />
<MetaItem label="Next action" value={item.nextAction} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,32 +1,44 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from "@tanstack/react-query";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { Separator } from '@/components/ui/separator'; Card,
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; CardContent,
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel'; CardDescription,
import { AuditLogTab } from '@/features/crm/components/audit-log-tab'; CardHeader,
import { formatDateTime } from '@/lib/format'; CardTitle,
import { formatNumber } from '@/lib/number-format'; } from "@/components/ui/card";
import { customerByIdOptions } from '../api/queries'; import { Separator } from "@/components/ui/separator";
import type { CustomerReferenceData } from '../api/types'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CustomerFormSheet } from './customer-form-sheet'; import { RecentActivitiesPanel } from "@/features/crm/activities/components/recent-activities-panel";
import { CustomerContactsTab } from './customer-contacts-tab'; import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
import { CustomerOwnerCard } from './customer-owner-card'; import { formatDateTime } from "@/lib/format";
import { CustomerStatusBadge } from './customer-status-badge'; import { formatNumber } from "@/lib/number-format";
import type { CustomerRelatedOpportunityItem } from '../api/types'; import { customerByIdOptions } from "../api/queries";
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; import type { CustomerReferenceData } from "../api/types";
import { CustomerFormSheet } from "./customer-form-sheet";
import { CustomerContactsTab } from "./customer-contacts-tab";
import { CustomerOwnerCard } from "./customer-owner-card";
import { CustomerStatusBadge } from "./customer-status-badge";
import type { CustomerRelatedOpportunityItem } from "../api/types";
import type { QuotationRelationItem } from "@/features/crm/quotations/api/types";
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { function FieldItem({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return ( return (
<div className='space-y-1'> <div className="space-y-1">
<div className='text-muted-foreground text-xs'>{label}</div> <div className="text-muted-foreground text-xs">{label}</div>
<div className='text-sm'>{value || '-'}</div> <div className="text-sm">{value || "-"}</div>
</div> </div>
); );
} }
@@ -41,7 +53,7 @@ export function CustomerDetail({
canManageContactShares, canManageContactShares,
canManageOwner, canManageOwner,
relatedOpportunities, relatedOpportunities,
relatedQuotations relatedQuotations,
}: { }: {
customerId: string; customerId: string;
referenceData: CustomerReferenceData; referenceData: CustomerReferenceData;
@@ -62,112 +74,133 @@ export function CustomerDetail({
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const branchMap = useMemo( const branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])), () => new Map(referenceData.branches.map((item) => [item.id, item.name])),
[referenceData] [referenceData],
); );
const typeMap = useMemo( const typeMap = useMemo(
() => new Map(referenceData.customerTypes.map((item) => [item.id, item])), () => new Map(referenceData.customerTypes.map((item) => [item.id, item])),
[referenceData] [referenceData],
); );
const statusMap = useMemo( const statusMap = useMemo(
() => new Map(referenceData.customerStatuses.map((item) => [item.id, item])), () =>
[referenceData] new Map(referenceData.customerStatuses.map((item) => [item.id, item])),
[referenceData],
); );
const leadMap = useMemo( const leadMap = useMemo(
() => new Map(referenceData.leadChannels.map((item) => [item.id, item.label])), () =>
[referenceData] new Map(referenceData.leadChannels.map((item) => [item.id, item.label])),
[referenceData],
); );
const groupMap = useMemo( const groupMap = useMemo(
() => new Map(referenceData.customerGroups.map((item) => [item.id, item.label])), () =>
[referenceData] new Map(
referenceData.customerGroups.map((item) => [item.id, item.label]),
),
[referenceData],
); );
const subGroupMap = useMemo( const subGroupMap = useMemo(
() => new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label])), () =>
[referenceData] new Map(
referenceData.customerSubGroups.map((item) => [item.id, item.label]),
),
[referenceData],
); );
const customer = data.customer; const customer = data.customer;
const status = statusMap.get(customer.customerStatus); const status = statusMap.get(customer.customerStatus);
const type = typeMap.get(customer.customerType); const type = typeMap.get(customer.customerType);
return ( return (
<div className='space-y-6'> <div className="space-y-6">
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'> <div className="flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between">
<div className='space-y-3'> <div className="space-y-3">
<div className='flex items-center gap-2'> <div className="flex items-center gap-2">
<Button asChild variant='ghost' size='icon'> <Button asChild variant="ghost" size="icon">
<Link href='/dashboard/crm/customers'> <Link href="/dashboard/crm/customers">
<Icons.chevronLeft className='h-4 w-4' /> <Icons.chevronLeft className="h-4 w-4" />
</Link> </Link>
</Button> </Button>
<span className='text-muted-foreground font-mono text-sm'>{customer.code}</span> <span className="text-muted-foreground font-mono text-sm">
<CustomerStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} /> {customer.code}
{type ? <Badge variant='outline'>{type.label}</Badge> : null} </span>
<CustomerStatusBadge
code={status?.code}
label={status?.label ?? "Unknown"}
/>
{type ? <Badge variant="outline">{type.label}</Badge> : null}
{customer.branchId ? ( {customer.branchId ? (
<Badge variant='secondary'> <Badge variant="secondary">
{branchMap.get(customer.branchId) ?? 'Unknown branch'} {branchMap.get(customer.branchId) ?? "Unknown branch"}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<div> <div>
<h2 className='text-2xl font-semibold'>{customer.name}</h2> <h2 className="text-2xl font-semibold">{customer.name}</h2>
<p className='text-muted-foreground text-sm'> <p className="text-muted-foreground text-sm">
{customer.abbr || customer.email || customer.phone || 'รายละเอียดข้อมูลลูกค้า'} {customer.abbr ||
customer.email ||
customer.phone ||
"รายละเอียดข้อมูลลูกค้า"}
</p> </p>
</div> </div>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
{canUpdate ? ( {canUpdate ? (
<Button variant='outline' onClick={() => setEditOpen(true)}> <Button variant="outline" onClick={() => setEditOpen(true)}>
<Icons.edit className='mr-2 h-4 w-4' /> <Icons.edit className="mr-2 h-4 w-4" />
</Button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
<div className='grid gap-6 lg:grid-cols-3'> <div className="grid gap-6 lg:grid-cols-3">
<div className='space-y-6 lg:col-span-2'> <div className="space-y-6 lg:col-span-2">
<Card> <Card>
<CardContent className='pt-6'> <CardContent className="pt-6">
<RecentActivitiesPanel <Tabs defaultValue="overview" className="gap-4">
items={data.recentActivities}
description='Recent customer-linked activity records shown alongside existing audit history.'
emptyMessage='No recent customer activity yet.'
/>
<Tabs defaultValue='overview' className='gap-4'>
<TabsList> <TabsList>
<TabsTrigger value='overview'></TabsTrigger> <TabsTrigger value="overview"></TabsTrigger>
{canReadContacts ? <TabsTrigger value='contacts'></TabsTrigger> : null} {canReadContacts ? (
<TabsTrigger value='activity'>Audit Log</TabsTrigger> <TabsTrigger value="contacts"></TabsTrigger>
<TabsTrigger value='related'></TabsTrigger> ) : null}
<TabsTrigger value="followup">Follow up</TabsTrigger>
<TabsTrigger value="related"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='overview'> <TabsContent value="overview">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> CRM</CardDescription> <CardDescription>
CRM
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<FieldItem label='Tax ID' value={customer.taxId} /> <FieldItem label="Tax ID" value={customer.taxId} />
<FieldItem label='Phone' value={customer.phone} /> <FieldItem label="Phone" value={customer.phone} />
<FieldItem label='Email' value={customer.email} /> <FieldItem label="Email" value={customer.email} />
<FieldItem label='Website' value={customer.website} /> <FieldItem label="Website" value={customer.website} />
<FieldItem <FieldItem
label='Branch' label="Branch"
value={customer.branchId ? branchMap.get(customer.branchId) : null} value={
customer.branchId
? branchMap.get(customer.branchId)
: null
}
/> />
<FieldItem label='ที่มาของลีด' value={leadMap.get(customer.leadChannel ?? '')} />
<FieldItem <FieldItem
label='กลุ่มลูกค้า' label="กลุ่มลูกค้า"
value={groupMap.get(customer.customerGroup ?? '')} value={groupMap.get(customer.customerGroup ?? "")}
/> />
<FieldItem <FieldItem
label='กลุ่มย่อยลูกค้า' label="กลุ่มย่อยลูกค้า"
value={subGroupMap.get(customer.customerSubGroup ?? '')} value={subGroupMap.get(customer.customerSubGroup ?? "")}
/> />
<FieldItem label='ใช้งานอยู่' value={customer.isActive ? 'ใช่' : 'ไม่ใช่'} />
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem <FieldItem
label='ที่อยู่' label="ใช้งานอยู่"
value={customer.isActive ? "ใช่" : "ไม่ใช่"}
/>
<div className="md:col-span-2 xl:col-span-4">
<FieldItem
label="ที่อยู่"
value={ value={
[ [
customer.address, customer.address,
@@ -175,21 +208,21 @@ export function CustomerDetail({
customer.district, customer.district,
customer.province, customer.province,
customer.postalCode, customer.postalCode,
customer.country customer.country,
] ]
.filter(Boolean) .filter(Boolean)
.join(', ') || null .join(", ") || null
} }
/> />
</div> </div>
<div className='md:col-span-2 xl:col-span-4'> <div className="md:col-span-2 xl:col-span-4">
<FieldItem label='หมายเหตุ' value={customer.notes} /> <FieldItem label="หมายเหตุ" value={customer.notes} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
{canReadContacts ? ( {canReadContacts ? (
<TabsContent value='contacts'> <TabsContent value="contacts">
<CustomerContactsTab <CustomerContactsTab
customerId={customerId} customerId={customerId}
canCreate={canManageContacts.create} canCreate={canManageContacts.create}
@@ -201,14 +234,19 @@ export function CustomerDetail({
/> />
</TabsContent> </TabsContent>
) : null} ) : null}
<TabsContent value='activity'> <TabsContent value="followup">
<AuditLogTab <RecentActivitiesPanel
items={data.activity} items={data.recentActivities}
emptyMessage='No audit log recorded yet.' description="Recent customer-linked follow up records shown alongside existing audit history."
description='System-generated immutable history for this customer.' emptyMessage="No recent customer follow up yet."
/> />
{/* <AuditLogTab
items={data.activity}
emptyMessage="No audit log recorded yet."
description="System-generated immutable history for this customer."
/> */}
</TabsContent> </TabsContent>
<TabsContent value='related'> <TabsContent value="related">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
@@ -217,26 +255,29 @@ export function CustomerDetail({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!relatedOpportunities.length && !relatedQuotations.length ? ( {!relatedOpportunities.length &&
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> !relatedQuotations.length ? (
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
</div> </div>
) : ( ) : (
<div className='space-y-3'> <div className="space-y-3">
{relatedOpportunities.map((opportunity) => ( {relatedOpportunities.map((opportunity) => (
<Link <Link
key={opportunity.id} key={opportunity.id}
href={`/dashboard/crm/opportunities/${opportunity.id}`} href={`/dashboard/crm/opportunities/${opportunity.id}`}
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40' className="block rounded-lg border p-4 transition-colors hover:bg-muted/40"
> >
<div className='flex items-center justify-between gap-3'> <div className="flex items-center justify-between gap-3">
<div className='space-y-1'> <div className="space-y-1">
<div className='font-medium'>{opportunity.title}</div> <div className="font-medium">
<div className='text-muted-foreground font-mono text-xs'> {opportunity.title}
</div>
<div className="text-muted-foreground font-mono text-xs">
{opportunity.code} {opportunity.code}
</div> </div>
</div> </div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' /> <Icons.arrowRight className="text-muted-foreground h-4 w-4" />
</div> </div>
</Link> </Link>
))} ))}
@@ -244,16 +285,18 @@ export function CustomerDetail({
<Link <Link
key={quotation.id} key={quotation.id}
href={`/dashboard/crm/quotations/${quotation.id}`} href={`/dashboard/crm/quotations/${quotation.id}`}
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40' className="block rounded-lg border p-4 transition-colors hover:bg-muted/40"
> >
<div className='flex items-center justify-between gap-3'> <div className="flex items-center justify-between gap-3">
<div className='space-y-1'> <div className="space-y-1">
<div className='font-medium'>{quotation.code}</div> <div className="font-medium">
<div className='text-muted-foreground text-xs'> {quotation.code}
</div>
<div className="text-muted-foreground text-xs">
{formatNumber(quotation.totalAmount)} {formatNumber(quotation.totalAmount)}
</div> </div>
</div> </div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' /> <Icons.arrowRight className="text-muted-foreground h-4 w-4" />
</div> </div>
</Link> </Link>
))} ))}
@@ -267,7 +310,7 @@ export function CustomerDetail({
</Card> </Card>
</div> </div>
<div className='space-y-6'> <div className="space-y-6">
<CustomerOwnerCard <CustomerOwnerCard
customerId={customerId} customerId={customerId}
referenceData={referenceData} referenceData={referenceData}
@@ -276,13 +319,27 @@ export function CustomerDetail({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription></CardDescription> <CardDescription>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className="space-y-4">
<FieldItem label='Created By' value={customer.createdByName ?? customer.createdBy} /> <FieldItem
<FieldItem label='Updated By' value={customer.updatedByName ?? customer.updatedBy} /> label="Created By"
<FieldItem label='Created At' value={formatDateTime(customer.createdAt)} /> value={customer.createdByName ?? customer.createdBy}
<FieldItem label='Updated At' value={formatDateTime(customer.updatedAt)} /> />
<FieldItem
label="Updated By"
value={customer.updatedByName ?? customer.updatedBy}
/>
<FieldItem
label="Created At"
value={formatDateTime(customer.createdAt)}
/>
<FieldItem
label="Updated At"
value={formatDateTime(customer.updatedAt)}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,24 +1,34 @@
'use client'; "use client";
import * as React from 'react'; import * as React from "react";
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from "react";
import { useStore } from '@tanstack/react-form'; import { useStore } from "@tanstack/react-form";
import { useMutation } from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
import { toast } from 'sonner'; import { toast } from "sonner";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetFooter, SheetFooter,
SheetHeader, SheetHeader,
SheetTitle SheetTitle,
} from '@/components/ui/sheet'; } from "@/components/ui/sheet";
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; import { useAppForm, useFormFields } from "@/components/ui/tanstack-form";
import { createCustomerMutation, updateCustomerMutation } from '../api/mutations'; import {
import type { CustomerMutationPayload, CustomerRecord, CustomerReferenceData } from '../api/types'; createCustomerMutation,
import { customerSchema, type CustomerFormValues } from '../schemas/customer.schema'; updateCustomerMutation,
} from "../api/mutations";
import type {
CustomerMutationPayload,
CustomerRecord,
CustomerReferenceData,
} from "../api/types";
import {
customerSchema,
type CustomerFormValues,
} from "../schemas/customer.schema";
interface CustomerFormSheetProps { interface CustomerFormSheetProps {
customer?: CustomerRecord; customer?: CustomerRecord;
@@ -29,30 +39,31 @@ interface CustomerFormSheetProps {
function toDefaultValues( function toDefaultValues(
customer: CustomerRecord | undefined, customer: CustomerRecord | undefined,
referenceData: CustomerReferenceData referenceData: CustomerReferenceData,
): CustomerFormValues { ): CustomerFormValues {
return { return {
name: customer?.name ?? '', name: customer?.name ?? "",
abbr: customer?.abbr ?? '', abbr: customer?.abbr ?? "",
taxId: customer?.taxId ?? '', taxId: customer?.taxId ?? "",
customerType: customer?.customerType ?? referenceData.customerTypes[0]?.id ?? '', customerType:
customerStatus: customer?.customerStatus ?? referenceData.customerStatuses[0]?.id ?? '', customer?.customerType ?? referenceData.customerTypes[0]?.id ?? "",
customerStatus:
customer?.customerStatus ?? referenceData.customerStatuses[0]?.id ?? "",
branchId: customer?.branchId ?? undefined, branchId: customer?.branchId ?? undefined,
address: customer?.address ?? '', address: customer?.address ?? "",
province: customer?.province ?? '', province: customer?.province ?? "",
district: customer?.district ?? '', district: customer?.district ?? "",
subDistrict: customer?.subDistrict ?? '', subDistrict: customer?.subDistrict ?? "",
postalCode: customer?.postalCode ?? '', postalCode: customer?.postalCode ?? "",
country: customer?.country ?? 'Thailand', country: customer?.country ?? "Thailand",
phone: customer?.phone ?? '', phone: customer?.phone ?? "",
fax: customer?.fax ?? '', fax: customer?.fax ?? "",
email: customer?.email ?? '', email: customer?.email ?? "",
website: customer?.website ?? '', website: customer?.website ?? "",
leadChannel: customer?.leadChannel ?? undefined,
customerGroup: customer?.customerGroup ?? undefined, customerGroup: customer?.customerGroup ?? undefined,
customerSubGroup: customer?.customerSubGroup ?? undefined, customerSubGroup: customer?.customerSubGroup ?? undefined,
notes: customer?.notes ?? '', notes: customer?.notes ?? "",
isActive: customer?.isActive ?? true isActive: customer?.isActive ?? true,
}; };
} }
@@ -60,12 +71,12 @@ export function CustomerFormSheet({
customer, customer,
open, open,
onOpenChange, onOpenChange,
referenceData referenceData,
}: CustomerFormSheetProps) { }: CustomerFormSheetProps) {
const isEdit = !!customer; const isEdit = !!customer;
const defaultValues = useMemo( const defaultValues = useMemo(
() => toDefaultValues(customer, referenceData), () => toDefaultValues(customer, referenceData),
[customer, referenceData] [customer, referenceData],
); );
const { FormTextField, FormSelectField, FormTextareaField, FormSwitchField } = const { FormTextField, FormSelectField, FormTextareaField, FormSwitchField } =
useFormFields<CustomerFormValues>(); useFormFields<CustomerFormValues>();
@@ -73,35 +84,38 @@ export function CustomerFormSheet({
const createMutation = useMutation({ const createMutation = useMutation({
...createCustomerMutation, ...createCustomerMutation,
onSuccess: () => { onSuccess: () => {
toast.success('สร้างลูกค้าสำเร็จ'); toast.success("สร้างลูกค้าสำเร็จ");
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างลูกค้าได้') toast.error(
error instanceof Error ? error.message : "ไม่สามารถสร้างลูกค้าได้",
),
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
...updateCustomerMutation, ...updateCustomerMutation,
onSuccess: () => { onSuccess: () => {
toast.success('อัปเดตลูกค้าสำเร็จ'); toast.success("อัปเดตลูกค้าสำเร็จ");
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'ไม่สามารถอัปเดตลูกค้าได้') toast.error(
error instanceof Error ? error.message : "ไม่สามารถอัปเดตลูกค้าได้",
),
}); });
const form = useAppForm({ const form = useAppForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmit: customerSchema onSubmit: customerSchema,
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
const payload: CustomerMutationPayload = { const payload: CustomerMutationPayload = {
...value, ...value,
branchId: value.branchId || null, branchId: value.branchId || null,
leadChannel: value.leadChannel || null,
customerGroup: value.customerGroup || null, customerGroup: value.customerGroup || null,
customerSubGroup: value.customerSubGroup || null customerSubGroup: value.customerSubGroup || null,
}; };
if (isEdit && customer) { if (isEdit && customer) {
@@ -110,7 +124,7 @@ export function CustomerFormSheet({
} }
await createMutation.mutateAsync(payload); await createMutation.mutateAsync(payload);
} },
}); });
useEffect(() => { useEffect(() => {
@@ -120,12 +134,20 @@ export function CustomerFormSheet({
form.reset(defaultValues); form.reset(defaultValues);
}, [defaultValues, form, open]); }, [defaultValues, form, open]);
const selectedCustomerGroup = useStore(form.store, (state) => state.values.customerGroup); const selectedCustomerGroup = useStore(
const selectedCustomerSubGroup = useStore(form.store, (state) => state.values.customerSubGroup); form.store,
(state) => state.values.customerGroup,
);
const selectedCustomerSubGroup = useStore(
form.store,
(state) => state.values.customerSubGroup,
);
const filteredCustomerSubGroups = useMemo( const filteredCustomerSubGroups = useMemo(
() => () =>
referenceData.customerSubGroups.filter((item) => item.parentId === selectedCustomerGroup), referenceData.customerSubGroups.filter(
[referenceData.customerSubGroups, selectedCustomerGroup] (item) => item.parentId === selectedCustomerGroup,
),
[referenceData.customerSubGroups, selectedCustomerGroup],
); );
useEffect(() => { useEffect(() => {
@@ -133,10 +155,12 @@ export function CustomerFormSheet({
return; return;
} }
const isValid = filteredCustomerSubGroups.some((item) => item.id === selectedCustomerSubGroup); const isValid = filteredCustomerSubGroups.some(
(item) => item.id === selectedCustomerSubGroup,
);
if (!isValid) { if (!isValid) {
form.setFieldValue('customerSubGroup', ''); form.setFieldValue("customerSubGroup", "");
} }
}, [filteredCustomerSubGroups, form, selectedCustomerSubGroup]); }, [filteredCustomerSubGroups, form, selectedCustomerSubGroup]);
@@ -144,107 +168,146 @@ export function CustomerFormSheet({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-3xl'> <SheetContent className="flex flex-col sm:max-w-3xl">
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? 'แก้ไขลูกค้า' : 'เพิ่มลูกค้า'}</SheetTitle> <SheetTitle>{isEdit ? "แก้ไขลูกค้า" : "เพิ่มลูกค้า"}</SheetTitle>
<SheetDescription> CRM</SheetDescription> <SheetDescription>
CRM
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 overflow-auto'> <div className="flex-1 overflow-auto">
<form.AppForm> <form.AppForm>
<form.Form id='customer-form-sheet' className='grid gap-4 md:grid-cols-2'> <form.Form
id="customer-form-sheet"
className="grid gap-4 md:grid-cols-2"
>
<FormTextField <FormTextField
name='name' name="name"
label='ชื่อลูกค้า' label="ชื่อลูกค้า"
required required
placeholder='ALLA Service Co., Ltd.' placeholder="ALLA Service Co., Ltd."
/> />
<FormTextField name='abbr' label='ชื่อย่อ' placeholder='ALLA' /> <FormTextField name="abbr" label="ชื่อย่อ" placeholder="ALLA" />
<FormTextField name='taxId' label='เลขประจำตัวผู้เสียภาษี' placeholder='0105556...' />
<FormTextField name='phone' label='โทรศัพท์' placeholder='02-000-0000' />
<FormTextField name='fax' label='แฟกซ์' placeholder='02-000-0001' />
<FormTextField <FormTextField
name='email' name="taxId"
label='อีเมล' label="เลขประจำตัวผู้เสียภาษี"
type='email' placeholder="0105556..."
placeholder='sales@example.com' />
<FormTextField
name="phone"
label="โทรศัพท์"
placeholder="02-000-0000"
/>
<FormTextField
name="fax"
label="แฟกซ์"
placeholder="02-000-0001"
/>
<FormTextField
name="email"
label="อีเมล"
type="email"
placeholder="sales@example.com"
/>
<FormTextField
name="website"
label="เว็บไซต์"
placeholder="https://example.com"
/>
<FormTextField
name="country"
label="ประเทศ"
placeholder="Thailand"
/> />
<FormTextField name='website' label='เว็บไซต์' placeholder='https://example.com' />
<FormTextField name='country' label='ประเทศ' placeholder='Thailand' />
<FormSelectField <FormSelectField
name='customerType' name="customerType"
label='ประเภทลูกค้า' label="ประเภทลูกค้า"
required required
options={referenceData.customerTypes.map((item) => ({ options={referenceData.customerTypes.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='customerStatus' name="customerStatus"
label='สถานะลูกค้า' label="สถานะลูกค้า"
required required
options={referenceData.customerStatuses.map((item) => ({ options={referenceData.customerStatuses.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='branchId' name="branchId"
label='สาขา' label="สาขา"
options={referenceData.branches.map((branch) => ({ options={referenceData.branches.map((branch) => ({
value: branch.id, value: branch.id,
label: branch.name label: branch.name,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='leadChannel' name="customerGroup"
label='ที่มาของลีด' label="กลุ่มลูกค้า"
options={referenceData.leadChannels.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<FormSelectField
name='customerGroup'
label='กลุ่มลูกค้า'
options={referenceData.customerGroups.map((item) => ({ options={referenceData.customerGroups.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='customerSubGroup' name="customerSubGroup"
label='กลุ่มย่อยลูกค้า' label="กลุ่มย่อยลูกค้า"
disabled={!selectedCustomerGroup} disabled={!selectedCustomerGroup}
placeholder={ placeholder={
selectedCustomerGroup ? 'เลือกกลุ่มย่อยลูกค้า' : 'เลือกกลุ่มลูกค้าก่อน' selectedCustomerGroup
? "เลือกกลุ่มย่อยลูกค้า"
: "เลือกกลุ่มลูกค้าก่อน"
} }
options={filteredCustomerSubGroups.map((item) => ({ options={filteredCustomerSubGroups.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<div className='md:col-span-2'> <div className="md:col-span-2">
<FormTextField name='address' label='ที่อยู่' placeholder='123 Main Road' /> <FormTextField
</div> name="address"
<FormTextField name='province' label='จังหวัด' placeholder='Bangkok' /> label="ที่อยู่"
<FormTextField name='district' label='เขต / อำเภอ' placeholder='Bang Kapi' /> placeholder="123 Main Road"
<FormTextField name='subDistrict' label='แขวง / ตำบล' placeholder='Hua Mak' />
<FormTextField name='postalCode' label='รหัสไปรษณีย์' placeholder='10240' />
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
size='md'
label='หมายเหตุ'
placeholder='หมายเหตุภายในเกี่ยวกับความสัมพันธ์กับลูกค้ารายนี้'
/> />
</div> </div>
<div className='md:col-span-2'> <FormTextField
name="province"
label="จังหวัด"
placeholder="Bangkok"
/>
<FormTextField
name="district"
label="เขต / อำเภอ"
placeholder="Bang Kapi"
/>
<FormTextField
name="subDistrict"
label="แขวง / ตำบล"
placeholder="Hua Mak"
/>
<FormTextField
name="postalCode"
label="รหัสไปรษณีย์"
placeholder="10240"
/>
<div className="md:col-span-2">
<FormTextareaField
name="notes"
size="md"
label="หมายเหตุ"
placeholder="หมายเหตุภายในเกี่ยวกับความสัมพันธ์กับลูกค้ารายนี้"
/>
</div>
<div className="md:col-span-2">
<FormSwitchField <FormSwitchField
name='isActive' name="isActive"
label='ใช้งานอยู่' label="ใช้งานอยู่"
description='ลูกค้าที่ปิดใช้งานยังคงอยู่ในประวัติ แต่ไม่ควรนำไปใช้กับงานใหม่' description="ลูกค้าที่ปิดใช้งานยังคงอยู่ในประวัติ แต่ไม่ควรนำไปใช้กับงานใหม่"
/> />
</div> </div>
</form.Form> </form.Form>
@@ -252,12 +315,20 @@ export function CustomerFormSheet({
</div> </div>
<SheetFooter> <SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}> <Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button> </Button>
<Button type='submit' form='customer-form-sheet' isLoading={isPending}> <Button
<Icons.check className='mr-2 h-4 w-4' /> type="submit"
{isEdit ? 'อัปเดตลูกค้า' : 'สร้างลูกค้า'} form="customer-form-sheet"
isLoading={isPending}
>
<Icons.check className="mr-2 h-4 w-4" />
{isEdit ? "อัปเดตลูกค้า" : "สร้างลูกค้า"}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -266,7 +337,7 @@ export function CustomerFormSheet({
} }
export function CustomerFormSheetTrigger({ export function CustomerFormSheetTrigger({
referenceData referenceData,
}: { }: {
referenceData: CustomerReferenceData; referenceData: CustomerReferenceData;
}) { }) {
@@ -275,9 +346,13 @@ export function CustomerFormSheetTrigger({
return ( return (
<> <>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> <Icons.add className="mr-2 h-4 w-4" />
</Button> </Button>
<CustomerFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} /> <CustomerFormSheet
open={open}
onOpenChange={setOpen}
referenceData={referenceData}
/>
</> </>
); );
} }

View File

@@ -1,20 +1,20 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import type { Column, ColumnDef } from '@tanstack/react-table'; import type { Column, ColumnDef } from "@tanstack/react-table";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
import { formatDate } from '@/lib/date-format'; import { formatDate } from "@/lib/date-format";
import { formatNumber } from '@/lib/number-format'; import { formatNumber } from "@/lib/number-format";
import type { LeadReferenceData, LeadSummary } from '../api/types'; import type { LeadReferenceData, LeadSummary } from "../api/types";
import { LeadCellAction } from './lead-cell-action'; import { LeadCellAction } from "./lead-cell-action";
export function getLeadColumns({ export function getLeadColumns({
referenceData, referenceData,
canUpdate, canUpdate,
canDelete, canDelete,
canAssign canAssign,
}: { }: {
referenceData: LeadReferenceData; referenceData: LeadReferenceData;
canUpdate: boolean; canUpdate: boolean;
@@ -23,142 +23,173 @@ export function getLeadColumns({
}): ColumnDef<LeadSummary>[] { }): ColumnDef<LeadSummary>[] {
return [ return [
{ {
id: 'code', id: "code",
accessorKey: 'code', accessorKey: "code",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Lead Code' /> <DataTableColumnHeader column={column} title="Lead Code" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className="flex flex-col">
<Link <Link
href={`/dashboard/crm/leads/${row.original.id}`} href={`/dashboard/crm/leads/${row.original.id}`}
className='font-medium hover:underline' className="font-medium hover:underline"
> >
{row.original.code} {row.original.code}
</Link> </Link>
<span className='text-muted-foreground text-xs'>{row.original.customerName ?? '-'}</span> <span className="text-muted-foreground text-xs">
{row.original.customerName ?? "-"}
</span>
</div> </div>
), ),
meta: { meta: {
label: 'Lead Code', label: "Lead Code",
placeholder: 'Search leads...', placeholder: "Search leads...",
variant: 'text' as const, variant: "text" as const,
icon: Icons.search icon: Icons.search,
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'status', id: "status",
accessorFn: (row) => row.status, accessorFn: (row) => row.status,
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Status' /> <DataTableColumnHeader column={column} title="Document Status" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant='outline'>{row.original.statusLabel ?? row.original.status}</Badge> <Badge variant="outline">
{row.original.statusLabel ?? row.original.status}
</Badge>
), ),
meta: { meta: {
label: 'Status', label: "Document Status",
variant: 'select' as const, variant: "select" as const,
options: referenceData.statuses.map((item) => ({ options: referenceData.statuses.map((item) => ({
label: item.label, label: item.label,
value: item.id value: item.id,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'branchId', id: "process_status",
accessorFn: (row) => row.branchId ?? '', accessorFn: (row) => row.status,
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Branch' /> <DataTableColumnHeader column={column} title="Process Status" />
),
cell: ({ row }) => (
<Badge variant="outline">
{row.original.statusLabel ?? row.original.status}
</Badge>
),
meta: {
label: "Process Status",
variant: "select" as const,
options: referenceData.statuses.map((item) => ({
label: item.label,
value: item.id,
})),
},
enableColumnFilter: true,
},
{
id: "branchId",
accessorFn: (row) => row.branchId ?? "",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title="Branch" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const branch = referenceData.branches.find((item) => item.id === row.original.branchId); const branch = referenceData.branches.find(
return <span>{branch?.name ?? '-'}</span>; (item) => item.id === row.original.branchId,
);
return <span>{branch?.name ?? "-"}</span>;
}, },
meta: { meta: {
label: 'Branch', label: "Branch",
variant: 'select' as const, variant: "select" as const,
options: referenceData.branches.map((item) => ({ options: referenceData.branches.map((item) => ({
label: item.name, label: item.name,
value: item.id value: item.id,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'customerId', id: "customerId",
accessorFn: (row) => row.customerId ?? '', accessorFn: (row) => row.customerId ?? "",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' /> <DataTableColumnHeader column={column} title="Customer" />
), ),
cell: ({ row }) => <span>{row.original.customerName ?? '-'}</span>, cell: ({ row }) => <span>{row.original.customerName ?? "-"}</span>,
meta: { meta: {
label: 'Customer', label: "Customer",
variant: 'select' as const, variant: "select" as const,
options: referenceData.customers.map((item) => ({ options: referenceData.customers.map((item) => ({
label: `${item.name} (${item.code})`, label: `${item.name} (${item.code})`,
value: item.id value: item.id,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'projectName', id: "projectName",
accessorKey: 'projectName', accessorKey: "projectName",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Project' /> <DataTableColumnHeader column={column} title="Project" />
), ),
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span> cell: ({ row }) => <span>{row.original.projectName ?? "-"}</span>,
}, },
{ {
id: 'priority', id: "priority",
accessorFn: (row) => row.priority ?? '', accessorFn: (row) => row.priority ?? "",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Priority' /> <DataTableColumnHeader column={column} title="Priority" />
), ),
cell: ({ row }) => <span>{row.original.priority ?? '-'}</span> cell: ({ row }) => <span>{row.original.priority ?? "-"}</span>,
}, },
{ {
id: 'suggestedSalesOwner', id: "suggestedSalesOwner",
accessorFn: (row) => row.suggestedSalesOwnerId ?? '', accessorFn: (row) => row.suggestedSalesOwnerId ?? "",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Suggested Owner' /> <DataTableColumnHeader column={column} title="Suggested Owner" />
), ),
cell: ({ row }) => <span>{row.original.suggestedSalesOwnerName ?? '-'}</span> cell: ({ row }) => (
}, <span>{row.original.suggestedSalesOwnerName ?? "-"}</span>
{
id: 'assignedSalesOwner',
accessorFn: (row) => row.assignedSalesOwnerId ?? '',
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Assigned Owner' />
), ),
cell: ({ row }) => <span>{row.original.assignedSalesOwnerName ?? '-'}</span>
}, },
{ {
id: 'estimatedValue', id: "assignedSalesOwner",
accessorKey: 'estimatedValue', accessorFn: (row) => row.assignedSalesOwnerId ?? "",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => ( header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Estimated Value' /> <DataTableColumnHeader column={column} title="Assigned Owner" />
),
cell: ({ row }) => (
<span>{row.original.assignedSalesOwnerName ?? "-"}</span>
),
},
{
id: "estimatedValue",
accessorKey: "estimatedValue",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title="Estimated Value" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>
{row.original.estimatedValue !== null {row.original.estimatedValue !== null
? formatNumber(row.original.estimatedValue) ? formatNumber(row.original.estimatedValue)
: '-'} : "-"}
</span> </span>
)
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title='Created' />
), ),
cell: ({ row }) => <span>{formatDate(row.original.createdAt)}</span>
}, },
{ {
id: 'actions', id: "createdAt",
accessorKey: "createdAt",
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => <span>{formatDate(row.original.createdAt)}</span>,
},
{
id: "actions",
cell: ({ row }) => ( cell: ({ row }) => (
<LeadCellAction <LeadCellAction
data={row.original} data={row.original}
@@ -167,7 +198,7 @@ export function getLeadColumns({
canDelete={canDelete} canDelete={canDelete}
canAssign={canAssign} canAssign={canAssign}
/> />
) ),
} },
]; ];
} }

View File

@@ -195,6 +195,22 @@ export function LeadForm({
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<form.AppForm> <form.AppForm>
<form.Form id="lead-form" className="grid gap-4 md:grid-cols-2"> <form.Form id="lead-form" className="grid gap-4 md:grid-cols-2">
<FormSelectField
name="status"
label="Document Status"
options={referenceData.statuses.map((item) => ({
value: item.id,
label: item.label,
}))}
/>
<FormSelectField
name="process_status"
label="Process Status"
options={referenceData.statuses.map((item) => ({
value: item.id,
label: item.label,
}))}
/>
<form.AppField <form.AppField
name="customerId" name="customerId"
children={(field) => ( children={(field) => (
@@ -326,25 +342,8 @@ export function LeadForm({
currencyLabel="THB" currencyLabel="THB"
/> />
<FormSelectField
name="followupStatus"
label="Follow-up Status"
options={referenceData.followupStatuses.map((item) => ({
value: item.id,
label: item.label,
}))}
/>
{isEdit ? ( {isEdit ? (
<> <>
<FormSelectField
name="status"
label="Status"
options={referenceData.statuses.map((item) => ({
value: item.id,
label: item.label,
}))}
/>
<FormSelectField <FormSelectField
name="lostReason" name="lostReason"
label="Lost Reason" label="Lost Reason"

View File

@@ -1,15 +1,19 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import type { Column, ColumnDef } from '@tanstack/react-table'; import type { Column, ColumnDef } from "@tanstack/react-table";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
import { formatDate } from '@/lib/date-format'; import { formatDate } from "@/lib/date-format";
import { formatNumber } from '@/lib/number-format'; import { formatNumber } from "@/lib/number-format";
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types'; import type {
import { OpportunityCellAction } from './opportunity-cell-action'; OpportunityListItem,
import { OpportunityStatusBadge } from './opportunity-status-badge'; OpportunityReferenceData,
} from "../api/types";
import { OpportunityCellAction } from "./opportunity-cell-action";
import { resolveOpportunityOption } from "./opportunity-option-resolver";
import { OpportunityStatusBadge } from "./opportunity-status-badge";
type SharedColumnsConfig = { type SharedColumnsConfig = {
referenceData: OpportunityReferenceData; referenceData: OpportunityReferenceData;
@@ -24,96 +28,119 @@ function getLeadColumns({
canUpdate, canUpdate,
canDelete, canDelete,
canAssign, canAssign,
canReassign canReassign,
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] { }: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item])); const leadChannelMap = new Map(
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label])); referenceData.leadChannels.map((item) => [item.id, item.label]),
);
return [ return [
{ {
id: 'code', id: "code",
accessorKey: 'code', accessorKey: "code",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Code' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Code" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className="flex flex-col">
<Link <Link
href={`/dashboard/crm/leads/${row.original.id}`} href={`/dashboard/crm/leads/${row.original.id}`}
className='font-medium hover:underline' className="font-medium hover:underline"
> >
{row.original.code} {row.original.code}
</Link> </Link>
<span className='text-muted-foreground text-xs'>{row.original.title}</span> <span className="text-muted-foreground text-xs">
{row.original.title}
</span>
</div> </div>
), ),
meta: { meta: {
label: 'Code', label: "Code",
placeholder: 'Search leads...', placeholder: "Search leads...",
variant: 'text' as const, variant: "text" as const,
icon: Icons.search icon: Icons.search,
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'leadChannel', id: "leadChannel",
accessorFn: (row) => row.leadChannel ?? '', accessorFn: (row) => row.leadChannel ?? "",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Lead Source' /> column,
}: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Lead Source" />,
cell: ({ row }) => (
<span>{leadChannelMap.get(row.original.leadChannel ?? "") ?? "-"}</span>
), ),
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
}, },
{ {
id: 'customer', id: "customer",
accessorFn: (row) => row.customerId, accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Customer' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Customer" />,
cell: ({ row }) => <span>{row.original.customerName}</span>, cell: ({ row }) => <span>{row.original.customerName}</span>,
meta: { meta: {
label: 'Customer', label: "Customer",
variant: 'select' as const, variant: "select" as const,
options: referenceData.customers.map((item) => ({ options: referenceData.customers.map((item) => ({
value: item.id, value: item.id,
label: `${item.name} (${item.code})` label: `${item.name} (${item.code})`,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'status', id: "status",
accessorKey: 'status', accessorFn: (row) =>
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( resolveOpportunityOption(referenceData.statuses, row.status)?.code ??
<DataTableColumnHeader column={column} title='Status' /> row.status,
), header: ({
column,
}: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => { cell: ({ row }) => {
const status = statusMap.get(row.original.status); const status = resolveOpportunityOption(
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />; referenceData.statuses,
row.original.status,
);
return (
<OpportunityStatusBadge
code={status?.code}
label={status?.label ?? "Unknown"}
/>
);
}, },
meta: { meta: {
label: 'Status', label: "Status",
variant: 'multiSelect' as const, variant: "multiSelect" as const,
options: referenceData.statuses.map((item) => ({ options: referenceData.statuses.map((item) => ({
value: item.id, value: item.code,
label: item.label label: item.label,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'actions', id: "actions",
cell: ({ row }) => ( cell: ({ row }) => (
<OpportunityCellAction <OpportunityCellAction
data={row.original} data={row.original}
referenceData={referenceData} referenceData={referenceData}
workspace='lead' workspace="lead"
canUpdate={canUpdate} canUpdate={canUpdate}
canDelete={canDelete} canDelete={canDelete}
canAssign={canAssign} canAssign={canAssign}
canReassign={canReassign} canReassign={canReassign}
/> />
) ),
} },
]; ];
} }
@@ -122,198 +149,277 @@ function getOpportunityWorkspaceColumns({
canUpdate, canUpdate,
canDelete, canDelete,
canAssign, canAssign,
canReassign canReassign,
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] { }: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item])); const productTypeMap = new Map(
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label])); referenceData.productTypes.map((item) => [item.id, item.label]),
);
return [ return [
{ {
id: 'code', id: "code",
accessorKey: 'code', accessorKey: "code",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Opportunity Code' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Opportunity Code" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className="flex flex-col">
<Link <Link
href={`/dashboard/crm/opportunities/${row.original.id}`} href={`/dashboard/crm/opportunities/${row.original.id}`}
className='font-medium hover:underline' className="font-medium hover:underline"
> >
{row.original.code} {row.original.code}
</Link> </Link>
<span className='text-muted-foreground text-xs'>{row.original.title}</span> <span className="text-muted-foreground text-xs">
{row.original.title}
</span>
</div> </div>
), ),
meta: { meta: {
label: 'Opportunity', label: "Opportunity",
placeholder: 'Search opportunities...', placeholder: "Search opportunities...",
variant: 'text' as const, variant: "text" as const,
icon: Icons.search icon: Icons.search,
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'sourceLead', id: "status",
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'), accessorFn: (row) =>
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( resolveOpportunityOption(referenceData.statuses, row.status)?.code ??
<DataTableColumnHeader column={column} title='Source Lead' /> row.status,
), header: ({
column,
}: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Document Status" />,
cell: ({ row }) => {
const status = resolveOpportunityOption(
referenceData.statuses,
row.original.status,
);
return (
<OpportunityStatusBadge
code={status?.code}
label={status?.label ?? "Unknown"}
/>
);
},
meta: {
label: "Document Status",
variant: "multiSelect" as const,
options: referenceData.statuses.map((item) => ({
value: item.code,
label: item.label,
})),
},
enableColumnFilter: true,
},
{
id: "process_status",
accessorFn: (row) =>
resolveOpportunityOption(referenceData.statuses, row.status)?.code ??
row.status,
header: ({
column,
}: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Process Status" />,
cell: ({ row }) => {
const status = resolveOpportunityOption(
referenceData.statuses,
row.original.status,
);
return (
<OpportunityStatusBadge
code={status?.code}
label={status?.label ?? "Unknown"}
/>
);
},
meta: {
label: "Process Status",
variant: "multiSelect" as const,
options: referenceData.statuses.map((item) => ({
value: item.code,
label: item.label,
})),
},
enableColumnFilter: true,
},
{
id: "sourceLead",
accessorFn: (row) => (row.leadId ? "linked" : "direct"),
header: ({
column,
}: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Source Lead" />,
cell: ({ row }) => cell: ({ row }) =>
row.original.leadId ? ( row.original.leadId ? (
<Badge variant='outline'> <Badge variant="outline">
{row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'} {row.original.source ??
row.original.sourceLeadCode ??
"Lead linked"}
</Badge> </Badge>
) : ( ) : (
<span className='text-muted-foreground text-sm'>Direct sales</span> <span className="text-muted-foreground text-sm">Direct sales</span>
) ),
}, },
{ {
id: 'customer', id: "customer",
accessorFn: (row) => row.customerId, accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Customer' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Customer" />,
cell: ({ row }) => <span>{row.original.customerName}</span>, cell: ({ row }) => <span>{row.original.customerName}</span>,
meta: { meta: {
label: 'Customer', label: "Customer",
variant: 'select' as const, variant: "select" as const,
options: referenceData.customers.map((item) => ({ options: referenceData.customers.map((item) => ({
value: item.id, value: item.id,
label: `${item.name} (${item.code})` label: `${item.name} (${item.code})`,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'projectName', id: "projectName",
accessorFn: (row) => row.projectName ?? '', accessorFn: (row) => row.projectName ?? "",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Project Name' /> column,
), }: {
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span> column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Project Name" />,
cell: ({ row }) => <span>{row.original.projectName ?? "-"}</span>,
}, },
{ {
id: 'productType', id: "productType",
accessorFn: (row) => row.productType, accessorFn: (row) => row.productType,
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Product Type' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Product Type" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span> <span>
{productTypeMap.get(row.original.productType) ??
row.original.productType}
</span>
), ),
meta: { meta: {
label: 'Product Type', label: "Product Type",
variant: 'multiSelect' as const, variant: "multiSelect" as const,
options: referenceData.productTypes.map((item) => ({ options: referenceData.productTypes.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
})) })),
}, },
enableColumnFilter: true enableColumnFilter: true,
}, },
{ {
id: 'assignedToName', id: "assignedToName",
accessorFn: (row) => row.assignedToName ?? '', accessorFn: (row) => row.assignedToName ?? "",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Sales Owner' /> column,
), }: {
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span> column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Sales Owner" />,
cell: ({ row }) => <span>{row.original.assignedToName ?? "-"}</span>,
}, },
{ {
id: 'status', id: "estimatedValue",
accessorKey: 'status', accessorKey: "estimatedValue",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Status' /> column,
), }: {
cell: ({ row }) => { column: Column<OpportunityListItem, unknown>;
const status = statusMap.get(row.original.status); }) => <DataTableColumnHeader column={column} title="Estimated Value" />,
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
},
meta: {
label: 'Status',
variant: 'multiSelect' as const,
options: referenceData.statuses.map((item) => ({
value: item.id,
label: item.label
}))
},
enableColumnFilter: true
},
{
id: 'estimatedValue',
accessorKey: 'estimatedValue',
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Estimated Value' />
),
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>
{row.original.estimatedValue !== null {row.original.estimatedValue !== null
? formatNumber(row.original.estimatedValue) ? formatNumber(row.original.estimatedValue)
: '-'} : "-"}
</span> </span>
) ),
}, },
{ {
id: 'chancePercent', id: "chancePercent",
accessorKey: 'chancePercent', accessorKey: "chancePercent",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Chance %' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Chance %" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span> <span>
) {row.original.chancePercent !== null
? `${row.original.chancePercent}%`
: "-"}
</span>
),
}, },
{ {
id: 'expectedCloseDate', id: "expectedCloseDate",
accessorKey: 'expectedCloseDate', accessorKey: "expectedCloseDate",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Expected Close' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Expected Close" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>
{row.original.expectedCloseDate {row.original.expectedCloseDate
? formatDate(row.original.expectedCloseDate) ? formatDate(row.original.expectedCloseDate)
: '-'} : "-"}
</span> </span>
) ),
}, },
{ {
id: 'nextFollowupDate', id: "nextFollowupDate",
accessorKey: 'nextFollowupDate', accessorKey: "nextFollowupDate",
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => ( header: ({
<DataTableColumnHeader column={column} title='Next Follow-up' /> column,
), }: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Next Follow-up" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>
{row.original.nextFollowupDate {row.original.nextFollowupDate
? formatDate(row.original.nextFollowupDate) ? formatDate(row.original.nextFollowupDate)
: '-'} : "-"}
</span> </span>
)
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Created Date' />
), ),
cell: ({ row }) => <span>{formatDate(row.original.createdAt)}</span>
}, },
{ {
id: 'actions', id: "createdAt",
accessorKey: "createdAt",
header: ({
column,
}: {
column: Column<OpportunityListItem, unknown>;
}) => <DataTableColumnHeader column={column} title="Created Date" />,
cell: ({ row }) => <span>{formatDate(row.original.createdAt)}</span>,
},
{
id: "actions",
cell: ({ row }) => ( cell: ({ row }) => (
<OpportunityCellAction <OpportunityCellAction
data={row.original} data={row.original}
referenceData={referenceData} referenceData={referenceData}
workspace='opportunity' workspace="opportunity"
canUpdate={canUpdate} canUpdate={canUpdate}
canDelete={canDelete} canDelete={canDelete}
canAssign={canAssign} canAssign={canAssign}
canReassign={canReassign} canReassign={canReassign}
/> />
) ),
} },
]; ];
} }
@@ -323,10 +429,18 @@ export function getOpportunityColumns({
canUpdate, canUpdate,
canDelete, canDelete,
canAssign, canAssign,
canReassign canReassign,
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] { }: SharedColumnsConfig & {
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign }; workspace: "lead" | "opportunity";
return workspace === 'lead' }): ColumnDef<OpportunityListItem>[] {
const sharedConfig = {
referenceData,
canUpdate,
canDelete,
canAssign,
canReassign,
};
return workspace === "lead"
? getLeadColumns(sharedConfig) ? getLeadColumns(sharedConfig)
: getOpportunityWorkspaceColumns(sharedConfig); : getOpportunityWorkspaceColumns(sharedConfig);
} }

View File

@@ -1,43 +1,59 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from "@tanstack/react-query";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; Card,
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel'; CardContent,
import { AuditLogTab } from '@/features/crm/components/audit-log-tab'; CardDescription,
import { formatDate, formatDateTime } from '@/lib/date-format'; CardHeader,
import { formatNumber } from '@/lib/number-format'; CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RecentActivitiesPanel } from "@/features/crm/activities/components/recent-activities-panel";
import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
import { formatDate, formatDateTime } from "@/lib/date-format";
import { formatNumber } from "@/lib/number-format";
import type { import type {
QuotationReferenceData, QuotationReferenceData,
QuotationRelationItem QuotationRelationItem,
} from '@/features/crm/quotations/api/types'; } from "@/features/crm/quotations/api/types";
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet'; import { QuotationFormSheet } from "@/features/crm/quotations/components/quotation-form-sheet";
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology'; import { getPipelineStageThaiLabel } from "@/features/crm/shared/terminology";
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries'; import {
import type { OpportunityRecord, OpportunityReferenceData } from '../api/types'; opportunityByIdOptions,
import { OpportunityAssignmentDialog } from './opportunity-assignment-dialog'; opportunityProjectPartiesOptions,
import { OpportunityFollowupsTab } from './opportunity-followups-tab'; } from "../api/queries";
import { OpportunityFormSheet } from './opportunity-form-sheet'; import type { OpportunityRecord, OpportunityReferenceData } from "../api/types";
import { OpportunityOutcomeCard } from './opportunity-outcome-card'; import { OpportunityAssignmentDialog } from "./opportunity-assignment-dialog";
import { OpportunityStatusBadge } from './opportunity-status-badge'; import { OpportunityFollowupsTab } from "./opportunity-followups-tab";
import { QuotationReadinessPanel } from './quotation-readiness-panel'; import { OpportunityFormSheet } from "./opportunity-form-sheet";
import { SourceLeadCard, type SourceLeadSummary } from './source-lead-card'; import { resolveOpportunityOption } from "./opportunity-option-resolver";
import { OpportunityOutcomeCard } from "./opportunity-outcome-card";
import { OpportunityStatusBadge } from "./opportunity-status-badge";
import { QuotationReadinessPanel } from "./quotation-readiness-panel";
import { SourceLeadCard, type SourceLeadSummary } from "./source-lead-card";
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { function FieldItem({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return ( return (
<div className='space-y-1'> <div className="space-y-1">
<div className='text-muted-foreground text-xs'>{label}</div> <div className="text-muted-foreground text-xs">{label}</div>
<div className='text-sm'>{value || '-'}</div> <div className="text-sm">{value || "-"}</div>
</div> </div>
); );
} }
function getPipelineStageLabel(stage: OpportunityRecord['pipelineStage']) { function getPipelineStageLabel(stage: OpportunityRecord["pipelineStage"]) {
return getPipelineStageThaiLabel(stage); return getPipelineStageThaiLabel(stage);
} }
@@ -45,7 +61,7 @@ interface OpportunityDetailProps {
opportunityId: string; opportunityId: string;
referenceData: OpportunityReferenceData; referenceData: OpportunityReferenceData;
relatedQuotations: QuotationRelationItem[]; relatedQuotations: QuotationRelationItem[];
workspace: 'lead' | 'opportunity'; workspace: "lead" | "opportunity";
canUpdate: boolean; canUpdate: boolean;
canAssign: boolean; canAssign: boolean;
canReassign: boolean; canReassign: boolean;
@@ -80,11 +96,11 @@ export function OpportunityDetail({
canCreateQuotation, canCreateQuotation,
canManageFollowups, canManageFollowups,
sourceLead = null, sourceLead = null,
quotationReferenceData = null quotationReferenceData = null,
}: OpportunityDetailProps) { }: OpportunityDetailProps) {
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId)); const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
const { data: projectPartiesData } = useSuspenseQuery( const { data: projectPartiesData } = useSuspenseQuery(
opportunityProjectPartiesOptions(opportunityId) opportunityProjectPartiesOptions(opportunityId),
); );
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [assignmentOpen, setAssignmentOpen] = useState(false); const [assignmentOpen, setAssignmentOpen] = useState(false);
@@ -93,61 +109,70 @@ export function OpportunityDetail({
const branchMap = useMemo( const branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])), () => new Map(referenceData.branches.map((item) => [item.id, item.name])),
[referenceData] [referenceData],
); );
const customerMap = useMemo( const customerMap = useMemo(
() => new Map(referenceData.customers.map((item) => [item.id, item])), () => new Map(referenceData.customers.map((item) => [item.id, item])),
[referenceData] [referenceData],
); );
const contactMap = useMemo( const contactMap = useMemo(
() => new Map(referenceData.contacts.map((item) => [item.id, item])), () => new Map(referenceData.contacts.map((item) => [item.id, item])),
[referenceData] [referenceData],
);
const statusMap = useMemo(
() => new Map(referenceData.statuses.map((item) => [item.id, item])),
[referenceData]
); );
const productTypeMap = useMemo( const productTypeMap = useMemo(
() => new Map(referenceData.productTypes.map((item) => [item.id, item.label])), () =>
[referenceData] new Map(referenceData.productTypes.map((item) => [item.id, item.label])),
[referenceData],
);
const priorityMap = useMemo(
() =>
new Map(referenceData.priorities.map((item) => [item.id, item.label])),
[referenceData],
);
const quotationInitialPrefill = useMemo(
() => ({
opportunityId: opportunity.id,
customerId: opportunity.customerId,
contactId: opportunity.contactId,
projectName: opportunity.projectName,
projectLocation: opportunity.projectLocation,
branchId: opportunity.branchId,
quotationType: opportunity.productType,
projectCloseDate: opportunity.projectCloseDate
? opportunity.projectCloseDate.slice(0, 10)
: null,
deliveryDate: opportunity.deliveryDate
? opportunity.deliveryDate.slice(0, 10)
: null,
isHotProject: opportunity.isHotProject,
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden,
projectParties: projectPartiesData.items.map((item) => ({
key: item.id,
customerId: item.customerId,
role: item.role,
remark: item.remark ?? "",
})),
}),
[opportunity, projectPartiesData.items],
); );
const priorityMap = useMemo(
() => new Map(referenceData.priorities.map((item) => [item.id, item.label])),
[referenceData]
);
const quotationInitialPrefill = useMemo(
() => ({
opportunityId: opportunity.id,
customerId: opportunity.customerId,
contactId: opportunity.contactId,
projectName: opportunity.projectName,
projectLocation: opportunity.projectLocation,
branchId: opportunity.branchId,
quotationType: opportunity.productType,
projectCloseDate: opportunity.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : null,
deliveryDate: opportunity.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : null,
isHotProject: opportunity.isHotProject,
hotProjectAutoSuggested: opportunity.hotProjectAutoSuggested,
hotProjectManuallyOverridden: opportunity.hotProjectManuallyOverridden,
projectParties: projectPartiesData.items.map((item) => ({
key: item.id,
customerId: item.customerId,
role: item.role,
remark: item.remark ?? ''
}))
}),
[opportunity, projectPartiesData.items]
);
const customer = customerMap.get(opportunity.customerId); const customer = customerMap.get(opportunity.customerId);
const contact = opportunity.contactId ? contactMap.get(opportunity.contactId) : null; const contact = opportunity.contactId
const status = statusMap.get(opportunity.status); ? contactMap.get(opportunity.contactId)
: null;
const status = resolveOpportunityOption(
referenceData.statuses,
opportunity.status,
);
const pipelineStageLabel = getPipelineStageLabel(opportunity.pipelineStage); const pipelineStageLabel = getPipelineStageLabel(opportunity.pipelineStage);
const canManageAssignment = opportunity.assignedToUserId ? canReassign : canAssign; const canManageAssignment = opportunity.assignedToUserId
const assignmentMode = opportunity.assignedToUserId ? 'reassign' : 'assign'; ? canReassign
: canAssign;
const assignmentMode = opportunity.assignedToUserId ? "reassign" : "assign";
return ( return (
<div className='space-y-6'> <div className="space-y-6">
<OpportunityFormSheet <OpportunityFormSheet
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
@@ -171,62 +196,78 @@ remark: item.remark ?? ''
/> />
) : null} ) : null}
<div className='flex items-start justify-between gap-4'> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className='flex items-center gap-3'> <div className="flex items-center gap-3">
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2> <h2 className="text-2xl font-semibold">{opportunity.code}</h2>
<OpportunityStatusBadge <OpportunityStatusBadge
code={status?.code} code={status?.code}
label={status?.label ?? opportunity.status} label={status?.label ?? opportunity.status}
/> />
</div> </div>
<p className='text-muted-foreground mt-2 text-sm'> <p className="text-muted-foreground mt-2 text-sm">
Opportunity detail for sales execution, follow-up continuity, and quotation readiness. Opportunity detail for sales execution, follow-up continuity, and
quotation readiness.
</p> </p>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
{canCreateQuotation ? ( {canCreateQuotation ? (
<Button onClick={() => setQuotationOpen(true)}> <Button onClick={() => setQuotationOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> <Icons.add className="mr-2 h-4 w-4" />
Create Quotation Create Quotation
</Button> </Button>
) : null} ) : null}
{sourceLead ? ( {sourceLead ? (
<Button variant='outline' asChild> <Button variant="outline" asChild>
<Link href={`/dashboard/crm/leads/${sourceLead.id}`}>Open Source Lead</Link> <Link href={`/dashboard/crm/leads/${sourceLead.id}`}>
Open Source Lead
</Link>
</Button> </Button>
) : null} ) : null}
{canManageAssignment ? ( {canManageAssignment ? (
<Button variant='outline' onClick={() => setAssignmentOpen(true)}> <Button variant="outline" onClick={() => setAssignmentOpen(true)}>
{assignmentMode === 'assign' ? 'Assign Opportunity' : 'Reassign Opportunity'} {assignmentMode === "assign"
? "Assign Opportunity"
: "Reassign Opportunity"}
</Button> </Button>
) : null} ) : null}
{canUpdate ? ( {canUpdate ? (
<Button variant='outline' onClick={() => setEditOpen(true)}> <Button variant="outline" onClick={() => setEditOpen(true)}>
Edit Opportunity Edit Opportunity
</Button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
<div className='grid gap-6 xl:grid-cols-3'> <div className="grid gap-6 xl:grid-cols-3">
<div className='space-y-6 xl:col-span-2'> <div className="space-y-6 xl:col-span-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Opportunity Summary</CardTitle> <CardTitle>Opportunity Summary</CardTitle>
<CardDescription>Core sales execution snapshot for this opportunity.</CardDescription> <CardDescription>
Core sales execution snapshot for this opportunity.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'> <CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<FieldItem label='Customer' value={customer?.name} /> <FieldItem label="Customer" value={customer?.name} />
<FieldItem label='Contact' value={contact?.name} /> <FieldItem label="Contact" value={contact?.name} />
<FieldItem label='Project Name' value={opportunity.projectName} /> <FieldItem label="Project Name" value={opportunity.projectName} />
<FieldItem label='Project Location' value={opportunity.projectLocation} />
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
<FieldItem label='Sales Owner' value={opportunity.assignedToName} />
<FieldItem label='Status' value={status?.label ?? opportunity.status} />
<FieldItem <FieldItem
label='Expected Close Date' label="Project Location"
value={opportunity.projectLocation}
/>
<FieldItem label="Pipeline Stage" value={pipelineStageLabel} />
<FieldItem
label="Sales Owner"
value={opportunity.assignedToName}
/>
<FieldItem
label="Status"
value={status?.label ?? opportunity.status}
/>
<FieldItem
label="Expected Close Date"
value={ value={
opportunity.expectedCloseDate opportunity.expectedCloseDate
? formatDate(opportunity.expectedCloseDate) ? formatDate(opportunity.expectedCloseDate)
@@ -234,7 +275,7 @@ remark: item.remark ?? ''
} }
/> />
<FieldItem <FieldItem
label='Project Close Date' label="Project Close Date"
value={ value={
opportunity.projectCloseDate opportunity.projectCloseDate
? formatDate(opportunity.projectCloseDate) ? formatDate(opportunity.projectCloseDate)
@@ -242,11 +283,15 @@ remark: item.remark ?? ''
} }
/> />
<FieldItem <FieldItem
label='Delivery Date' label="Delivery Date"
value={opportunity.deliveryDate ? formatDate(opportunity.deliveryDate) : null} value={
opportunity.deliveryDate
? formatDate(opportunity.deliveryDate)
: null
}
/> />
<FieldItem <FieldItem
label='Estimated Value' label="Estimated Value"
value={ value={
opportunity.estimatedValue !== null opportunity.estimatedValue !== null
? formatNumber(opportunity.estimatedValue) ? formatNumber(opportunity.estimatedValue)
@@ -255,136 +300,180 @@ remark: item.remark ?? ''
/> />
</CardContent> </CardContent>
</Card> </Card>
<Tabs defaultValue="followups" className="space-y-4">
<SourceLeadCard sourceLead={sourceLead} /> <TabsList>
<TabsTrigger value="followups">Follow-up</TabsTrigger>
<Card> <TabsTrigger value="customer">Customer / Contact</TabsTrigger>
<CardHeader> <TabsTrigger value="lead">Lead</TabsTrigger>
<CardTitle>Customer / Contact</CardTitle> <TabsTrigger value="requirement">Requirement</TabsTrigger>
</CardHeader> <TabsTrigger value="sales">Sales Qualification</TabsTrigger>
<CardContent className='grid gap-4 md:grid-cols-2'> <TabsTrigger value="quotations">Linked Quotations</TabsTrigger>
<FieldItem label='Customer Code' value={customer?.code} /> {/* <TabsTrigger value="activity">Audit Log</TabsTrigger> */}
<FieldItem label='Customer Owner' value={customer?.ownerName} /> </TabsList>
<FieldItem label='Contact Name' value={contact?.name} /> <TabsContent value="followups">
<FieldItem label='Contact Email' value={contact?.email} />
<FieldItem label='Contact Mobile' value={contact?.mobile} />
<FieldItem
label='Branch'
value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Requirement</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem label='Title' value={opportunity.title} />
<FieldItem label='Description' value={opportunity.description} />
<FieldItem label='Requirement' value={opportunity.requirement} />
<div className='grid gap-4 md:grid-cols-2'>
<FieldItem label='Source' value={opportunity.source} />
<FieldItem label='Assignment Remark' value={opportunity.assignmentRemark} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Sales Qualification</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<FieldItem
label='Product Type'
value={productTypeMap.get(opportunity.productType) ?? opportunity.productType}
/>
<FieldItem
label='Priority'
value={priorityMap.get(opportunity.priority) ?? opportunity.priority}
/>
<FieldItem
label='Chance %'
value={opportunity.chancePercent !== null ? `${opportunity.chancePercent}%` : null}
/>
<FieldItem label='Competitor' value={opportunity.competitor} />
<FieldItem
label='Assigned At'
value={
opportunity.assignedAt ? formatDateTime(opportunity.assignedAt) : null
}
/>
<FieldItem label='Assigned By' value={opportunity.assignedByName} />
</CardContent>
</Card>
<RecentActivitiesPanel <RecentActivitiesPanel
items={data.recentActivities} items={data.recentActivities}
description='New activity records and legacy opportunity follow-ups shown together.' description="New activity records and legacy opportunity follow-ups shown together."
emptyMessage='No recent activity or opportunity follow-up yet.' emptyMessage="No recent activity or opportunity follow-up yet."
/> />
{/* <OpportunityFollowupsTab
<Tabs defaultValue='followups' className='space-y-4'>
<TabsList>
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
<TabsTrigger value='quotations'>Linked Quotations</TabsTrigger>
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
</TabsList>
<TabsContent value='followups'>
<OpportunityFollowupsTab
opportunityId={opportunityId} opportunityId={opportunityId}
customerId={opportunity.customerId} customerId={opportunity.customerId}
referenceData={referenceData} referenceData={referenceData}
canCreate={canManageFollowups.create} canCreate={canManageFollowups.create}
canUpdate={canManageFollowups.update} canUpdate={canManageFollowups.update}
canDelete={canManageFollowups.delete} canDelete={canManageFollowups.delete}
/> /> */}
</TabsContent> </TabsContent>
<TabsContent value="customer">
<TabsContent value='quotations'> <Card>
<CardHeader>
<CardTitle>Customer / Contact</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FieldItem label="Customer Code" value={customer?.code} />
<FieldItem
label="Customer Owner"
value={customer?.ownerName}
/>
<FieldItem label="Contact Name" value={contact?.name} />
<FieldItem label="Contact Email" value={contact?.email} />
<FieldItem label="Contact Mobile" value={contact?.mobile} />
<FieldItem
label="Branch"
value={
opportunity.branchId
? branchMap.get(opportunity.branchId)
: null
}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="lead">
<SourceLeadCard sourceLead={sourceLead} />
</TabsContent>
<TabsContent value="requirement">
<Card>
<CardHeader>
<CardTitle>Requirement</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FieldItem label="Title" value={opportunity.title} />
<FieldItem
label="Description"
value={opportunity.description}
/>
<FieldItem
label="Requirement"
value={opportunity.requirement}
/>
<div className="grid gap-4 md:grid-cols-2">
<FieldItem label="Source" value={opportunity.source} />
<FieldItem
label="Assignment Remark"
value={opportunity.assignmentRemark}
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="quotations">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Linked Quotations</CardTitle> <CardTitle>Linked Quotations</CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className="space-y-3">
{!canViewRelatedQuotations ? ( {!canViewRelatedQuotations ? (
<div className='text-muted-foreground text-sm'> <div className="text-muted-foreground text-sm">
Quotation access is restricted. Quotation access is restricted.
</div> </div>
) : relatedQuotations.length === 0 ? ( ) : relatedQuotations.length === 0 ? (
<div className='text-muted-foreground text-sm'>No quotations created yet.</div> <div className="text-muted-foreground text-sm">
No quotations created yet.
</div>
) : ( ) : (
relatedQuotations.map((quotation) => ( relatedQuotations.map((quotation) => (
<div key={quotation.id} className='border-border rounded-md border p-3'> <div
<div className='flex items-center justify-between gap-3'> key={quotation.id}
className="border-border rounded-md border p-3"
>
<div className="flex items-center justify-between gap-3">
<Link <Link
href={`/dashboard/crm/quotations/${quotation.id}`} href={`/dashboard/crm/quotations/${quotation.id}`}
className='font-medium hover:underline' className="font-medium hover:underline"
> >
{quotation.code} {quotation.code}
</Link> </Link>
<Badge variant='outline'>{quotation.status}</Badge> <Badge variant="outline">{quotation.status}</Badge>
</div>
<div className="mt-1 text-sm">
{quotation.quotationType}
</div> </div>
<div className='mt-1 text-sm'>{quotation.quotationType}</div>
</div> </div>
)) ))
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="sales">
<TabsContent value='activity'> <Card>
<CardHeader>
<CardTitle>Sales Qualification</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<FieldItem
label="Product Type"
value={
productTypeMap.get(opportunity.productType) ??
opportunity.productType
}
/>
<FieldItem
label="Priority"
value={
priorityMap.get(opportunity.priority) ??
opportunity.priority
}
/>
<FieldItem
label="Chance %"
value={
opportunity.chancePercent !== null
? `${opportunity.chancePercent}%`
: null
}
/>
<FieldItem
label="Competitor"
value={opportunity.competitor}
/>
<FieldItem
label="Assigned At"
value={
opportunity.assignedAt
? formatDateTime(opportunity.assignedAt)
: null
}
/>
<FieldItem
label="Assigned By"
value={opportunity.assignedByName}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity">
<AuditLogTab <AuditLogTab
items={data.activity} items={data.activity}
emptyMessage='No audit log recorded yet.' emptyMessage="No audit log recorded yet."
description='System-generated immutable history for this opportunity and its follow-ups.' description="System-generated immutable history for this opportunity and its follow-ups."
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
<div className='space-y-6'> <div className="space-y-6">
<QuotationReadinessPanel opportunity={opportunity} /> <QuotationReadinessPanel opportunity={opportunity} />
<OpportunityOutcomeCard <OpportunityOutcomeCard
@@ -396,32 +485,44 @@ remark: item.remark ?? ''
canReopen={canReopen} canReopen={canReopen}
/> />
<Card> {/* <Card>
<CardHeader> <CardHeader>
<CardTitle>Quotation Context</CardTitle> <CardTitle>Quotation Context</CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className="space-y-3">
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
<FieldItem label='Project Parties' value={String(projectPartiesData.items.length)} />
<FieldItem <FieldItem
label='Record Type' label="Related Quotations"
value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} value={String(relatedQuotations.length)}
/>
<FieldItem
label="Project Parties"
value={String(projectPartiesData.items.length)}
/>
<FieldItem
label="Record Type"
value={workspace === "opportunity" ? "Opportunity" : "Lead"}
/> />
{relatedQuotations.length ? ( {relatedQuotations.length ? (
<div className='space-y-2'> <div className="space-y-2">
{relatedQuotations.slice(0, 3).map((quotation) => ( {relatedQuotations.slice(0, 3).map((quotation) => (
<div key={quotation.id} className='rounded-lg border p-3 text-sm'> <div
<div className='flex items-center justify-between gap-3'> key={quotation.id}
className="rounded-lg border p-3 text-sm"
>
<div className="flex items-center justify-between gap-3">
<Link <Link
href={`/dashboard/crm/quotations/${quotation.id}`} href={`/dashboard/crm/quotations/${quotation.id}`}
className='font-medium hover:underline' className="font-medium hover:underline"
> >
{quotation.code} {quotation.code}
</Link> </Link>
<Badge variant='outline'>Rev {quotation.revision}</Badge> <Badge variant="outline">
Rev {quotation.revision}
</Badge>
</div> </div>
<div className='text-muted-foreground mt-1'> <div className="text-muted-foreground mt-1">
{quotation.status} · {formatNumber(quotation.totalAmount)}{' '} {quotation.status} ·{" "}
{formatNumber(quotation.totalAmount)}{" "}
{quotation.currency} {quotation.currency}
</div> </div>
</div> </div>
@@ -429,7 +530,7 @@ remark: item.remark ?? ''
</div> </div>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card> */}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,71 +1,99 @@
'use client'; "use client";
import * as React from 'react'; import * as React from "react";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from 'sonner'; import { toast } from "sonner";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { import {
ProjectPartiesEditor, ProjectPartiesEditor,
type ProjectPartyEditorItem type ProjectPartyEditorItem,
} from '@/features/crm/components/project-parties-editor'; } from "@/features/crm/components/project-parties-editor";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue,
} from '@/components/ui/select'; } from "@/components/ui/select";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetFooter, SheetFooter,
SheetHeader, SheetHeader,
SheetTitle SheetTitle,
} from '@/components/ui/sheet'; } from "@/components/ui/sheet";
import { Switch } from '@/components/ui/switch'; import { Switch } from "@/components/ui/switch";
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; import { useAppForm, useFormFields } from "@/components/ui/tanstack-form";
import { opportunityProjectPartiesOptions } from '../api/queries'; import { opportunityProjectPartiesOptions } from "../api/queries";
import { createOpportunityMutation, updateOpportunityMutation } from '../api/mutations'; import {
import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenceData } from '../api/types'; createOpportunityMutation,
import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema'; updateOpportunityMutation,
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project'; } from "../api/mutations";
import type {
OpportunityMutationPayload,
OpportunityRecord,
OpportunityReferenceData,
} from "../api/types";
import {
opportunitySchema,
type OpportunityFormValues,
} from "../schemas/opportunity.schema";
import { isHotProjectSuggested } from "@/features/crm/shared/hot-project";
import { resolveOpportunityOption } from "./opportunity-option-resolver";
const CHANCE_PERCENT_OPTIONS = [10, 25, 50, 75, 85] as const; const CHANCE_PERCENT_OPTIONS = [10, 25, 50, 75, 85] as const;
function toDefaultValues( function toDefaultValues(
opportunity: OpportunityRecord | undefined, opportunity: OpportunityRecord | undefined,
referenceData: OpportunityReferenceData referenceData: OpportunityReferenceData,
): OpportunityFormValues { ): OpportunityFormValues {
return { return {
customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? '', customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? "",
contactId: opportunity?.contactId ?? undefined, contactId: opportunity?.contactId ?? undefined,
assignedToUserId: opportunity?.assignedToUserId ?? undefined, assignedToUserId: opportunity?.assignedToUserId ?? undefined,
title: opportunity?.title ?? '', title: opportunity?.title ?? "",
description: opportunity?.description ?? '', description: opportunity?.description ?? "",
requirement: opportunity?.requirement ?? '', requirement: opportunity?.requirement ?? "",
projectName: opportunity?.projectName ?? '', projectName: opportunity?.projectName ?? "",
projectLocation: opportunity?.projectLocation ?? '', projectLocation: opportunity?.projectLocation ?? "",
branchId: opportunity?.branchId ?? undefined, branchId: opportunity?.branchId ?? undefined,
productType: opportunity?.productType ?? referenceData.productTypes[0]?.id ?? '', productType:
status: opportunity?.status ?? referenceData.statuses[0]?.id ?? '', opportunity?.productType ?? referenceData.productTypes[0]?.id ?? "",
status:
(opportunity?.status
? resolveOpportunityOption(referenceData.statuses, opportunity.status)
?.code
: null) ??
referenceData.statuses[0]?.code ??
"",
priority: priority:
opportunity?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '', opportunity?.priority ??
referenceData.priorities[1]?.id ??
referenceData.priorities[0]?.id ??
"",
leadChannel: opportunity?.leadChannel ?? undefined, leadChannel: opportunity?.leadChannel ?? undefined,
estimatedValue: opportunity?.estimatedValue ?? undefined, estimatedValue: opportunity?.estimatedValue ?? undefined,
chancePercent: opportunity?.chancePercent ?? undefined, chancePercent: opportunity?.chancePercent ?? undefined,
expectedCloseDate: opportunity?.expectedCloseDate ? opportunity.expectedCloseDate.slice(0, 10) : '', expectedCloseDate: opportunity?.expectedCloseDate
projectCloseDate: opportunity?.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : '', ? opportunity.expectedCloseDate.slice(0, 10)
deliveryDate: opportunity?.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : '', : "",
competitor: opportunity?.competitor ?? '', projectCloseDate: opportunity?.projectCloseDate
source: opportunity?.source ?? '', ? opportunity.projectCloseDate.slice(0, 10)
isHotProject: opportunity?.isHotProject ?? false, : "",
hotProjectAutoSuggested: opportunity?.hotProjectAutoSuggested ?? false, deliveryDate: opportunity?.deliveryDate
hotProjectManuallyOverridden: opportunity?.hotProjectManuallyOverridden ?? false, ? opportunity.deliveryDate.slice(0, 10)
notes: opportunity?.notes ?? '', : "",
isActive: opportunity?.isActive ?? true competitor: opportunity?.competitor ?? "",
source: opportunity?.source ?? "",
isHotProject: opportunity?.isHotProject ?? false,
hotProjectAutoSuggested: opportunity?.hotProjectAutoSuggested ?? false,
hotProjectManuallyOverridden:
opportunity?.hotProjectManuallyOverridden ?? false,
notes: opportunity?.notes ?? "",
isActive: opportunity?.isActive ?? true,
}; };
} }
@@ -74,32 +102,36 @@ export function OpportunityFormSheet({
open, open,
onOpenChange, onOpenChange,
referenceData, referenceData,
workspace = 'opportunity' workspace = "opportunity",
}: { }: {
opportunity?: OpportunityRecord; opportunity?: OpportunityRecord;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
referenceData: OpportunityReferenceData; referenceData: OpportunityReferenceData;
workspace?: 'lead' | 'opportunity'; workspace?: "lead" | "opportunity";
}) { }) {
const isEdit = !!opportunity; const isEdit = !!opportunity;
const defaultValues = useMemo( const defaultValues = useMemo(
() => toDefaultValues(opportunity, referenceData), () => toDefaultValues(opportunity, referenceData),
[opportunity, referenceData] [opportunity, referenceData],
); );
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId); const [selectedCustomerId, setSelectedCustomerId] = useState(
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]); defaultValues.customerId,
);
const [projectParties, setProjectParties] = useState<
ProjectPartyEditorItem[]
>([]);
const { const {
FormTextField, FormTextField,
FormTextareaField, FormTextareaField,
FormSelectField, FormSelectField,
FormSwitchField, FormSwitchField,
FormDatePickerField, FormDatePickerField,
FormCurrencyField FormCurrencyField,
} = useFormFields<OpportunityFormValues>(); } = useFormFields<OpportunityFormValues>();
const projectPartiesQuery = useQuery({ const projectPartiesQuery = useQuery({
...opportunityProjectPartiesOptions(opportunity?.id ?? ''), ...opportunityProjectPartiesOptions(opportunity?.id ?? ""),
enabled: open && !!opportunity?.id enabled: open && !!opportunity?.id,
}); });
const createMutation = useMutation({ const createMutation = useMutation({
@@ -109,7 +141,11 @@ export function OpportunityFormSheet({
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : `ไม่สามารถสร้าง${recordLabel}ได้`) toast.error(
error instanceof Error
? error.message
: `ไม่สามารถสร้าง${recordLabel}ได้`,
),
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
@@ -119,18 +155,24 @@ export function OpportunityFormSheet({
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : `ไม่สามารถอัปเดต${recordLabel}ได้`) toast.error(
error instanceof Error
? error.message
: `ไม่สามารถอัปเดต${recordLabel}ได้`,
),
}); });
const contactsForCustomer = referenceData.contacts.filter( const contactsForCustomer = referenceData.contacts.filter(
(contact) => contact.customerId === selectedCustomerId (contact) => contact.customerId === selectedCustomerId,
);
const selectedCustomer = referenceData.customers.find(
(customer) => customer.id === selectedCustomerId,
); );
const selectedCustomer = referenceData.customers.find((customer) => customer.id === selectedCustomerId);
const form = useAppForm({ const form = useAppForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmit: opportunitySchema onSubmit: opportunitySchema,
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
const payload: OpportunityMutationPayload = { const payload: OpportunityMutationPayload = {
@@ -148,35 +190,41 @@ export function OpportunityFormSheet({
priority: value.priority, priority: value.priority,
leadChannel: value.leadChannel || null, leadChannel: value.leadChannel || null,
estimatedValue: estimatedValue:
typeof value.estimatedValue === 'number' ? value.estimatedValue : null, typeof value.estimatedValue === "number"
? value.estimatedValue
: null,
chancePercent: chancePercent:
typeof value.chancePercent === 'number' ? value.chancePercent : null, typeof value.chancePercent === "number" ? value.chancePercent : null,
expectedCloseDate: value.expectedCloseDate || null, expectedCloseDate: value.expectedCloseDate || null,
projectCloseDate: value.projectCloseDate || null, projectCloseDate: value.projectCloseDate || null,
deliveryDate: value.deliveryDate || null, deliveryDate: value.deliveryDate || null,
competitor: value.competitor, competitor: value.competitor,
source: value.source, source: value.source,
isHotProject: value.isHotProject ?? false, isHotProject: value.isHotProject ?? false,
hotProjectAutoSuggested: value.hotProjectAutoSuggested ?? false, hotProjectAutoSuggested: value.hotProjectAutoSuggested ?? false,
hotProjectManuallyOverridden: value.hotProjectManuallyOverridden ?? false, hotProjectManuallyOverridden:
notes: value.notes, value.hotProjectManuallyOverridden ?? false,
notes: value.notes,
isActive: value.isActive ?? true, isActive: value.isActive ?? true,
projectParties: projectParties projectParties: projectParties
.filter((item) => item.customerId && item.role) .filter((item) => item.customerId && item.role)
.map((item) => ({ .map((item) => ({
customerId: item.customerId, customerId: item.customerId,
role: item.role, role: item.role,
remark: item.remark || null remark: item.remark || null,
})) })),
}; };
if (isEdit && opportunity) { if (isEdit && opportunity) {
await updateMutation.mutateAsync({ id: opportunity.id, values: payload }); await updateMutation.mutateAsync({
id: opportunity.id,
values: payload,
});
return; return;
} }
await createMutation.mutateAsync(payload); await createMutation.mutateAsync(payload);
} },
}); });
useEffect(() => { useEffect(() => {
@@ -192,9 +240,9 @@ notes: value.notes,
key: item.id, key: item.id,
customerId: item.customerId, customerId: item.customerId,
role: item.role, role: item.role,
remark: item.remark ?? '' remark: item.remark ?? "",
})) }))
: [] : [],
); );
}, [defaultValues, opportunity, form, open, projectPartiesQuery.data?.items]); }, [defaultValues, opportunity, form, open, projectPartiesQuery.data?.items]);
@@ -204,65 +252,102 @@ notes: value.notes,
} }
const ownerUserId = const ownerUserId =
referenceData.customers.find((customer) => customer.id === selectedCustomerId)?.ownerUserId ?? ''; referenceData.customers.find(
(customer) => customer.id === selectedCustomerId,
)?.ownerUserId ?? "";
form.setFieldValue('assignedToUserId', ownerUserId); form.setFieldValue("assignedToUserId", ownerUserId);
}, [form, isEdit, open, referenceData.customers, selectedCustomerId]); }, [form, isEdit, open, referenceData.customers, selectedCustomerId]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
} }
const projectCloseDate = form.getFieldValue('projectCloseDate'); const projectCloseDate = form.getFieldValue("projectCloseDate");
const manuallyOverridden = form.getFieldValue('hotProjectManuallyOverridden'); const manuallyOverridden = form.getFieldValue(
"hotProjectManuallyOverridden",
);
if (manuallyOverridden) { if (manuallyOverridden) {
form.setFieldValue('hotProjectAutoSuggested', isHotProjectSuggested(projectCloseDate)); form.setFieldValue(
return; "hotProjectAutoSuggested",
} isHotProjectSuggested(projectCloseDate),
);
return;
}
const suggested = isHotProjectSuggested(projectCloseDate); const suggested = isHotProjectSuggested(projectCloseDate);
form.setFieldValue('hotProjectAutoSuggested', suggested); form.setFieldValue("hotProjectAutoSuggested", suggested);
form.setFieldValue('isHotProject', suggested); form.setFieldValue("isHotProject", suggested);
}, [form, open, form.state.values.projectCloseDate, form.state.values.hotProjectManuallyOverridden]); }, [
form,
open,
form.state.values.projectCloseDate,
form.state.values.hotProjectManuallyOverridden,
]);
const isPending = createMutation.isPending || updateMutation.isPending; const isPending = createMutation.isPending || updateMutation.isPending;
const recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย'; const recordLabel = workspace === "lead" ? "ลีด" : "โอกาสขาย";
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-4xl'> <SheetContent className="flex flex-col sm:max-w-4xl">
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`}</SheetTitle> <SheetTitle>
{isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`}
</SheetTitle>
<SheetDescription> <SheetDescription>
{workspace === 'lead' {workspace === "lead"
? 'บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย' ? "บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย"
: 'บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา'} : "บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา"}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 overflow-auto'> <div className="flex-1 overflow-auto">
<form.AppForm> <form.AppForm>
<form.Form id='opportunity-form-sheet' className='grid gap-4 md:grid-cols-2'> <form.Form
id="opportunity-form-sheet"
className="grid gap-4 md:grid-cols-2"
>
<FormSelectField
name="status"
label="Document status"
required
options={referenceData.statuses.map((item) => ({
value: item.code,
label: item.label,
}))}
/>
<FormSelectField
name="status"
label="Process Status"
required
options={referenceData.statuses.map((item) => ({
value: item.code,
label: item.label,
}))}
/>
<form.AppField <form.AppField
name='customerId' name="customerId"
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel> *</field.FieldLabel> <field.FieldLabel>
*
</field.FieldLabel>
<Select <Select
value={field.state.value ?? ''} value={field.state.value ?? ""}
onValueChange={(value) => { onValueChange={(value) => {
field.handleChange(value); field.handleChange(value);
field.handleBlur(); field.handleBlur();
setSelectedCustomerId(value); setSelectedCustomerId(value);
form.setFieldValue('contactId', ''); form.setFieldValue("contactId", "");
}} }}
> >
<SelectTrigger aria-label='ลูกค้า'> <SelectTrigger aria-label="ลูกค้า">
<SelectValue placeholder='เลือกลูกค้า' /> <SelectValue placeholder="เลือกลูกค้า" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{referenceData.customers.map((customer) => ( {referenceData.customers.map((customer) => (
@@ -279,31 +364,33 @@ form.setFieldValue('isHotProject', suggested);
/> />
<form.AppField <form.AppField
name='contactId' name="contactId"
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel></field.FieldLabel> <field.FieldLabel></field.FieldLabel>
<Select <Select
value={field.state.value ?? ''} value={field.state.value ?? ""}
onValueChange={(value) => { onValueChange={(value) => {
field.handleChange(value === '__none__' ? '' : value); field.handleChange(value === "__none__" ? "" : value);
field.handleBlur(); field.handleBlur();
}} }}
> >
<SelectTrigger aria-label='ผู้ติดต่อ'> <SelectTrigger aria-label="ผู้ติดต่อ">
<SelectValue placeholder='เลือกผู้ติดต่อ' /> <SelectValue placeholder="เลือกผู้ติดต่อ" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{contactsForCustomer.length ? ( {contactsForCustomer.length ? (
contactsForCustomer.map((contact) => ( contactsForCustomer.map((contact) => (
<SelectItem key={contact.id} value={contact.id}> <SelectItem key={contact.id} value={contact.id}>
{contact.name} {contact.name}
{contact.isPrimary ? ' (ผู้ติดต่อหลัก)' : ''} {contact.isPrimary ? " (ผู้ติดต่อหลัก)" : ""}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
<SelectItem value='__none__'></SelectItem> <SelectItem value="__none__">
</SelectItem>
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -313,108 +400,149 @@ form.setFieldValue('isHotProject', suggested);
)} )}
/> />
<FormSelectField <FormSelectField
name='assignedToUserId' name="assignedToUserId"
label='Suggested Sales Owner' label="Sales Owner"
description={ description={
selectedCustomer?.ownerName selectedCustomer?.ownerName
? `Suggested from customer owner: ${selectedCustomer.ownerName}` ? `Suggested from customer owner: ${selectedCustomer.ownerName}`
: 'Optional sales owner suggestion for this lead or opportunity' : ""
} }
options={referenceData.assignableUsers.map((user) => ({ options={referenceData.assignableUsers.map((user) => ({
value: user.id, value: user.id,
label: user.name label: user.name,
}))} }))}
/> />
<FormTextField {/* <FormTextField
name='title' name="title"
label='ชื่อรายการ' label="ชื่อรายการ"
required required
placeholder='Warehouse crane upgrade opportunity' placeholder="Warehouse crane upgrade opportunity"
/> */}
<FormTextField
name="projectName"
label="ชื่อโครงการ"
placeholder="ระบุชื่อโครงการ"
/> />
<FormTextField <FormTextField
name='projectName' name="projectLocation"
label='ชื่อโครงการ' label="สถานที่โครงการ"
placeholder='Bangna Warehouse Expansion' placeholder="ระบุสถานที่โครงการ"
/> />
<FormTextField <FormTextField
name='projectLocation' name="source"
label='สถานที่โครงการ' label="เอกสารอ้างอิงจาก Lead"
placeholder='Bangkok' placeholder=""
/>
<FormTextField
name='source'
label='ที่มา'
placeholder='Existing customer referral'
/> />
<div className="md:col-span-2 grid gap-4 md:grid-cols-2 rounded-sm">
{/* <FormSwitchField
name="isActive"
label="ใช้งานอยู่"
description="รายการที่ปิดใช้งานจะยังอยู่ในประวัติ แต่ไม่ควรถูกดำเนินการต่อ"
/> */}
</div>
<FormSelectField <FormSelectField
name='branchId' name="productType"
label='สาขา' label="ประเภทสินค้า"
options={referenceData.branches.map((branch) => ({
value: branch.id,
label: branch.name
}))}
/>
<FormSelectField
name='productType'
label='ประเภทสินค้า'
required required
options={referenceData.productTypes.map((item) => ({ options={referenceData.productTypes.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <form.AppField
name='status' name="isHotProject"
label='สถานะ' children={(field) => (
required <field.FieldSet>
options={referenceData.statuses.map((item) => ({ <field.Field orientation="horizontal">
value: item.id, <div className="flex flex-1 flex-col gap-1.5 leading-snug mb-2">
label: item.label <field.FieldLabel className="text-base">
}))}
</field.FieldLabel>
<field.FieldDescription></field.FieldDescription>
</div>
<Switch
checked={Boolean(field.state.value)}
onCheckedChange={(checked) => {
field.handleChange(checked);
field.handleBlur();
form.setFieldValue(
"hotProjectManuallyOverridden",
true,
);
form.setFieldValue(
"hotProjectAutoSuggested",
isHotProjectSuggested(
form.getFieldValue("projectCloseDate"),
),
);
}}
/> />
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
{/* <FormSelectField
name="branchId"
label="สาขา"
options={referenceData.branches.map((branch) => ({
value: branch.id,
label: branch.name,
}))}
/> */}
<div className="md:col-span-2">
<ProjectPartiesEditor
customers={referenceData.customers}
roles={referenceData.projectPartyRoles}
items={projectParties}
onChange={setProjectParties}
/>
</div>
<FormSelectField <FormSelectField
name='priority' name="priority"
label='ความสำคัญ' label="ความสำคัญ"
required required
options={referenceData.priorities.map((item) => ({ options={referenceData.priorities.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='leadChannel' name="leadChannel"
label='ที่มาของลีด' label="ที่มาของลีด"
options={referenceData.leadChannels.map((item) => ({ options={referenceData.leadChannels.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
disabled
/> />
<FormCurrencyField <FormCurrencyField
name='estimatedValue' name="estimatedValue"
label='มูลค่าประมาณการ' label="มูลค่าประมาณการ"
currencyLabel='THB' currencyLabel="THB"
placeholder='2500000' placeholder="2500000"
/> />
<form.AppField <form.AppField
name='chancePercent' name="chancePercent"
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel> %</field.FieldLabel> <field.FieldLabel> %</field.FieldLabel>
<Select <Select
value={ value={
typeof field.state.value === 'number' typeof field.state.value === "number"
? String(field.state.value) ? String(field.state.value)
: '' : ""
} }
onValueChange={(value) => { onValueChange={(value) => {
field.handleChange(value ? Number(value) : undefined); field.handleChange(value ? Number(value) : undefined);
field.handleBlur(); field.handleBlur();
}} }}
> >
<SelectTrigger aria-label='Chance percentage'> <SelectTrigger aria-label="Chance percentage">
<SelectValue placeholder='เลือก Chance %' /> <SelectValue placeholder="เลือก Chance %" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CHANCE_PERCENT_OPTIONS.map((option) => ( {CHANCE_PERCENT_OPTIONS.map((option) => (
@@ -429,84 +557,42 @@ form.setFieldValue('isHotProject', suggested);
</field.FieldSet> </field.FieldSet>
)} )}
/> />
{/* <FormDatePickerField
name="expectedCloseDate"
label="วันที่คาดว่าจะปิดการขาย"
/> */}
<FormDatePickerField <FormDatePickerField
name='expectedCloseDate' name="projectCloseDate"
label='วันที่คาดว่าจะปิดการขาย' label="วันที่สรุปงาน"
/> />
<FormDatePickerField <FormDatePickerField name="deliveryDate" label="วันที่ส่งมอบ" />
name='projectCloseDate' <FormTextField
label='Project Close Date' name="competitor"
label="คู่แข่ง"
placeholder="Other vendor name"
/> />
<FormDatePickerField <div className="md:col-span-2">
name='deliveryDate'
label='Delivery Date'
/>
<FormTextField name='competitor' label='คู่แข่ง' placeholder='Other vendor name' />
<div className='md:col-span-2'>
<FormTextareaField <FormTextareaField
name='description' name="description"
size='md' size="md"
label='รายละเอียด' label="รายละเอียดเบื้องต้นจาก Lead"
placeholder='สรุปภาพรวมของลีดหรือโอกาสขาย' placeholder="รายละเอียดเบื้องต้นจาก Lead"
/> />
</div> </div>
<div className='md:col-span-2'> <div className="md:col-span-2">
<FormTextareaField <FormTextareaField
name='requirement' name="requirement"
size='md' size="md"
label='ความต้องการ' label="ความต้องการเชิงลึก"
placeholder='ความต้องการเชิงเทคนิคหรือเชิงพาณิชย์' placeholder="ความต้องการเชิงลึก"
/> />
</div> </div>
<div className='md:col-span-2'> <div className="md:col-span-2">
<FormTextareaField <FormTextareaField
name='notes' name="notes"
size='md' size="md"
label='หมายเหตุ' label="หมายเหตุ"
placeholder='หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน' placeholder="หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน"
/>
</div>
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
<form.AppField
name='isHotProject'
children={(field) => (
<field.FieldSet>
<field.Field orientation='horizontal'>
<div className='flex flex-1 flex-col gap-1.5 leading-snug'>
<field.FieldLabel className='text-base'></field.FieldLabel>
<field.FieldDescription>
</field.FieldDescription>
</div>
<Switch
checked={Boolean(field.state.value)}
onCheckedChange={(checked) => {
field.handleChange(checked);
field.handleBlur();
form.setFieldValue('hotProjectManuallyOverridden', true);
form.setFieldValue(
'hotProjectAutoSuggested',
isHotProjectSuggested(form.getFieldValue('projectCloseDate'))
);
}}
/>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/>
<FormSwitchField
name='isActive'
label='ใช้งานอยู่'
description='รายการที่ปิดใช้งานจะยังอยู่ในประวัติ แต่ไม่ควรถูกดำเนินการต่อ'
/>
</div>
<div className='md:col-span-2'>
<ProjectPartiesEditor
customers={referenceData.customers}
roles={referenceData.projectPartyRoles}
items={projectParties}
onChange={setProjectParties}
/> />
</div> </div>
</form.Form> </form.Form>
@@ -514,11 +600,19 @@ form.setFieldValue('isHotProject', suggested);
</div> </div>
<SheetFooter> <SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}> <Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button> </Button>
<Button type='submit' form='opportunity-form-sheet' isLoading={isPending}> <Button
<Icons.check className='mr-2 h-4 w-4' /> type="submit"
form="opportunity-form-sheet"
isLoading={isPending}
>
<Icons.check className="mr-2 h-4 w-4" />
{isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`} {isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
</Button> </Button>
</SheetFooter> </SheetFooter>
@@ -529,17 +623,18 @@ form.setFieldValue('isHotProject', suggested);
export function OpportunityFormSheetTrigger({ export function OpportunityFormSheetTrigger({
referenceData, referenceData,
workspace = 'opportunity' workspace = "opportunity",
}: { }: {
referenceData: OpportunityReferenceData; referenceData: OpportunityReferenceData;
workspace?: 'lead' | 'opportunity'; workspace?: "lead" | "opportunity";
}) { }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
return ( return (
<> <>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'เพิ่มลีด' : 'เพิ่มโอกาสขาย'} <Icons.add className="mr-2 h-4 w-4" />{" "}
{workspace === "lead" ? "เพิ่มลีด" : "เพิ่มโอกาสขาย"}
</Button> </Button>
<OpportunityFormSheet <OpportunityFormSheet
open={open} open={open}

View File

@@ -0,0 +1,17 @@
'use client';
import type { OpportunityOption } from '../api/types';
export function resolveOpportunityOption(
options: OpportunityOption[],
storedValue: string | null | undefined
): OpportunityOption | undefined {
if (!storedValue) {
return undefined;
}
return options.find(
(option) =>
option.id === storedValue || option.code === storedValue || option.value === storedValue
);
}

View File

@@ -1,4 +1,4 @@
import { and, eq, inArray, isNull } from 'drizzle-orm'; import { and, eq, isNull } from 'drizzle-orm';
import { crmOpportunities, crmQuotations } from '@/db/schema'; import { crmOpportunities, crmQuotations } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service'; import { auditAction } from '@/features/foundation/audit-log/service';
import { db } from '@/lib/db'; import { db } from '@/lib/db';

View File

@@ -29,9 +29,7 @@ import type {
OpportunityFollowupMutationPayload, OpportunityFollowupMutationPayload,
OpportunityFollowupRecord, OpportunityFollowupRecord,
OpportunityListItem, OpportunityListItem,
OpportunityMarkCancelledPayload,
OpportunityMarkLostPayload, OpportunityMarkLostPayload,
OpportunityMarkNoQuotationPayload,
OpportunityMarkWonPayload, OpportunityMarkWonPayload,
OpportunityMutationPayload, OpportunityMutationPayload,
OpportunityOption, OpportunityOption,
@@ -354,6 +352,43 @@ function splitFilterValue(value?: string) {
: []; : [];
} }
async function resolveOptionFilterValues(
organizationId: string,
category: string,
selectedValues: string[]
) {
if (!selectedValues.length) {
return [];
}
const options = await getActiveOptionsByCategory(category, { organizationId });
const normalizedSelections = new Set(
selectedValues.map((value) => value.trim().toLowerCase()).filter(Boolean)
);
const resolvedValues = new Set(selectedValues);
for (const option of options) {
const optionValue = option.value?.trim().toLowerCase();
const matchesSelection =
normalizedSelections.has(option.id.toLowerCase()) ||
normalizedSelections.has(option.code.toLowerCase()) ||
(!!optionValue && normalizedSelections.has(optionValue));
if (!matchesSelection) {
continue;
}
resolvedValues.add(option.id);
resolvedValues.add(option.code);
if (option.value) {
resolvedValues.add(option.value);
}
}
return [...resolvedValues];
}
async function resolveValidOptionIds(category: string, organizationId: string) { async function resolveValidOptionIds(category: string, organizationId: string) {
const options = await getActiveOptionsByCategory(category, { organizationId }); const options = await getActiveOptionsByCategory(category, { organizationId });
return new Set(options.map((option) => option.id)); return new Set(options.map((option) => option.id));
@@ -388,6 +423,46 @@ async function resolveOptionCodeById(
return options.find((option) => option.id === optionId)?.code ?? null; return options.find((option) => option.id === optionId)?.code ?? null;
} }
async function resolveOptionCode(
organizationId: string,
category: string,
value?: string | null
) {
if (!value) {
return null;
}
const options = await getActiveOptionsByCategory(category, { organizationId });
const normalizedValue = value.trim().toLowerCase();
const matchedOption = options.find((option) => {
const optionValue = option.value?.trim().toLowerCase();
return (
option.id.toLowerCase() === normalizedValue ||
option.code.toLowerCase() === normalizedValue ||
(!!optionValue && optionValue === normalizedValue)
);
});
return matchedOption?.code ?? null;
}
async function assertMasterOptionCodeValue(
organizationId: string,
category: string,
value?: string | null
) {
if (!value) {
return;
}
const resolvedCode = await resolveOptionCode(organizationId, category, value);
if (!resolvedCode) {
throw new AuthError(`Invalid option for ${category}`, 400);
}
}
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
const [customer] = await db const [customer] = await db
.select() .select()
@@ -559,7 +634,7 @@ async function validateOpportunityPayload(
await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId); await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId);
} }
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status); await assertMasterOptionCodeValue(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status);
await assertMasterOptionValue( await assertMasterOptionValue(
organizationId, organizationId,
ENQUIRY_OPTION_CATEGORIES.productType, ENQUIRY_OPTION_CATEGORIES.productType,
@@ -626,7 +701,10 @@ async function validateFollowupPayload(
} }
} }
function buildOpportunityFilters(context: OpportunityAccessContext, filters: OpportunityFilters): SQL[] { async function buildOpportunityFilters(
context: OpportunityAccessContext,
filters: OpportunityFilters
): Promise<SQL[]> {
const pipelineStages = splitFilterValue(filters.pipelineStage); const pipelineStages = splitFilterValue(filters.pipelineStage);
const statuses = splitFilterValue(filters.status); const statuses = splitFilterValue(filters.status);
const stages = splitFilterValue(filters.stage); const stages = splitFilterValue(filters.stage);
@@ -636,14 +714,18 @@ function buildOpportunityFilters(context: OpportunityAccessContext, filters: Opp
const branches = splitFilterValue(filters.branch); const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer); const customers = splitFilterValue(filters.customer);
const lostReasons = splitFilterValue(filters.lostReason); const lostReasons = splitFilterValue(filters.lostReason);
const [statusFilterValues, stageFilterValues] = await Promise.all([
resolveOptionFilterValues(context.organizationId, ENQUIRY_OPTION_CATEGORIES.status, statuses),
resolveOptionFilterValues(context.organizationId, ENQUIRY_OPTION_CATEGORIES.status, stages)
]);
return [ return [
eq(crmOpportunities.organizationId, context.organizationId), eq(crmOpportunities.organizationId, context.organizationId),
isNull(crmOpportunities.deletedAt), isNull(crmOpportunities.deletedAt),
...buildAccessScopedFilters(context), ...buildAccessScopedFilters(context),
...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []), ...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []),
...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []), ...(statusFilterValues.length ? [inArray(crmOpportunities.status, statusFilterValues)] : []),
...(stages.length ? [inArray(crmOpportunities.status, stages)] : []), ...(stageFilterValues.length ? [inArray(crmOpportunities.status, stageFilterValues)] : []),
...(outcomeStatuses.length ? [inArray(crmOpportunities.outcomeStatus, outcomeStatuses)] : []), ...(outcomeStatuses.length ? [inArray(crmOpportunities.outcomeStatus, outcomeStatuses)] : []),
...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []), ...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []),
...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []), ...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []),
@@ -792,7 +874,7 @@ export async function listOpportunities(
): Promise<{ items: OpportunityListItem[]; totalItems: number }> { ): Promise<{ items: OpportunityListItem[]; totalItems: number }> {
const page = filters.page ?? 1; const page = filters.page ?? 1;
const limit = filters.limit ?? 10; const limit = filters.limit ?? 10;
const whereFilters = buildOpportunityFilters(context, filters); const whereFilters = await buildOpportunityFilters(context, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -1160,6 +1242,9 @@ export async function createOpportunity(
await validateOpportunityPayload(organizationId, payload, accessContext); await validateOpportunityPayload(organizationId, payload, accessContext);
const hotProjectState = resolveHotProjectState(payload); const hotProjectState = resolveHotProjectState(payload);
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole); const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
const normalizedStatusCode =
(await resolveOptionCode(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status)) ??
payload.status;
const documentCode = await generateNextDocumentCode({ const documentCode = await generateNextDocumentCode({
organizationId, organizationId,
@@ -1183,7 +1268,7 @@ export async function createOpportunity(
projectName: payload.projectName?.trim() || null, projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null, projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType, productType: payload.productType,
status: payload.status, status: normalizedStatusCode,
priority: payload.priority, priority: payload.priority,
leadChannel: payload.leadChannel ?? null, leadChannel: payload.leadChannel ?? null,
estimatedValue: payload.estimatedValue ?? null, estimatedValue: payload.estimatedValue ?? null,
@@ -1232,6 +1317,9 @@ export async function updateOpportunity(
const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
assertOpportunityOpenForEditing(current); assertOpportunityOpenForEditing(current);
const pipelineStage = await resolvePipelineStageForUpdate(current); const pipelineStage = await resolvePipelineStageForUpdate(current);
const normalizedStatusCode =
(await resolveOptionCode(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status)) ??
payload.status;
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [updated] = await tx const [updated] = await tx
@@ -1246,7 +1334,7 @@ export async function updateOpportunity(
projectName: payload.projectName?.trim() || null, projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null, projectLocation: payload.projectLocation?.trim() || null,
productType: payload.productType, productType: payload.productType,
status: payload.status, status: normalizedStatusCode,
priority: payload.priority, priority: payload.priority,
leadChannel: payload.leadChannel ?? null, leadChannel: payload.leadChannel ?? null,
estimatedValue: payload.estimatedValue ?? null, estimatedValue: payload.estimatedValue ?? null,
@@ -1837,15 +1925,15 @@ export async function syncOpportunityPipelineStageFromQuotationStatus(
return; return;
} }
const nextStage = quotationStatusCode ? 'quotation_created' : opportunity.status; const nextStatus = quotationStatusCode ? 'quotation_created' : opportunity.status;
if (nextStage === opportunity.status && opportunity.pipelineStage === 'opportunity') { if (nextStatus === opportunity.status && opportunity.pipelineStage === 'opportunity') {
return; return;
} }
await db await db
.update(crmOpportunities) .update(crmOpportunities)
.set({ .set({
status: nextStage, status: nextStatus,
pipelineStage: 'opportunity', pipelineStage: 'opportunity',
updatedAt: new Date() updatedAt: new Date()
}) })

View File

@@ -512,6 +512,40 @@ export function QuotationFormSheet({
onSubmit={onSubmit} onSubmit={onSubmit}
className="grid flex-1 gap-4 overflow-auto py-2 md:grid-cols-2" className="grid flex-1 gap-4 overflow-auto py-2 md:grid-cols-2"
> >
<Field label="Document Status">
<Select
value={state.status}
onValueChange={(value) => setField("status", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{referenceData.statuses.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Quotation Type" required>
<Select
value={state.quotationType}
onValueChange={(value) => setField("quotationType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{referenceData.quotationTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Opportunity"> <Field label="Opportunity">
<Select <Select
value={state.opportunityId || "__none__"} value={state.opportunityId || "__none__"}
@@ -573,24 +607,6 @@ export function QuotationFormSheet({
</Select> </Select>
</Field> </Field>
<Field label="Quotation Type" required>
<Select
value={state.quotationType}
onValueChange={(value) => setField("quotationType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{referenceData.quotationTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Quotation Date" required> <Field label="Quotation Date" required>
<CrmDateInput <CrmDateInput
value={state.quotationDate} value={state.quotationDate}
@@ -605,21 +621,21 @@ export function QuotationFormSheet({
/> />
</Field> </Field>
<Field label="Project Close Date"> <Field label="วันที่สรุปงาน">
<CrmDateInput <CrmDateInput
value={state.projectCloseDate} value={state.projectCloseDate}
onChange={(value) => setField("projectCloseDate", value)} onChange={(value) => setField("projectCloseDate", value)}
/> />
</Field> </Field>
<Field label="Delivery Date"> <Field label="วันที่ส่งมอบ">
<CrmDateInput <CrmDateInput
value={state.deliveryDate} value={state.deliveryDate}
onChange={(value) => setField("deliveryDate", value)} onChange={(value) => setField("deliveryDate", value)}
/> />
</Field> </Field>
<Field label="Branch"> {/* <Field label="Branch">
<Select <Select
value={state.branchId || "__none__"} value={state.branchId || "__none__"}
onValueChange={(value) => onValueChange={(value) =>
@@ -638,25 +654,7 @@ export function QuotationFormSheet({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field> */}
<Field label="Status">
<Select
value={state.status}
onValueChange={(value) => setField("status", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{referenceData.statuses.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Currency"> <Field label="Currency">
<Select <Select