commit
This commit is contained in:
@@ -4,14 +4,14 @@ import { NavGroup } from "@/types";
|
||||
{
|
||||
label: "CRM",
|
||||
items: [
|
||||
{
|
||||
title: "My Day",
|
||||
url: "/dashboard/crm/my-day",
|
||||
icon: "calendar",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "crm.activity.read" },
|
||||
},
|
||||
// {
|
||||
// title: "My Day",
|
||||
// url: "/dashboard/crm/my-day",
|
||||
// icon: "calendar",
|
||||
// isActive: false,
|
||||
// items: [],
|
||||
// access: { requireOrg: true, permission: "crm.activity.read" },
|
||||
// },
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/dashboard/crm",
|
||||
@@ -20,14 +20,14 @@ import { NavGroup } from "@/types";
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "crm.dashboard.read" },
|
||||
},
|
||||
{
|
||||
title: "Calendar",
|
||||
url: "/dashboard/crm/calendar",
|
||||
icon: "calendar",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "crm.activity.read" },
|
||||
},
|
||||
// {
|
||||
// title: "Calendar",
|
||||
// url: "/dashboard/crm/calendar",
|
||||
// icon: "calendar",
|
||||
// isActive: false,
|
||||
// items: [],
|
||||
// access: { requireOrg: true, permission: "crm.activity.read" },
|
||||
// },
|
||||
{
|
||||
title: "ลูกค้า",
|
||||
url: "/dashboard/crm/customers",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import type { ActivityTimelineItem } from '../api/types';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { formatDateTime } from "@/lib/date-format";
|
||||
import type { ActivityTimelineItem } from "../api/types";
|
||||
|
||||
interface RecentActivitiesPanelProps {
|
||||
items: ActivityTimelineItem[];
|
||||
@@ -26,9 +32,9 @@ function MetaItem({ label, value }: { label: string; value: string | null }) {
|
||||
|
||||
export function RecentActivitiesPanel({
|
||||
items,
|
||||
title = 'Recent Activities',
|
||||
description = 'Unified view of new activities and legacy follow-up records.',
|
||||
emptyMessage = 'No recent activities yet.'
|
||||
title = "Recent Follow up",
|
||||
description = "Unified view of new activities and legacy follow-up records.",
|
||||
emptyMessage = "No recent activities yet.",
|
||||
}: RecentActivitiesPanelProps) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -36,34 +42,46 @@ export function RecentActivitiesPanel({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<CardContent className="space-y-3">
|
||||
{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}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={`${item.source}-${item.id}`} className='rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='space-y-2'>
|
||||
<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'}>
|
||||
<div
|
||||
key={`${item.source}-${item.id}`}
|
||||
className="rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<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}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{item.statusLabel}</Badge>
|
||||
<Badge variant="outline">{item.statusLabel}</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{item.activityTypeLabel}
|
||||
{item.primaryRecordLabel ? ` • ${item.primaryRecordLabel}` : ''}
|
||||
{item.primaryRecordLabel
|
||||
? ` • ${item.primaryRecordLabel}`
|
||||
: ""}
|
||||
</div>
|
||||
{item.description ? <div className='text-sm'>{item.description}</div> : null}
|
||||
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs'>
|
||||
<MetaItem 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} />
|
||||
{item.description ? (
|
||||
<div className="text-sm">{item.description}</div>
|
||||
) : null}
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<MetaItem
|
||||
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>
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { formatNumber } from '@/lib/number-format';
|
||||
import { customerByIdOptions } from '../api/queries';
|
||||
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';
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RecentActivitiesPanel } from "@/features/crm/activities/components/recent-activities-panel";
|
||||
import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { formatNumber } from "@/lib/number-format";
|
||||
import { customerByIdOptions } from "../api/queries";
|
||||
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 (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">{label}</div>
|
||||
<div className="text-sm">{value || "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +53,7 @@ export function CustomerDetail({
|
||||
canManageContactShares,
|
||||
canManageOwner,
|
||||
relatedOpportunities,
|
||||
relatedQuotations
|
||||
relatedQuotations,
|
||||
}: {
|
||||
customerId: string;
|
||||
referenceData: CustomerReferenceData;
|
||||
@@ -62,112 +74,133 @@ export function CustomerDetail({
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
[referenceData]
|
||||
[referenceData],
|
||||
);
|
||||
const typeMap = useMemo(
|
||||
() => new Map(referenceData.customerTypes.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
[referenceData],
|
||||
);
|
||||
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(
|
||||
() => 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(
|
||||
() => 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(
|
||||
() => 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 status = statusMap.get(customer.customerStatus);
|
||||
const type = typeMap.get(customer.customerType);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild variant='ghost' size='icon'>
|
||||
<Link href='/dashboard/crm/customers'>
|
||||
<Icons.chevronLeft className='h-4 w-4' />
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<Link href="/dashboard/crm/customers">
|
||||
<Icons.chevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className='text-muted-foreground font-mono text-sm'>{customer.code}</span>
|
||||
<CustomerStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
|
||||
{type ? <Badge variant='outline'>{type.label}</Badge> : null}
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{customer.code}
|
||||
</span>
|
||||
<CustomerStatusBadge
|
||||
code={status?.code}
|
||||
label={status?.label ?? "Unknown"}
|
||||
/>
|
||||
{type ? <Badge variant="outline">{type.label}</Badge> : null}
|
||||
{customer.branchId ? (
|
||||
<Badge variant='secondary'>
|
||||
{branchMap.get(customer.branchId) ?? 'Unknown branch'}
|
||||
<Badge variant="secondary">
|
||||
{branchMap.get(customer.branchId) ?? "Unknown branch"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold'>{customer.name}</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{customer.abbr || customer.email || customer.phone || 'รายละเอียดข้อมูลลูกค้า'}
|
||||
<h2 className="text-2xl font-semibold">{customer.name}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{customer.abbr ||
|
||||
customer.email ||
|
||||
customer.phone ||
|
||||
"รายละเอียดข้อมูลลูกค้า"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> แก้ไขลูกค้า
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className="mr-2 h-4 w-4" /> แก้ไขลูกค้า
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardContent className='pt-6'>
|
||||
<RecentActivitiesPanel
|
||||
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'>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs defaultValue="overview" className="gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
||||
{canReadContacts ? <TabsTrigger value='contacts'>ผู้ติดต่อ</TabsTrigger> : null}
|
||||
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
|
||||
<TabsTrigger value='related'>เอกสารที่เกี่ยวข้อง</TabsTrigger>
|
||||
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
|
||||
{canReadContacts ? (
|
||||
<TabsTrigger value="contacts">ผู้ติดต่อ</TabsTrigger>
|
||||
) : null}
|
||||
<TabsTrigger value="followup">Follow up</TabsTrigger>
|
||||
<TabsTrigger value="related">เอกสารที่เกี่ยวข้อง</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลลูกค้า</CardTitle>
|
||||
<CardDescription>ข้อมูลหลักของลูกค้า ที่อยู่ และการจัดประเภทใน CRM</CardDescription>
|
||||
<CardDescription>
|
||||
ข้อมูลหลักของลูกค้า ที่อยู่ และการจัดประเภทใน CRM
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Tax ID' value={customer.taxId} />
|
||||
<FieldItem label='Phone' value={customer.phone} />
|
||||
<FieldItem label='Email' value={customer.email} />
|
||||
<FieldItem label='Website' value={customer.website} />
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<FieldItem label="Tax ID" value={customer.taxId} />
|
||||
<FieldItem label="Phone" value={customer.phone} />
|
||||
<FieldItem label="Email" value={customer.email} />
|
||||
<FieldItem label="Website" value={customer.website} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
value={customer.branchId ? branchMap.get(customer.branchId) : null}
|
||||
label="Branch"
|
||||
value={
|
||||
customer.branchId
|
||||
? branchMap.get(customer.branchId)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem label='ที่มาของลีด' value={leadMap.get(customer.leadChannel ?? '')} />
|
||||
|
||||
<FieldItem
|
||||
label='กลุ่มลูกค้า'
|
||||
value={groupMap.get(customer.customerGroup ?? '')}
|
||||
label="กลุ่มลูกค้า"
|
||||
value={groupMap.get(customer.customerGroup ?? "")}
|
||||
/>
|
||||
<FieldItem
|
||||
label='กลุ่มย่อยลูกค้า'
|
||||
value={subGroupMap.get(customer.customerSubGroup ?? '')}
|
||||
label="กลุ่มย่อยลูกค้า"
|
||||
value={subGroupMap.get(customer.customerSubGroup ?? "")}
|
||||
/>
|
||||
<FieldItem label='ใช้งานอยู่' value={customer.isActive ? 'ใช่' : 'ไม่ใช่'} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='ที่อยู่'
|
||||
label="ใช้งานอยู่"
|
||||
value={customer.isActive ? "ใช่" : "ไม่ใช่"}
|
||||
/>
|
||||
<div className="md:col-span-2 xl:col-span-4">
|
||||
<FieldItem
|
||||
label="ที่อยู่"
|
||||
value={
|
||||
[
|
||||
customer.address,
|
||||
@@ -175,21 +208,21 @@ export function CustomerDetail({
|
||||
customer.district,
|
||||
customer.province,
|
||||
customer.postalCode,
|
||||
customer.country
|
||||
customer.country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ') || null
|
||||
.join(", ") || null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='หมายเหตุ' value={customer.notes} />
|
||||
<div className="md:col-span-2 xl:col-span-4">
|
||||
<FieldItem label="หมายเหตุ" value={customer.notes} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{canReadContacts ? (
|
||||
<TabsContent value='contacts'>
|
||||
<TabsContent value="contacts">
|
||||
<CustomerContactsTab
|
||||
customerId={customerId}
|
||||
canCreate={canManageContacts.create}
|
||||
@@ -201,14 +234,19 @@ export function CustomerDetail({
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
<TabsContent value='activity'>
|
||||
<AuditLogTab
|
||||
items={data.activity}
|
||||
emptyMessage='No audit log recorded yet.'
|
||||
description='System-generated immutable history for this customer.'
|
||||
<TabsContent value="followup">
|
||||
<RecentActivitiesPanel
|
||||
items={data.recentActivities}
|
||||
description="Recent customer-linked follow up records shown alongside existing audit history."
|
||||
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 value='related'>
|
||||
<TabsContent value="related">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>เอกสารที่เกี่ยวข้อง</CardTitle>
|
||||
@@ -217,26 +255,29 @@ export function CustomerDetail({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedOpportunities.length && !relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
{!relatedOpportunities.length &&
|
||||
!relatedQuotations.length ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
|
||||
ยังไม่มีโอกาสขายหรือใบเสนอราคาที่เชื่อมกับลูกค้ารายนี้
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
<div className="space-y-3">
|
||||
{relatedOpportunities.map((opportunity) => (
|
||||
<Link
|
||||
key={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='space-y-1'>
|
||||
<div className='font-medium'>{opportunity.title}</div>
|
||||
<div className='text-muted-foreground font-mono text-xs'>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{opportunity.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground font-mono text-xs">
|
||||
{opportunity.code}
|
||||
</div>
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
<Icons.arrowRight className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@@ -244,16 +285,18 @@ export function CustomerDetail({
|
||||
<Link
|
||||
key={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='space-y-1'>
|
||||
<div className='font-medium'>{quotation.code}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{quotation.code}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatNumber(quotation.totalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
<Icons.arrowRight className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@@ -267,7 +310,7 @@ export function CustomerDetail({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<div className="space-y-6">
|
||||
<CustomerOwnerCard
|
||||
customerId={customerId}
|
||||
referenceData={referenceData}
|
||||
@@ -276,13 +319,27 @@ export function CustomerDetail({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
||||
<CardDescription>
|
||||
ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Created By' value={customer.createdByName ?? customer.createdBy} />
|
||||
<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 className="space-y-4">
|
||||
<FieldItem
|
||||
label="Created By"
|
||||
value={customer.createdByName ?? customer.createdBy}
|
||||
/>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createCustomerMutation, updateCustomerMutation } from '../api/mutations';
|
||||
import type { CustomerMutationPayload, CustomerRecord, CustomerReferenceData } from '../api/types';
|
||||
import { customerSchema, type CustomerFormValues } from '../schemas/customer.schema';
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { useAppForm, useFormFields } from "@/components/ui/tanstack-form";
|
||||
import {
|
||||
createCustomerMutation,
|
||||
updateCustomerMutation,
|
||||
} from "../api/mutations";
|
||||
import type {
|
||||
CustomerMutationPayload,
|
||||
CustomerRecord,
|
||||
CustomerReferenceData,
|
||||
} from "../api/types";
|
||||
import {
|
||||
customerSchema,
|
||||
type CustomerFormValues,
|
||||
} from "../schemas/customer.schema";
|
||||
|
||||
interface CustomerFormSheetProps {
|
||||
customer?: CustomerRecord;
|
||||
@@ -29,30 +39,31 @@ interface CustomerFormSheetProps {
|
||||
|
||||
function toDefaultValues(
|
||||
customer: CustomerRecord | undefined,
|
||||
referenceData: CustomerReferenceData
|
||||
referenceData: CustomerReferenceData,
|
||||
): CustomerFormValues {
|
||||
return {
|
||||
name: customer?.name ?? '',
|
||||
abbr: customer?.abbr ?? '',
|
||||
taxId: customer?.taxId ?? '',
|
||||
customerType: customer?.customerType ?? referenceData.customerTypes[0]?.id ?? '',
|
||||
customerStatus: customer?.customerStatus ?? referenceData.customerStatuses[0]?.id ?? '',
|
||||
name: customer?.name ?? "",
|
||||
abbr: customer?.abbr ?? "",
|
||||
taxId: customer?.taxId ?? "",
|
||||
customerType:
|
||||
customer?.customerType ?? referenceData.customerTypes[0]?.id ?? "",
|
||||
customerStatus:
|
||||
customer?.customerStatus ?? referenceData.customerStatuses[0]?.id ?? "",
|
||||
branchId: customer?.branchId ?? undefined,
|
||||
address: customer?.address ?? '',
|
||||
province: customer?.province ?? '',
|
||||
district: customer?.district ?? '',
|
||||
subDistrict: customer?.subDistrict ?? '',
|
||||
postalCode: customer?.postalCode ?? '',
|
||||
country: customer?.country ?? 'Thailand',
|
||||
phone: customer?.phone ?? '',
|
||||
fax: customer?.fax ?? '',
|
||||
email: customer?.email ?? '',
|
||||
website: customer?.website ?? '',
|
||||
leadChannel: customer?.leadChannel ?? undefined,
|
||||
address: customer?.address ?? "",
|
||||
province: customer?.province ?? "",
|
||||
district: customer?.district ?? "",
|
||||
subDistrict: customer?.subDistrict ?? "",
|
||||
postalCode: customer?.postalCode ?? "",
|
||||
country: customer?.country ?? "Thailand",
|
||||
phone: customer?.phone ?? "",
|
||||
fax: customer?.fax ?? "",
|
||||
email: customer?.email ?? "",
|
||||
website: customer?.website ?? "",
|
||||
customerGroup: customer?.customerGroup ?? undefined,
|
||||
customerSubGroup: customer?.customerSubGroup ?? undefined,
|
||||
notes: customer?.notes ?? '',
|
||||
isActive: customer?.isActive ?? true
|
||||
notes: customer?.notes ?? "",
|
||||
isActive: customer?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,12 +71,12 @@ export function CustomerFormSheet({
|
||||
customer,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
referenceData,
|
||||
}: CustomerFormSheetProps) {
|
||||
const isEdit = !!customer;
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(customer, referenceData),
|
||||
[customer, referenceData]
|
||||
[customer, referenceData],
|
||||
);
|
||||
const { FormTextField, FormSelectField, FormTextareaField, FormSwitchField } =
|
||||
useFormFields<CustomerFormValues>();
|
||||
@@ -73,35 +84,38 @@ export function CustomerFormSheet({
|
||||
const createMutation = useMutation({
|
||||
...createCustomerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('สร้างลูกค้าสำเร็จ');
|
||||
toast.success("สร้างลูกค้าสำเร็จ");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างลูกค้าได้')
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "ไม่สามารถสร้างลูกค้าได้",
|
||||
),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateCustomerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('อัปเดตลูกค้าสำเร็จ');
|
||||
toast.success("อัปเดตลูกค้าสำเร็จ");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถอัปเดตลูกค้าได้')
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "ไม่สามารถอัปเดตลูกค้าได้",
|
||||
),
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: customerSchema
|
||||
onSubmit: customerSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload: CustomerMutationPayload = {
|
||||
...value,
|
||||
branchId: value.branchId || null,
|
||||
leadChannel: value.leadChannel || null,
|
||||
customerGroup: value.customerGroup || null,
|
||||
customerSubGroup: value.customerSubGroup || null
|
||||
customerSubGroup: value.customerSubGroup || null,
|
||||
};
|
||||
|
||||
if (isEdit && customer) {
|
||||
@@ -110,7 +124,7 @@ export function CustomerFormSheet({
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,12 +134,20 @@ export function CustomerFormSheet({
|
||||
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form, open]);
|
||||
const selectedCustomerGroup = useStore(form.store, (state) => state.values.customerGroup);
|
||||
const selectedCustomerSubGroup = useStore(form.store, (state) => state.values.customerSubGroup);
|
||||
const selectedCustomerGroup = useStore(
|
||||
form.store,
|
||||
(state) => state.values.customerGroup,
|
||||
);
|
||||
const selectedCustomerSubGroup = useStore(
|
||||
form.store,
|
||||
(state) => state.values.customerSubGroup,
|
||||
);
|
||||
const filteredCustomerSubGroups = useMemo(
|
||||
() =>
|
||||
referenceData.customerSubGroups.filter((item) => item.parentId === selectedCustomerGroup),
|
||||
[referenceData.customerSubGroups, selectedCustomerGroup]
|
||||
referenceData.customerSubGroups.filter(
|
||||
(item) => item.parentId === selectedCustomerGroup,
|
||||
),
|
||||
[referenceData.customerSubGroups, selectedCustomerGroup],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,10 +155,12 @@ export function CustomerFormSheet({
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = filteredCustomerSubGroups.some((item) => item.id === selectedCustomerSubGroup);
|
||||
const isValid = filteredCustomerSubGroups.some(
|
||||
(item) => item.id === selectedCustomerSubGroup,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
form.setFieldValue('customerSubGroup', '');
|
||||
form.setFieldValue("customerSubGroup", "");
|
||||
}
|
||||
}, [filteredCustomerSubGroups, form, selectedCustomerSubGroup]);
|
||||
|
||||
@@ -144,107 +168,146 @@ export function CustomerFormSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-3xl'>
|
||||
<SheetContent className="flex flex-col sm:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'แก้ไขลูกค้า' : 'เพิ่มลูกค้า'}</SheetTitle>
|
||||
<SheetDescription>จัดการข้อมูลหลักของลูกค้า สาขาที่ดูแล และการจัดประเภทใน CRM</SheetDescription>
|
||||
<SheetTitle>{isEdit ? "แก้ไขลูกค้า" : "เพิ่มลูกค้า"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
จัดการข้อมูลหลักของลูกค้า สาขาที่ดูแล และการจัดประเภทใน CRM
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<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
|
||||
name='name'
|
||||
label='ชื่อลูกค้า'
|
||||
name="name"
|
||||
label="ชื่อลูกค้า"
|
||||
required
|
||||
placeholder='ALLA Service Co., Ltd.'
|
||||
placeholder="ALLA Service Co., Ltd."
|
||||
/>
|
||||
<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 name="abbr" label="ชื่อย่อ" placeholder="ALLA" />
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='อีเมล'
|
||||
type='email'
|
||||
placeholder='sales@example.com'
|
||||
name="taxId"
|
||||
label="เลขประจำตัวผู้เสียภาษี"
|
||||
placeholder="0105556..."
|
||||
/>
|
||||
<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
|
||||
name='customerType'
|
||||
label='ประเภทลูกค้า'
|
||||
name="customerType"
|
||||
label="ประเภทลูกค้า"
|
||||
required
|
||||
options={referenceData.customerTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerStatus'
|
||||
label='สถานะลูกค้า'
|
||||
name="customerStatus"
|
||||
label="สถานะลูกค้า"
|
||||
required
|
||||
options={referenceData.customerStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='สาขา'
|
||||
name="branchId"
|
||||
label="สาขา"
|
||||
options={referenceData.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
label: branch.name,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='leadChannel'
|
||||
label='ที่มาของลีด'
|
||||
options={referenceData.leadChannels.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerGroup'
|
||||
label='กลุ่มลูกค้า'
|
||||
name="customerGroup"
|
||||
label="กลุ่มลูกค้า"
|
||||
options={referenceData.customerGroups.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerSubGroup'
|
||||
label='กลุ่มย่อยลูกค้า'
|
||||
name="customerSubGroup"
|
||||
label="กลุ่มย่อยลูกค้า"
|
||||
disabled={!selectedCustomerGroup}
|
||||
placeholder={
|
||||
selectedCustomerGroup ? 'เลือกกลุ่มย่อยลูกค้า' : 'เลือกกลุ่มลูกค้าก่อน'
|
||||
selectedCustomerGroup
|
||||
? "เลือกกลุ่มย่อยลูกค้า"
|
||||
: "เลือกกลุ่มลูกค้าก่อน"
|
||||
}
|
||||
options={filteredCustomerSubGroups.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextField name='address' label='ที่อยู่' placeholder='123 Main Road' />
|
||||
</div>
|
||||
<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 className="md:col-span-2">
|
||||
<FormTextField
|
||||
name="address"
|
||||
label="ที่อยู่"
|
||||
placeholder="123 Main Road"
|
||||
/>
|
||||
</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
|
||||
name='isActive'
|
||||
label='ใช้งานอยู่'
|
||||
description='ลูกค้าที่ปิดใช้งานยังคงอยู่ในประวัติ แต่ไม่ควรนำไปใช้กับงานใหม่'
|
||||
name="isActive"
|
||||
label="ใช้งานอยู่"
|
||||
description="ลูกค้าที่ปิดใช้งานยังคงอยู่ในประวัติ แต่ไม่ควรนำไปใช้กับงานใหม่"
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
@@ -252,12 +315,20 @@ export function CustomerFormSheet({
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type='submit' form='customer-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'อัปเดตลูกค้า' : 'สร้างลูกค้า'}
|
||||
<Button
|
||||
type="submit"
|
||||
form="customer-form-sheet"
|
||||
isLoading={isPending}
|
||||
>
|
||||
<Icons.check className="mr-2 h-4 w-4" />
|
||||
{isEdit ? "อัปเดตลูกค้า" : "สร้างลูกค้า"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -266,7 +337,7 @@ export function CustomerFormSheet({
|
||||
}
|
||||
|
||||
export function CustomerFormSheetTrigger({
|
||||
referenceData
|
||||
referenceData,
|
||||
}: {
|
||||
referenceData: CustomerReferenceData;
|
||||
}) {
|
||||
@@ -275,9 +346,13 @@ export function CustomerFormSheetTrigger({
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> เพิ่มลูกค้า
|
||||
<Icons.add className="mr-2 h-4 w-4" /> เพิ่มลูกค้า
|
||||
</Button>
|
||||
<CustomerFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
<CustomerFormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import { formatDate } from '@/lib/date-format';
|
||||
import { formatNumber } from '@/lib/number-format';
|
||||
import type { LeadReferenceData, LeadSummary } from '../api/types';
|
||||
import { LeadCellAction } from './lead-cell-action';
|
||||
import Link from "next/link";
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { formatNumber } from "@/lib/number-format";
|
||||
import type { LeadReferenceData, LeadSummary } from "../api/types";
|
||||
import { LeadCellAction } from "./lead-cell-action";
|
||||
|
||||
export function getLeadColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign
|
||||
canAssign,
|
||||
}: {
|
||||
referenceData: LeadReferenceData;
|
||||
canUpdate: boolean;
|
||||
@@ -23,142 +23,173 @@ export function getLeadColumns({
|
||||
}): ColumnDef<LeadSummary>[] {
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
id: "code",
|
||||
accessorKey: "code",
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Lead Code' />
|
||||
<DataTableColumnHeader column={column} title="Lead Code" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.customerName ?? '-'}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.customerName ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Lead Code',
|
||||
placeholder: 'Search leads...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
label: "Lead Code",
|
||||
placeholder: "Search leads...",
|
||||
variant: "text" as const,
|
||||
icon: Icons.search,
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
id: "status",
|
||||
accessorFn: (row) => row.status,
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
<DataTableColumnHeader column={column} title="Document Status" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='outline'>{row.original.statusLabel ?? row.original.status}</Badge>
|
||||
<Badge variant="outline">
|
||||
{row.original.statusLabel ?? row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'select' as const,
|
||||
label: "Document Status",
|
||||
variant: "select" as const,
|
||||
options: referenceData.statuses.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.id
|
||||
}))
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'branchId',
|
||||
accessorFn: (row) => row.branchId ?? '',
|
||||
id: "process_status",
|
||||
accessorFn: (row) => row.status,
|
||||
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 }) => {
|
||||
const branch = referenceData.branches.find((item) => item.id === row.original.branchId);
|
||||
return <span>{branch?.name ?? '-'}</span>;
|
||||
const branch = referenceData.branches.find(
|
||||
(item) => item.id === row.original.branchId,
|
||||
);
|
||||
return <span>{branch?.name ?? "-"}</span>;
|
||||
},
|
||||
meta: {
|
||||
label: 'Branch',
|
||||
variant: 'select' as const,
|
||||
label: "Branch",
|
||||
variant: "select" as const,
|
||||
options: referenceData.branches.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}))
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'customerId',
|
||||
accessorFn: (row) => row.customerId ?? '',
|
||||
id: "customerId",
|
||||
accessorFn: (row) => row.customerId ?? "",
|
||||
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: {
|
||||
label: 'Customer',
|
||||
variant: 'select' as const,
|
||||
label: "Customer",
|
||||
variant: "select" as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.id
|
||||
}))
|
||||
value: item.id,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'projectName',
|
||||
accessorKey: 'projectName',
|
||||
id: "projectName",
|
||||
accessorKey: "projectName",
|
||||
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',
|
||||
accessorFn: (row) => row.priority ?? '',
|
||||
id: "priority",
|
||||
accessorFn: (row) => row.priority ?? "",
|
||||
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',
|
||||
accessorFn: (row) => row.suggestedSalesOwnerId ?? '',
|
||||
id: "suggestedSalesOwner",
|
||||
accessorFn: (row) => row.suggestedSalesOwnerId ?? "",
|
||||
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>
|
||||
},
|
||||
{
|
||||
id: 'assignedSalesOwner',
|
||||
accessorFn: (row) => row.assignedSalesOwnerId ?? '',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Assigned Owner' />
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.suggestedSalesOwnerName ?? "-"}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedSalesOwnerName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
id: "assignedSalesOwner",
|
||||
accessorFn: (row) => row.assignedSalesOwnerId ?? "",
|
||||
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 }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null
|
||||
? formatNumber(row.original.estimatedValue)
|
||||
: '-'}
|
||||
: "-"}
|
||||
</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 }) => (
|
||||
<LeadCellAction
|
||||
data={row.original}
|
||||
@@ -167,7 +198,7 @@ export function getLeadColumns({
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -195,6 +195,22 @@ export function LeadForm({
|
||||
<div className="flex-1 overflow-auto">
|
||||
<form.AppForm>
|
||||
<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
|
||||
name="customerId"
|
||||
children={(field) => (
|
||||
@@ -326,25 +342,8 @@ export function LeadForm({
|
||||
currencyLabel="THB"
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
name="followupStatus"
|
||||
label="Follow-up Status"
|
||||
options={referenceData.followupStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{isEdit ? (
|
||||
<>
|
||||
<FormSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name="lostReason"
|
||||
label="Lost Reason"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import { formatDate } from '@/lib/date-format';
|
||||
import { formatNumber } from '@/lib/number-format';
|
||||
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
|
||||
import { OpportunityCellAction } from './opportunity-cell-action';
|
||||
import { OpportunityStatusBadge } from './opportunity-status-badge';
|
||||
import Link from "next/link";
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { formatNumber } from "@/lib/number-format";
|
||||
import type {
|
||||
OpportunityListItem,
|
||||
OpportunityReferenceData,
|
||||
} from "../api/types";
|
||||
import { OpportunityCellAction } from "./opportunity-cell-action";
|
||||
import { resolveOpportunityOption } from "./opportunity-option-resolver";
|
||||
import { OpportunityStatusBadge } from "./opportunity-status-badge";
|
||||
|
||||
type SharedColumnsConfig = {
|
||||
referenceData: OpportunityReferenceData;
|
||||
@@ -24,96 +28,119 @@ function getLeadColumns({
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
canReassign,
|
||||
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
||||
const leadChannelMap = new Map(
|
||||
referenceData.leadChannels.map((item) => [item.id, item.label]),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
id: "code",
|
||||
accessorKey: "code",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Code" />,
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.title}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Code',
|
||||
placeholder: 'Search leads...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
label: "Code",
|
||||
placeholder: "Search leads...",
|
||||
variant: "text" as const,
|
||||
icon: Icons.search,
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'leadChannel',
|
||||
accessorFn: (row) => row.leadChannel ?? '',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Lead Source' />
|
||||
id: "leadChannel",
|
||||
accessorFn: (row) => row.leadChannel ?? "",
|
||||
header: ({
|
||||
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,
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Customer" />,
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
variant: 'select' as const,
|
||||
label: "Customer",
|
||||
variant: "select" as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.name} (${item.code})`
|
||||
}))
|
||||
label: `${item.name} (${item.code})`,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
id: "status",
|
||||
accessorFn: (row) =>
|
||||
resolveOpportunityOption(referenceData.statuses, row.status)?.code ??
|
||||
row.status,
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
const status = statusMap.get(row.original.status);
|
||||
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||
const status = resolveOpportunityOption(
|
||||
referenceData.statuses,
|
||||
row.original.status,
|
||||
);
|
||||
return (
|
||||
<OpportunityStatusBadge
|
||||
code={status?.code}
|
||||
label={status?.label ?? "Unknown"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'multiSelect' as const,
|
||||
label: "Status",
|
||||
variant: "multiSelect" as const,
|
||||
options: referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
value: item.code,
|
||||
label: item.label,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<OpportunityCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='lead'
|
||||
workspace="lead"
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -122,198 +149,277 @@ function getOpportunityWorkspaceColumns({
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
canReassign,
|
||||
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label]));
|
||||
const productTypeMap = new Map(
|
||||
referenceData.productTypes.map((item) => [item.id, item.label]),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Opportunity Code' />
|
||||
),
|
||||
id: "code",
|
||||
accessorKey: "code",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Opportunity Code" />,
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
href={`/dashboard/crm/opportunities/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.title}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Opportunity',
|
||||
placeholder: 'Search opportunities...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
label: "Opportunity",
|
||||
placeholder: "Search opportunities...",
|
||||
variant: "text" as const,
|
||||
icon: Icons.search,
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'sourceLead',
|
||||
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'),
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Source Lead' />
|
||||
),
|
||||
id: "status",
|
||||
accessorFn: (row) =>
|
||||
resolveOpportunityOption(referenceData.statuses, row.status)?.code ??
|
||||
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 }) =>
|
||||
row.original.leadId ? (
|
||||
<Badge variant='outline'>
|
||||
{row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'}
|
||||
<Badge variant="outline">
|
||||
{row.original.source ??
|
||||
row.original.sourceLeadCode ??
|
||||
"Lead linked"}
|
||||
</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,
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Customer" />,
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
variant: 'select' as const,
|
||||
label: "Customer",
|
||||
variant: "select" as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.name} (${item.code})`
|
||||
}))
|
||||
label: `${item.name} (${item.code})`,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'projectName',
|
||||
accessorFn: (row) => row.projectName ?? '',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Project Name' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span>
|
||||
id: "projectName",
|
||||
accessorFn: (row) => row.projectName ?? "",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
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,
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Product Type' />
|
||||
),
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Product Type" />,
|
||||
cell: ({ row }) => (
|
||||
<span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>
|
||||
<span>
|
||||
{productTypeMap.get(row.original.productType) ??
|
||||
row.original.productType}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
label: 'Product Type',
|
||||
variant: 'multiSelect' as const,
|
||||
label: "Product Type",
|
||||
variant: "multiSelect" as const,
|
||||
options: referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
label: item.label,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: 'assignedToName',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Sales Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
||||
id: "assignedToName",
|
||||
accessorFn: (row) => row.assignedToName ?? "",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Sales Owner" />,
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? "-"}</span>,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = statusMap.get(row.original.status);
|
||||
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' />
|
||||
),
|
||||
id: "estimatedValue",
|
||||
accessorKey: "estimatedValue",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Estimated Value" />,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null
|
||||
? formatNumber(row.original.estimatedValue)
|
||||
: '-'}
|
||||
: "-"}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'chancePercent',
|
||||
accessorKey: 'chancePercent',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Chance %' />
|
||||
),
|
||||
id: "chancePercent",
|
||||
accessorKey: "chancePercent",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Chance %" />,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||
)
|
||||
<span>
|
||||
{row.original.chancePercent !== null
|
||||
? `${row.original.chancePercent}%`
|
||||
: "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expectedCloseDate',
|
||||
accessorKey: 'expectedCloseDate',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Expected Close' />
|
||||
),
|
||||
id: "expectedCloseDate",
|
||||
accessorKey: "expectedCloseDate",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Expected Close" />,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.expectedCloseDate
|
||||
? formatDate(row.original.expectedCloseDate)
|
||||
: '-'}
|
||||
: "-"}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nextFollowupDate',
|
||||
accessorKey: 'nextFollowupDate',
|
||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||
),
|
||||
id: "nextFollowupDate",
|
||||
accessorKey: "nextFollowupDate",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: Column<OpportunityListItem, unknown>;
|
||||
}) => <DataTableColumnHeader column={column} title="Next Follow-up" />,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.nextFollowupDate
|
||||
? formatDate(row.original.nextFollowupDate)
|
||||
: '-'}
|
||||
: "-"}
|
||||
</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 }) => (
|
||||
<OpportunityCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='opportunity'
|
||||
workspace="opportunity"
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -323,10 +429,18 @@ export function getOpportunityColumns({
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] {
|
||||
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
||||
return workspace === 'lead'
|
||||
canReassign,
|
||||
}: SharedColumnsConfig & {
|
||||
workspace: "lead" | "opportunity";
|
||||
}): ColumnDef<OpportunityListItem>[] {
|
||||
const sharedConfig = {
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign,
|
||||
};
|
||||
return workspace === "lead"
|
||||
? getLeadColumns(sharedConfig)
|
||||
: getOpportunityWorkspaceColumns(sharedConfig);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,59 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { 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 Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { 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 {
|
||||
QuotationReferenceData,
|
||||
QuotationRelationItem
|
||||
} from '@/features/crm/quotations/api/types';
|
||||
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
|
||||
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
||||
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries';
|
||||
import type { OpportunityRecord, OpportunityReferenceData } from '../api/types';
|
||||
import { OpportunityAssignmentDialog } from './opportunity-assignment-dialog';
|
||||
import { OpportunityFollowupsTab } from './opportunity-followups-tab';
|
||||
import { OpportunityFormSheet } from './opportunity-form-sheet';
|
||||
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';
|
||||
QuotationRelationItem,
|
||||
} from "@/features/crm/quotations/api/types";
|
||||
import { QuotationFormSheet } from "@/features/crm/quotations/components/quotation-form-sheet";
|
||||
import { getPipelineStageThaiLabel } from "@/features/crm/shared/terminology";
|
||||
import {
|
||||
opportunityByIdOptions,
|
||||
opportunityProjectPartiesOptions,
|
||||
} from "../api/queries";
|
||||
import type { OpportunityRecord, OpportunityReferenceData } from "../api/types";
|
||||
import { OpportunityAssignmentDialog } from "./opportunity-assignment-dialog";
|
||||
import { OpportunityFollowupsTab } from "./opportunity-followups-tab";
|
||||
import { OpportunityFormSheet } from "./opportunity-form-sheet";
|
||||
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 (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">{label}</div>
|
||||
<div className="text-sm">{value || "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPipelineStageLabel(stage: OpportunityRecord['pipelineStage']) {
|
||||
function getPipelineStageLabel(stage: OpportunityRecord["pipelineStage"]) {
|
||||
return getPipelineStageThaiLabel(stage);
|
||||
}
|
||||
|
||||
@@ -45,7 +61,7 @@ interface OpportunityDetailProps {
|
||||
opportunityId: string;
|
||||
referenceData: OpportunityReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
workspace: 'lead' | 'opportunity';
|
||||
workspace: "lead" | "opportunity";
|
||||
canUpdate: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
@@ -80,11 +96,11 @@ export function OpportunityDetail({
|
||||
canCreateQuotation,
|
||||
canManageFollowups,
|
||||
sourceLead = null,
|
||||
quotationReferenceData = null
|
||||
quotationReferenceData = null,
|
||||
}: OpportunityDetailProps) {
|
||||
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
|
||||
const { data: projectPartiesData } = useSuspenseQuery(
|
||||
opportunityProjectPartiesOptions(opportunityId)
|
||||
opportunityProjectPartiesOptions(opportunityId),
|
||||
);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||
@@ -93,61 +109,70 @@ export function OpportunityDetail({
|
||||
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
[referenceData]
|
||||
[referenceData],
|
||||
);
|
||||
const customerMap = useMemo(
|
||||
() => new Map(referenceData.customers.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
[referenceData],
|
||||
);
|
||||
const contactMap = useMemo(
|
||||
() => new Map(referenceData.contacts.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const statusMap = useMemo(
|
||||
() => new Map(referenceData.statuses.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
[referenceData],
|
||||
);
|
||||
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 contact = opportunity.contactId ? contactMap.get(opportunity.contactId) : null;
|
||||
const status = statusMap.get(opportunity.status);
|
||||
const contact = opportunity.contactId
|
||||
? contactMap.get(opportunity.contactId)
|
||||
: null;
|
||||
const status = resolveOpportunityOption(
|
||||
referenceData.statuses,
|
||||
opportunity.status,
|
||||
);
|
||||
const pipelineStageLabel = getPipelineStageLabel(opportunity.pipelineStage);
|
||||
const canManageAssignment = opportunity.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = opportunity.assignedToUserId ? 'reassign' : 'assign';
|
||||
const canManageAssignment = opportunity.assignedToUserId
|
||||
? canReassign
|
||||
: canAssign;
|
||||
const assignmentMode = opportunity.assignedToUserId ? "reassign" : "assign";
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className="space-y-6">
|
||||
<OpportunityFormSheet
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
@@ -171,62 +196,78 @@ remark: item.remark ?? ''
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-semibold">{opportunity.code}</h2>
|
||||
<OpportunityStatusBadge
|
||||
code={status?.code}
|
||||
label={status?.label ?? opportunity.status}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Opportunity detail for sales execution, follow-up continuity, and
|
||||
quotation readiness.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canCreateQuotation ? (
|
||||
<Button onClick={() => setQuotationOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
<Icons.add className="mr-2 h-4 w-4" />
|
||||
Create Quotation
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceLead ? (
|
||||
<Button variant='outline' asChild>
|
||||
<Link href={`/dashboard/crm/leads/${sourceLead.id}`}>Open Source Lead</Link>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/dashboard/crm/leads/${sourceLead.id}`}>
|
||||
Open Source Lead
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
{assignmentMode === 'assign' ? 'Assign Opportunity' : 'Reassign Opportunity'}
|
||||
<Button variant="outline" onClick={() => setAssignmentOpen(true)}>
|
||||
{assignmentMode === "assign"
|
||||
? "Assign Opportunity"
|
||||
: "Reassign Opportunity"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Button variant="outline" onClick={() => setEditOpen(true)}>
|
||||
Edit Opportunity
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<div className='space-y-6 xl:col-span-2'>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="space-y-6 xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Opportunity Summary</CardTitle>
|
||||
<CardDescription>Core sales execution snapshot for this opportunity.</CardDescription>
|
||||
<CardDescription>
|
||||
Core sales execution snapshot for this opportunity.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<FieldItem label='Customer' value={customer?.name} />
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem label='Project Name' value={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} />
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<FieldItem label="Customer" value={customer?.name} />
|
||||
<FieldItem label="Contact" value={contact?.name} />
|
||||
<FieldItem label="Project Name" value={opportunity.projectName} />
|
||||
<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={
|
||||
opportunity.expectedCloseDate
|
||||
? formatDate(opportunity.expectedCloseDate)
|
||||
@@ -234,7 +275,7 @@ remark: item.remark ?? ''
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Project Close Date'
|
||||
label="Project Close Date"
|
||||
value={
|
||||
opportunity.projectCloseDate
|
||||
? formatDate(opportunity.projectCloseDate)
|
||||
@@ -242,11 +283,15 @@ remark: item.remark ?? ''
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Delivery Date'
|
||||
value={opportunity.deliveryDate ? formatDate(opportunity.deliveryDate) : null}
|
||||
label="Delivery Date"
|
||||
value={
|
||||
opportunity.deliveryDate
|
||||
? formatDate(opportunity.deliveryDate)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Estimated Value'
|
||||
label="Estimated Value"
|
||||
value={
|
||||
opportunity.estimatedValue !== null
|
||||
? formatNumber(opportunity.estimatedValue)
|
||||
@@ -255,136 +300,180 @@ remark: item.remark ?? ''
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SourceLeadCard sourceLead={sourceLead} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customer / Contact</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldItem label='Customer Code' value={customer?.code} />
|
||||
<FieldItem label='Customer Owner' value={customer?.ownerName} />
|
||||
<FieldItem label='Contact Name' value={contact?.name} />
|
||||
<FieldItem label='Contact Email' value={contact?.email} />
|
||||
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
value={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>
|
||||
|
||||
<Tabs defaultValue="followups" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="followups">Follow-up</TabsTrigger>
|
||||
<TabsTrigger value="customer">Customer / Contact</TabsTrigger>
|
||||
<TabsTrigger value="lead">Lead</TabsTrigger>
|
||||
<TabsTrigger value="requirement">Requirement</TabsTrigger>
|
||||
<TabsTrigger value="sales">Sales Qualification</TabsTrigger>
|
||||
<TabsTrigger value="quotations">Linked Quotations</TabsTrigger>
|
||||
{/* <TabsTrigger value="activity">Audit Log</TabsTrigger> */}
|
||||
</TabsList>
|
||||
<TabsContent value="followups">
|
||||
<RecentActivitiesPanel
|
||||
items={data.recentActivities}
|
||||
description='New activity records and legacy opportunity follow-ups shown together.'
|
||||
emptyMessage='No recent activity or opportunity follow-up yet.'
|
||||
description="New activity records and legacy opportunity follow-ups shown together."
|
||||
emptyMessage="No recent activity or opportunity follow-up yet."
|
||||
/>
|
||||
|
||||
<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
|
||||
{/* <OpportunityFollowupsTab
|
||||
opportunityId={opportunityId}
|
||||
customerId={opportunity.customerId}
|
||||
referenceData={referenceData}
|
||||
canCreate={canManageFollowups.create}
|
||||
canUpdate={canManageFollowups.update}
|
||||
canDelete={canManageFollowups.delete}
|
||||
/>
|
||||
/> */}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='quotations'>
|
||||
<TabsContent value="customer">
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Quotations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<CardContent className="space-y-3">
|
||||
{!canViewRelatedQuotations ? (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Quotation access is restricted.
|
||||
</div>
|
||||
) : relatedQuotations.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No quotations created yet.</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No quotations created yet.
|
||||
</div>
|
||||
) : (
|
||||
relatedQuotations.map((quotation) => (
|
||||
<div key={quotation.id} className='border-border rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div
|
||||
key={quotation.id}
|
||||
className="border-border rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||
className='font-medium hover:underline'
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{quotation.code}
|
||||
</Link>
|
||||
<Badge variant='outline'>{quotation.status}</Badge>
|
||||
<Badge variant="outline">{quotation.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-sm">
|
||||
{quotation.quotationType}
|
||||
</div>
|
||||
<div className='mt-1 text-sm'>{quotation.quotationType}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<TabsContent value="sales">
|
||||
<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
|
||||
items={data.activity}
|
||||
emptyMessage='No audit log recorded yet.'
|
||||
description='System-generated immutable history for this opportunity and its follow-ups.'
|
||||
emptyMessage="No audit log recorded yet."
|
||||
description="System-generated immutable history for this opportunity and its follow-ups."
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<div className="space-y-6">
|
||||
<QuotationReadinessPanel opportunity={opportunity} />
|
||||
|
||||
<OpportunityOutcomeCard
|
||||
@@ -396,32 +485,44 @@ remark: item.remark ?? ''
|
||||
canReopen={canReopen}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quotation Context</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
|
||||
<FieldItem label='Project Parties' value={String(projectPartiesData.items.length)} />
|
||||
<CardContent className="space-y-3">
|
||||
<FieldItem
|
||||
label='Record Type'
|
||||
value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'}
|
||||
label="Related Quotations"
|
||||
value={String(relatedQuotations.length)}
|
||||
/>
|
||||
<FieldItem
|
||||
label="Project Parties"
|
||||
value={String(projectPartiesData.items.length)}
|
||||
/>
|
||||
<FieldItem
|
||||
label="Record Type"
|
||||
value={workspace === "opportunity" ? "Opportunity" : "Lead"}
|
||||
/>
|
||||
{relatedQuotations.length ? (
|
||||
<div className='space-y-2'>
|
||||
<div className="space-y-2">
|
||||
{relatedQuotations.slice(0, 3).map((quotation) => (
|
||||
<div key={quotation.id} className='rounded-lg border p-3 text-sm'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div
|
||||
key={quotation.id}
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||
className='font-medium hover:underline'
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{quotation.code}
|
||||
</Link>
|
||||
<Badge variant='outline'>Rev {quotation.revision}</Badge>
|
||||
<Badge variant="outline">
|
||||
Rev {quotation.revision}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1'>
|
||||
{quotation.status} · {formatNumber(quotation.totalAmount)}{' '}
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{quotation.status} ·{" "}
|
||||
{formatNumber(quotation.totalAmount)}{" "}
|
||||
{quotation.currency}
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +530,7 @@ remark: item.remark ?? ''
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,71 +1,99 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ProjectPartiesEditor,
|
||||
type ProjectPartyEditorItem
|
||||
} from '@/features/crm/components/project-parties-editor';
|
||||
type ProjectPartyEditorItem,
|
||||
} from "@/features/crm/components/project-parties-editor";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { opportunityProjectPartiesOptions } from '../api/queries';
|
||||
import { createOpportunityMutation, updateOpportunityMutation } 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';
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAppForm, useFormFields } from "@/components/ui/tanstack-form";
|
||||
import { opportunityProjectPartiesOptions } from "../api/queries";
|
||||
import {
|
||||
createOpportunityMutation,
|
||||
updateOpportunityMutation,
|
||||
} 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;
|
||||
|
||||
function toDefaultValues(
|
||||
opportunity: OpportunityRecord | undefined,
|
||||
referenceData: OpportunityReferenceData
|
||||
referenceData: OpportunityReferenceData,
|
||||
): OpportunityFormValues {
|
||||
return {
|
||||
customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||
customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? "",
|
||||
contactId: opportunity?.contactId ?? undefined,
|
||||
assignedToUserId: opportunity?.assignedToUserId ?? undefined,
|
||||
title: opportunity?.title ?? '',
|
||||
description: opportunity?.description ?? '',
|
||||
requirement: opportunity?.requirement ?? '',
|
||||
projectName: opportunity?.projectName ?? '',
|
||||
projectLocation: opportunity?.projectLocation ?? '',
|
||||
title: opportunity?.title ?? "",
|
||||
description: opportunity?.description ?? "",
|
||||
requirement: opportunity?.requirement ?? "",
|
||||
projectName: opportunity?.projectName ?? "",
|
||||
projectLocation: opportunity?.projectLocation ?? "",
|
||||
branchId: opportunity?.branchId ?? undefined,
|
||||
productType: opportunity?.productType ?? referenceData.productTypes[0]?.id ?? '',
|
||||
status: opportunity?.status ?? referenceData.statuses[0]?.id ?? '',
|
||||
productType:
|
||||
opportunity?.productType ?? referenceData.productTypes[0]?.id ?? "",
|
||||
status:
|
||||
(opportunity?.status
|
||||
? resolveOpportunityOption(referenceData.statuses, opportunity.status)
|
||||
?.code
|
||||
: null) ??
|
||||
referenceData.statuses[0]?.code ??
|
||||
"",
|
||||
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,
|
||||
estimatedValue: opportunity?.estimatedValue ?? undefined,
|
||||
chancePercent: opportunity?.chancePercent ?? undefined,
|
||||
expectedCloseDate: opportunity?.expectedCloseDate ? opportunity.expectedCloseDate.slice(0, 10) : '',
|
||||
projectCloseDate: opportunity?.projectCloseDate ? opportunity.projectCloseDate.slice(0, 10) : '',
|
||||
deliveryDate: opportunity?.deliveryDate ? opportunity.deliveryDate.slice(0, 10) : '',
|
||||
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
|
||||
estimatedValue: opportunity?.estimatedValue ?? undefined,
|
||||
chancePercent: opportunity?.chancePercent ?? undefined,
|
||||
expectedCloseDate: opportunity?.expectedCloseDate
|
||||
? opportunity.expectedCloseDate.slice(0, 10)
|
||||
: "",
|
||||
projectCloseDate: opportunity?.projectCloseDate
|
||||
? opportunity.projectCloseDate.slice(0, 10)
|
||||
: "",
|
||||
deliveryDate: opportunity?.deliveryDate
|
||||
? opportunity.deliveryDate.slice(0, 10)
|
||||
: "",
|
||||
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,
|
||||
onOpenChange,
|
||||
referenceData,
|
||||
workspace = 'opportunity'
|
||||
workspace = "opportunity",
|
||||
}: {
|
||||
opportunity?: OpportunityRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: OpportunityReferenceData;
|
||||
workspace?: 'lead' | 'opportunity';
|
||||
workspace?: "lead" | "opportunity";
|
||||
}) {
|
||||
const isEdit = !!opportunity;
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(opportunity, referenceData),
|
||||
[opportunity, referenceData]
|
||||
[opportunity, referenceData],
|
||||
);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
||||
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState(
|
||||
defaultValues.customerId,
|
||||
);
|
||||
const [projectParties, setProjectParties] = useState<
|
||||
ProjectPartyEditorItem[]
|
||||
>([]);
|
||||
const {
|
||||
FormTextField,
|
||||
FormTextareaField,
|
||||
FormSelectField,
|
||||
FormSwitchField,
|
||||
FormDatePickerField,
|
||||
FormCurrencyField
|
||||
FormCurrencyField,
|
||||
} = useFormFields<OpportunityFormValues>();
|
||||
const projectPartiesQuery = useQuery({
|
||||
...opportunityProjectPartiesOptions(opportunity?.id ?? ''),
|
||||
enabled: open && !!opportunity?.id
|
||||
...opportunityProjectPartiesOptions(opportunity?.id ?? ""),
|
||||
enabled: open && !!opportunity?.id,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -109,7 +141,11 @@ export function OpportunityFormSheet({
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : `ไม่สามารถสร้าง${recordLabel}ได้`)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `ไม่สามารถสร้าง${recordLabel}ได้`,
|
||||
),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -119,18 +155,24 @@ export function OpportunityFormSheet({
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : `ไม่สามารถอัปเดต${recordLabel}ได้`)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `ไม่สามารถอัปเดต${recordLabel}ได้`,
|
||||
),
|
||||
});
|
||||
|
||||
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({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: opportunitySchema
|
||||
onSubmit: opportunitySchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload: OpportunityMutationPayload = {
|
||||
@@ -148,35 +190,41 @@ export function OpportunityFormSheet({
|
||||
priority: value.priority,
|
||||
leadChannel: value.leadChannel || null,
|
||||
estimatedValue:
|
||||
typeof value.estimatedValue === 'number' ? value.estimatedValue : null,
|
||||
typeof value.estimatedValue === "number"
|
||||
? value.estimatedValue
|
||||
: null,
|
||||
chancePercent:
|
||||
typeof value.chancePercent === 'number' ? value.chancePercent : null,
|
||||
expectedCloseDate: value.expectedCloseDate || null,
|
||||
projectCloseDate: value.projectCloseDate || null,
|
||||
deliveryDate: value.deliveryDate || null,
|
||||
competitor: value.competitor,
|
||||
source: value.source,
|
||||
isHotProject: value.isHotProject ?? false,
|
||||
hotProjectAutoSuggested: value.hotProjectAutoSuggested ?? false,
|
||||
hotProjectManuallyOverridden: value.hotProjectManuallyOverridden ?? false,
|
||||
notes: value.notes,
|
||||
typeof value.chancePercent === "number" ? value.chancePercent : null,
|
||||
expectedCloseDate: value.expectedCloseDate || null,
|
||||
projectCloseDate: value.projectCloseDate || null,
|
||||
deliveryDate: value.deliveryDate || null,
|
||||
competitor: value.competitor,
|
||||
source: value.source,
|
||||
isHotProject: value.isHotProject ?? false,
|
||||
hotProjectAutoSuggested: value.hotProjectAutoSuggested ?? false,
|
||||
hotProjectManuallyOverridden:
|
||||
value.hotProjectManuallyOverridden ?? false,
|
||||
notes: value.notes,
|
||||
isActive: value.isActive ?? true,
|
||||
projectParties: projectParties
|
||||
.filter((item) => item.customerId && item.role)
|
||||
.map((item) => ({
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark || null
|
||||
}))
|
||||
remark: item.remark || null,
|
||||
})),
|
||||
};
|
||||
|
||||
if (isEdit && opportunity) {
|
||||
await updateMutation.mutateAsync({ id: opportunity.id, values: payload });
|
||||
await updateMutation.mutateAsync({
|
||||
id: opportunity.id,
|
||||
values: payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,9 +240,9 @@ notes: value.notes,
|
||||
key: item.id,
|
||||
customerId: item.customerId,
|
||||
role: item.role,
|
||||
remark: item.remark ?? ''
|
||||
remark: item.remark ?? "",
|
||||
}))
|
||||
: []
|
||||
: [],
|
||||
);
|
||||
}, [defaultValues, opportunity, form, open, projectPartiesQuery.data?.items]);
|
||||
|
||||
@@ -204,65 +252,102 @@ notes: value.notes,
|
||||
}
|
||||
|
||||
const ownerUserId =
|
||||
referenceData.customers.find((customer) => customer.id === selectedCustomerId)?.ownerUserId ?? '';
|
||||
referenceData.customers.find(
|
||||
(customer) => customer.id === selectedCustomerId,
|
||||
)?.ownerUserId ?? "";
|
||||
|
||||
form.setFieldValue('assignedToUserId', ownerUserId);
|
||||
}, [form, isEdit, open, referenceData.customers, selectedCustomerId]);
|
||||
form.setFieldValue("assignedToUserId", ownerUserId);
|
||||
}, [form, isEdit, open, referenceData.customers, selectedCustomerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectCloseDate = form.getFieldValue('projectCloseDate');
|
||||
const manuallyOverridden = form.getFieldValue('hotProjectManuallyOverridden');
|
||||
const projectCloseDate = form.getFieldValue("projectCloseDate");
|
||||
const manuallyOverridden = form.getFieldValue(
|
||||
"hotProjectManuallyOverridden",
|
||||
);
|
||||
|
||||
if (manuallyOverridden) {
|
||||
form.setFieldValue('hotProjectAutoSuggested', isHotProjectSuggested(projectCloseDate));
|
||||
return;
|
||||
}
|
||||
if (manuallyOverridden) {
|
||||
form.setFieldValue(
|
||||
"hotProjectAutoSuggested",
|
||||
isHotProjectSuggested(projectCloseDate),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const suggested = isHotProjectSuggested(projectCloseDate);
|
||||
form.setFieldValue('hotProjectAutoSuggested', suggested);
|
||||
form.setFieldValue('isHotProject', suggested);
|
||||
}, [form, open, form.state.values.projectCloseDate, form.state.values.hotProjectManuallyOverridden]);
|
||||
const suggested = isHotProjectSuggested(projectCloseDate);
|
||||
form.setFieldValue("hotProjectAutoSuggested", suggested);
|
||||
form.setFieldValue("isHotProject", suggested);
|
||||
}, [
|
||||
form,
|
||||
open,
|
||||
form.state.values.projectCloseDate,
|
||||
form.state.values.hotProjectManuallyOverridden,
|
||||
]);
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย';
|
||||
const recordLabel = workspace === "lead" ? "ลีด" : "โอกาสขาย";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
||||
<SheetContent className="flex flex-col sm:max-w-4xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`}</SheetTitle>
|
||||
<SheetTitle>
|
||||
{isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{workspace === 'lead'
|
||||
? 'บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย'
|
||||
: 'บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา'}
|
||||
{workspace === "lead"
|
||||
? "บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย"
|
||||
: "บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<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
|
||||
name='customerId'
|
||||
name="customerId"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>ลูกค้าผู้รับใบเสนอราคา *</field.FieldLabel>
|
||||
<field.FieldLabel>
|
||||
ลูกค้าผู้รับใบเสนอราคา *
|
||||
</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
value={field.state.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value);
|
||||
field.handleBlur();
|
||||
setSelectedCustomerId(value);
|
||||
form.setFieldValue('contactId', '');
|
||||
form.setFieldValue("contactId", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='ลูกค้า'>
|
||||
<SelectValue placeholder='เลือกลูกค้า' />
|
||||
<SelectTrigger aria-label="ลูกค้า">
|
||||
<SelectValue placeholder="เลือกลูกค้า" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.customers.map((customer) => (
|
||||
@@ -279,31 +364,33 @@ form.setFieldValue('isHotProject', suggested);
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='contactId'
|
||||
name="contactId"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>ผู้ติดต่อ</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
value={field.state.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value === '__none__' ? '' : value);
|
||||
field.handleChange(value === "__none__" ? "" : value);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='ผู้ติดต่อ'>
|
||||
<SelectValue placeholder='เลือกผู้ติดต่อ' />
|
||||
<SelectTrigger aria-label="ผู้ติดต่อ">
|
||||
<SelectValue placeholder="เลือกผู้ติดต่อ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactsForCustomer.length ? (
|
||||
contactsForCustomer.map((contact) => (
|
||||
<SelectItem key={contact.id} value={contact.id}>
|
||||
{contact.name}
|
||||
{contact.isPrimary ? ' (ผู้ติดต่อหลัก)' : ''}
|
||||
{contact.isPrimary ? " (ผู้ติดต่อหลัก)" : ""}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value='__none__'>ไม่เลือกผู้ติดต่อ</SelectItem>
|
||||
<SelectItem value="__none__">
|
||||
ไม่เลือกผู้ติดต่อ
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -313,108 +400,149 @@ form.setFieldValue('isHotProject', suggested);
|
||||
)}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='assignedToUserId'
|
||||
label='Suggested Sales Owner'
|
||||
name="assignedToUserId"
|
||||
label="Sales Owner"
|
||||
description={
|
||||
selectedCustomer?.ownerName
|
||||
? `Suggested from customer owner: ${selectedCustomer.ownerName}`
|
||||
: 'Optional sales owner suggestion for this lead or opportunity'
|
||||
: ""
|
||||
}
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name
|
||||
label: user.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name='title'
|
||||
label='ชื่อรายการ'
|
||||
{/* <FormTextField
|
||||
name="title"
|
||||
label="ชื่อรายการ"
|
||||
required
|
||||
placeholder='Warehouse crane upgrade opportunity'
|
||||
placeholder="Warehouse crane upgrade opportunity"
|
||||
/> */}
|
||||
<FormTextField
|
||||
name="projectName"
|
||||
label="ชื่อโครงการ"
|
||||
placeholder="ระบุชื่อโครงการ"
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectName'
|
||||
label='ชื่อโครงการ'
|
||||
placeholder='Bangna Warehouse Expansion'
|
||||
name="projectLocation"
|
||||
label="สถานที่โครงการ"
|
||||
placeholder="ระบุสถานที่โครงการ"
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectLocation'
|
||||
label='สถานที่โครงการ'
|
||||
placeholder='Bangkok'
|
||||
/>
|
||||
<FormTextField
|
||||
name='source'
|
||||
label='ที่มา'
|
||||
placeholder='Existing customer referral'
|
||||
name="source"
|
||||
label="เอกสารอ้างอิงจาก Lead"
|
||||
placeholder=""
|
||||
/>
|
||||
<div className="md:col-span-2 grid gap-4 md:grid-cols-2 rounded-sm">
|
||||
{/* <FormSwitchField
|
||||
name="isActive"
|
||||
label="ใช้งานอยู่"
|
||||
description="รายการที่ปิดใช้งานจะยังอยู่ในประวัติ แต่ไม่ควรถูกดำเนินการต่อ"
|
||||
/> */}
|
||||
</div>
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='สาขา'
|
||||
options={referenceData.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='productType'
|
||||
label='ประเภทสินค้า'
|
||||
name="productType"
|
||||
label="ประเภทสินค้า"
|
||||
required
|
||||
options={referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='status'
|
||||
label='สถานะ'
|
||||
required
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
<form.AppField
|
||||
name="isHotProject"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field orientation="horizontal">
|
||||
<div className="flex flex-1 flex-col gap-1.5 leading-snug mb-2">
|
||||
<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
|
||||
name='priority'
|
||||
label='ความสำคัญ'
|
||||
name="priority"
|
||||
label="ความสำคัญ"
|
||||
required
|
||||
options={referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='leadChannel'
|
||||
label='ที่มาของลีด'
|
||||
name="leadChannel"
|
||||
label="ที่มาของลีด"
|
||||
options={referenceData.leadChannels.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
disabled
|
||||
/>
|
||||
<FormCurrencyField
|
||||
name='estimatedValue'
|
||||
label='มูลค่าประมาณการ'
|
||||
currencyLabel='THB'
|
||||
placeholder='2500000'
|
||||
name="estimatedValue"
|
||||
label="มูลค่าประมาณการ"
|
||||
currencyLabel="THB"
|
||||
placeholder="2500000"
|
||||
/>
|
||||
<form.AppField
|
||||
name='chancePercent'
|
||||
name="chancePercent"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>โอกาสปิดการขาย %</field.FieldLabel>
|
||||
<Select
|
||||
value={
|
||||
typeof field.state.value === 'number'
|
||||
typeof field.state.value === "number"
|
||||
? String(field.state.value)
|
||||
: ''
|
||||
: ""
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value ? Number(value) : undefined);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='Chance percentage'>
|
||||
<SelectValue placeholder='เลือก Chance %' />
|
||||
<SelectTrigger aria-label="Chance percentage">
|
||||
<SelectValue placeholder="เลือก Chance %" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANCE_PERCENT_OPTIONS.map((option) => (
|
||||
@@ -429,84 +557,42 @@ form.setFieldValue('isHotProject', suggested);
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
{/* <FormDatePickerField
|
||||
name="expectedCloseDate"
|
||||
label="วันที่คาดว่าจะปิดการขาย"
|
||||
/> */}
|
||||
<FormDatePickerField
|
||||
name='expectedCloseDate'
|
||||
label='วันที่คาดว่าจะปิดการขาย'
|
||||
name="projectCloseDate"
|
||||
label="วันที่สรุปงาน"
|
||||
/>
|
||||
<FormDatePickerField
|
||||
name='projectCloseDate'
|
||||
label='Project Close Date'
|
||||
<FormDatePickerField name="deliveryDate" label="วันที่ส่งมอบ" />
|
||||
<FormTextField
|
||||
name="competitor"
|
||||
label="คู่แข่ง"
|
||||
placeholder="Other vendor name"
|
||||
/>
|
||||
<FormDatePickerField
|
||||
name='deliveryDate'
|
||||
label='Delivery Date'
|
||||
/>
|
||||
<FormTextField name='competitor' label='คู่แข่ง' placeholder='Other vendor name' />
|
||||
<div className='md:col-span-2'>
|
||||
<div className="md:col-span-2">
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
size='md'
|
||||
label='รายละเอียด'
|
||||
placeholder='สรุปภาพรวมของลีดหรือโอกาสขาย'
|
||||
name="description"
|
||||
size="md"
|
||||
label="รายละเอียดเบื้องต้นจาก Lead"
|
||||
placeholder="รายละเอียดเบื้องต้นจาก Lead"
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<div className="md:col-span-2">
|
||||
<FormTextareaField
|
||||
name='requirement'
|
||||
size='md'
|
||||
label='ความต้องการ'
|
||||
placeholder='ความต้องการเชิงเทคนิคหรือเชิงพาณิชย์'
|
||||
name="requirement"
|
||||
size="md"
|
||||
label="ความต้องการเชิงลึก"
|
||||
placeholder="ความต้องการเชิงลึก"
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<div className="md:col-span-2">
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
size='md'
|
||||
label='หมายเหตุ'
|
||||
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}
|
||||
name="notes"
|
||||
size="md"
|
||||
label="หมายเหตุ"
|
||||
placeholder="หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน"
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
@@ -514,11 +600,19 @@ form.setFieldValue('isHotProject', suggested);
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type='submit' form='opportunity-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
<Button
|
||||
type="submit"
|
||||
form="opportunity-form-sheet"
|
||||
isLoading={isPending}
|
||||
>
|
||||
<Icons.check className="mr-2 h-4 w-4" />
|
||||
{isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
@@ -529,17 +623,18 @@ form.setFieldValue('isHotProject', suggested);
|
||||
|
||||
export function OpportunityFormSheetTrigger({
|
||||
referenceData,
|
||||
workspace = 'opportunity'
|
||||
workspace = "opportunity",
|
||||
}: {
|
||||
referenceData: OpportunityReferenceData;
|
||||
workspace?: 'lead' | 'opportunity';
|
||||
workspace?: "lead" | "opportunity";
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<OpportunityFormSheet
|
||||
open={open}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
@@ -29,9 +29,7 @@ import type {
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityFollowupRecord,
|
||||
OpportunityListItem,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload,
|
||||
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) {
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
return new Set(options.map((option) => option.id));
|
||||
@@ -388,6 +423,46 @@ async function resolveOptionCodeById(
|
||||
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) {
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -559,7 +634,7 @@ async function validateOpportunityPayload(
|
||||
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(
|
||||
organizationId,
|
||||
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 statuses = splitFilterValue(filters.status);
|
||||
const stages = splitFilterValue(filters.stage);
|
||||
@@ -636,14 +714,18 @@ function buildOpportunityFilters(context: OpportunityAccessContext, filters: Opp
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
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 [
|
||||
eq(crmOpportunities.organizationId, context.organizationId),
|
||||
isNull(crmOpportunities.deletedAt),
|
||||
...buildAccessScopedFilters(context),
|
||||
...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []),
|
||||
...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []),
|
||||
...(stages.length ? [inArray(crmOpportunities.status, stages)] : []),
|
||||
...(statusFilterValues.length ? [inArray(crmOpportunities.status, statusFilterValues)] : []),
|
||||
...(stageFilterValues.length ? [inArray(crmOpportunities.status, stageFilterValues)] : []),
|
||||
...(outcomeStatuses.length ? [inArray(crmOpportunities.outcomeStatus, outcomeStatuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []),
|
||||
@@ -792,7 +874,7 @@ export async function listOpportunities(
|
||||
): Promise<{ items: OpportunityListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
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 offset = (page - 1) * limit;
|
||||
|
||||
@@ -1160,6 +1242,9 @@ export async function createOpportunity(
|
||||
await validateOpportunityPayload(organizationId, payload, accessContext);
|
||||
const hotProjectState = resolveHotProjectState(payload);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
|
||||
const normalizedStatusCode =
|
||||
(await resolveOptionCode(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status)) ??
|
||||
payload.status;
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
@@ -1183,7 +1268,7 @@ export async function createOpportunity(
|
||||
projectName: payload.projectName?.trim() || null,
|
||||
projectLocation: payload.projectLocation?.trim() || null,
|
||||
productType: payload.productType,
|
||||
status: payload.status,
|
||||
status: normalizedStatusCode,
|
||||
priority: payload.priority,
|
||||
leadChannel: payload.leadChannel ?? null,
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
@@ -1232,6 +1317,9 @@ export async function updateOpportunity(
|
||||
const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
assertOpportunityOpenForEditing(current);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(current);
|
||||
const normalizedStatusCode =
|
||||
(await resolveOptionCode(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status)) ??
|
||||
payload.status;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
@@ -1246,7 +1334,7 @@ export async function updateOpportunity(
|
||||
projectName: payload.projectName?.trim() || null,
|
||||
projectLocation: payload.projectLocation?.trim() || null,
|
||||
productType: payload.productType,
|
||||
status: payload.status,
|
||||
status: normalizedStatusCode,
|
||||
priority: payload.priority,
|
||||
leadChannel: payload.leadChannel ?? null,
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
@@ -1837,15 +1925,15 @@ export async function syncOpportunityPipelineStageFromQuotationStatus(
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage = quotationStatusCode ? 'quotation_created' : opportunity.status;
|
||||
if (nextStage === opportunity.status && opportunity.pipelineStage === 'opportunity') {
|
||||
const nextStatus = quotationStatusCode ? 'quotation_created' : opportunity.status;
|
||||
if (nextStatus === opportunity.status && opportunity.pipelineStage === 'opportunity') {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: nextStage,
|
||||
status: nextStatus,
|
||||
pipelineStage: 'opportunity',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
|
||||
@@ -512,6 +512,40 @@ export function QuotationFormSheet({
|
||||
onSubmit={onSubmit}
|
||||
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">
|
||||
<Select
|
||||
value={state.opportunityId || "__none__"}
|
||||
@@ -573,24 +607,6 @@ export function QuotationFormSheet({
|
||||
</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="Quotation Date" required>
|
||||
<CrmDateInput
|
||||
value={state.quotationDate}
|
||||
@@ -605,21 +621,21 @@ export function QuotationFormSheet({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Project Close Date">
|
||||
<Field label="วันที่สรุปงาน">
|
||||
<CrmDateInput
|
||||
value={state.projectCloseDate}
|
||||
onChange={(value) => setField("projectCloseDate", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Delivery Date">
|
||||
<Field label="วันที่ส่งมอบ">
|
||||
<CrmDateInput
|
||||
value={state.deliveryDate}
|
||||
onChange={(value) => setField("deliveryDate", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Branch">
|
||||
{/* <Field label="Branch">
|
||||
<Select
|
||||
value={state.branchId || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
@@ -638,25 +654,7 @@ export function QuotationFormSheet({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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> */}
|
||||
|
||||
<Field label="Currency">
|
||||
<Select
|
||||
|
||||
Reference in New Issue
Block a user