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