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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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