From 69ae3d42c4980de62a3c7fb368df6c8c7a474614 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Wed, 15 Jul 2026 15:43:28 +0700 Subject: [PATCH] commit --- src/config/nav-config.ts | 32 +- .../components/recent-activities-panel.tsx | 70 ++- .../customers/components/customer-detail.tsx | 295 +++++---- .../components/customer-form-sheet.tsx | 305 ++++++---- .../crm/leads/components/lead-columns.tsx | 197 +++--- .../crm/leads/components/lead-form.tsx | 33 +- .../components/opportunity-columns.tsx | 468 ++++++++------ .../components/opportunity-detail.tsx | 519 +++++++++------- .../components/opportunity-form-sheet.tsx | 569 ++++++++++-------- .../components/opportunity-option-resolver.ts | 17 + .../server/quotation-sync.service.ts | 2 +- .../crm/opportunities/server/service.ts | 112 +++- .../components/quotation-form-sheet.tsx | 78 ++- 13 files changed, 1645 insertions(+), 1052 deletions(-) create mode 100644 src/features/crm/opportunities/components/opportunity-option-resolver.ts diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 53660c7..d7783cd 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -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", diff --git a/src/features/crm/activities/components/recent-activities-panel.tsx b/src/features/crm/activities/components/recent-activities-panel.tsx index f1208b5..7890919 100644 --- a/src/features/crm/activities/components/recent-activities-panel.tsx +++ b/src/features/crm/activities/components/recent-activities-panel.tsx @@ -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 ( @@ -36,34 +42,46 @@ export function RecentActivitiesPanel({ {title} {description} - + {items.length === 0 ? ( -
+
{emptyMessage}
) : ( items.map((item) => ( -
-
-
-
-
{item.subject}
- {item.activityCode ? {item.activityCode} : null} - +
+
+
+
+
{item.subject}
+ {item.activityCode ? ( + {item.activityCode} + ) : null} + {item.sourceLabel} - {item.statusLabel} + {item.statusLabel}
-
+
{item.activityTypeLabel} - {item.primaryRecordLabel ? ` • ${item.primaryRecordLabel}` : ''} + {item.primaryRecordLabel + ? ` • ${item.primaryRecordLabel}` + : ""}
- {item.description ?
{item.description}
: null} -
- - - - + {item.description ? ( +
{item.description}
+ ) : null} +
+ + + +
diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 91f1fac..dacaf1a 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -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 ( -
-
{label}
-
{value || '-'}
+
+
{label}
+
{value || "-"}
); } @@ -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 ( -
-
-
-
- - {customer.code} - - {type ? {type.label} : null} + + {customer.code} + + + {type ? {type.label} : null} {customer.branchId ? ( - - {branchMap.get(customer.branchId) ?? 'Unknown branch'} + + {branchMap.get(customer.branchId) ?? "Unknown branch"} ) : null}
-

{customer.name}

-

- {customer.abbr || customer.email || customer.phone || 'รายละเอียดข้อมูลลูกค้า'} +

{customer.name}

+

+ {customer.abbr || + customer.email || + customer.phone || + "รายละเอียดข้อมูลลูกค้า"}

-
+
{canUpdate ? ( - ) : null}
-
-
+
+
- - - - + + - ภาพรวม - {canReadContacts ? ผู้ติดต่อ : null} - Audit Log - เอกสารที่เกี่ยวข้อง + ภาพรวม + {canReadContacts ? ( + ผู้ติดต่อ + ) : null} + Follow up + เอกสารที่เกี่ยวข้อง - + ข้อมูลลูกค้า - ข้อมูลหลักของลูกค้า ที่อยู่ และการจัดประเภทใน CRM + + ข้อมูลหลักของลูกค้า ที่อยู่ และการจัดประเภทใน CRM + - - - - - + + + + + - + - -
+ +
-
- +
+
{canReadContacts ? ( - + ) : null} - - + + {/* */} - + เอกสารที่เกี่ยวข้อง @@ -217,26 +255,29 @@ export function CustomerDetail({ - {!relatedOpportunities.length && !relatedQuotations.length ? ( -
+ {!relatedOpportunities.length && + !relatedQuotations.length ? ( +
ยังไม่มีโอกาสขายหรือใบเสนอราคาที่เชื่อมกับลูกค้ารายนี้
) : ( -
+
{relatedOpportunities.map((opportunity) => ( -
-
-
{opportunity.title}
-
+
+
+
+ {opportunity.title} +
+
{opportunity.code}
- +
))} @@ -244,16 +285,18 @@ export function CustomerDetail({ -
-
-
{quotation.code}
-
+
+
+
+ {quotation.code} +
+
{formatNumber(quotation.totalAmount)}
- +
))} @@ -267,7 +310,7 @@ export function CustomerDetail({
-
+
ข้อมูลระบบ - ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้ + + ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้ + - - - - - + + + + +
diff --git a/src/features/crm/customers/components/customer-form-sheet.tsx b/src/features/crm/customers/components/customer-form-sheet.tsx index 688555b..c713f74 100644 --- a/src/features/crm/customers/components/customer-form-sheet.tsx +++ b/src/features/crm/customers/components/customer-form-sheet.tsx @@ -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(); @@ -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 ( - + - {isEdit ? 'แก้ไขลูกค้า' : 'เพิ่มลูกค้า'} - จัดการข้อมูลหลักของลูกค้า สาขาที่ดูแล และการจัดประเภทใน CRM + {isEdit ? "แก้ไขลูกค้า" : "เพิ่มลูกค้า"} + + จัดการข้อมูลหลักของลูกค้า สาขาที่ดูแล และการจัดประเภทใน CRM + -
+
- + - - - - + + + + + + - - ({ value: item.id, - label: item.label + label: item.label, }))} /> ({ value: item.id, - label: item.label + label: item.label, }))} /> ({ value: branch.id, - label: branch.name + label: branch.name, }))} /> ({ - value: item.id, - label: item.label - }))} - /> - ({ value: item.id, - label: item.label + label: item.label, }))} /> ({ value: item.id, - label: item.label + label: item.label, }))} /> -
- -
- - - - -
- +
-
+ + + + +
+ +
+
@@ -252,12 +315,20 @@ export function CustomerFormSheet({
- - @@ -266,7 +337,7 @@ export function CustomerFormSheet({ } export function CustomerFormSheetTrigger({ - referenceData + referenceData, }: { referenceData: CustomerReferenceData; }) { @@ -275,9 +346,13 @@ export function CustomerFormSheetTrigger({ return ( <> - + ); } diff --git a/src/features/crm/leads/components/lead-columns.tsx b/src/features/crm/leads/components/lead-columns.tsx index f439089..1f2d518 100644 --- a/src/features/crm/leads/components/lead-columns.tsx +++ b/src/features/crm/leads/components/lead-columns.tsx @@ -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[] { return [ { - id: 'code', - accessorKey: 'code', + id: "code", + accessorKey: "code", header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => ( -
+
{row.original.code} - {row.original.customerName ?? '-'} + + {row.original.customerName ?? "-"} +
), 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 }) => ( - + ), cell: ({ row }) => ( - {row.original.statusLabel ?? row.original.status} + + {row.original.statusLabel ?? row.original.status} + ), 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 }) => ( - + + ), + cell: ({ row }) => ( + + {row.original.statusLabel ?? row.original.status} + + ), + 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 }) => ( + ), cell: ({ row }) => { - const branch = referenceData.branches.find((item) => item.id === row.original.branchId); - return {branch?.name ?? '-'}; + const branch = referenceData.branches.find( + (item) => item.id === row.original.branchId, + ); + return {branch?.name ?? "-"}; }, 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 }) => ( - + ), - cell: ({ row }) => {row.original.customerName ?? '-'}, + cell: ({ row }) => {row.original.customerName ?? "-"}, 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 }) => ( - + ), - cell: ({ row }) => {row.original.projectName ?? '-'} + cell: ({ row }) => {row.original.projectName ?? "-"}, }, { - id: 'priority', - accessorFn: (row) => row.priority ?? '', + id: "priority", + accessorFn: (row) => row.priority ?? "", header: ({ column }: { column: Column }) => ( - + ), - cell: ({ row }) => {row.original.priority ?? '-'} + cell: ({ row }) => {row.original.priority ?? "-"}, }, { - id: 'suggestedSalesOwner', - accessorFn: (row) => row.suggestedSalesOwnerId ?? '', + id: "suggestedSalesOwner", + accessorFn: (row) => row.suggestedSalesOwnerId ?? "", header: ({ column }: { column: Column }) => ( - + ), - cell: ({ row }) => {row.original.suggestedSalesOwnerName ?? '-'} - }, - { - id: 'assignedSalesOwner', - accessorFn: (row) => row.assignedSalesOwnerId ?? '', - header: ({ column }: { column: Column }) => ( - + cell: ({ row }) => ( + {row.original.suggestedSalesOwnerName ?? "-"} ), - cell: ({ row }) => {row.original.assignedSalesOwnerName ?? '-'} }, { - id: 'estimatedValue', - accessorKey: 'estimatedValue', + id: "assignedSalesOwner", + accessorFn: (row) => row.assignedSalesOwnerId ?? "", header: ({ column }: { column: Column }) => ( - + + ), + cell: ({ row }) => ( + {row.original.assignedSalesOwnerName ?? "-"} + ), + }, + { + id: "estimatedValue", + accessorKey: "estimatedValue", + header: ({ column }: { column: Column }) => ( + ), cell: ({ row }) => ( {row.original.estimatedValue !== null ? formatNumber(row.original.estimatedValue) - : '-'} + : "-"} - ) - }, - { - id: 'createdAt', - accessorKey: 'createdAt', - header: ({ column }: { column: Column }) => ( - ), - cell: ({ row }) => {formatDate(row.original.createdAt)} }, { - id: 'actions', + id: "createdAt", + accessorKey: "createdAt", + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {formatDate(row.original.createdAt)}, + }, + { + id: "actions", cell: ({ row }) => ( - ) - } + ), + }, ]; } diff --git a/src/features/crm/leads/components/lead-form.tsx b/src/features/crm/leads/components/lead-form.tsx index 70f93ce..2d29377 100644 --- a/src/features/crm/leads/components/lead-form.tsx +++ b/src/features/crm/leads/components/lead-form.tsx @@ -195,6 +195,22 @@ export function LeadForm({
+ ({ + value: item.id, + label: item.label, + }))} + /> + ({ + value: item.id, + label: item.label, + }))} + /> ( @@ -326,25 +342,8 @@ export function LeadForm({ currencyLabel="THB" /> - ({ - value: item.id, - label: item.label, - }))} - /> - {isEdit ? ( <> - ({ - value: item.id, - label: item.label, - }))} - /> [] { - 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 }) => ( - - ), + id: "code", + accessorKey: "code", + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( -
+
{row.original.code} - {row.original.title} + + {row.original.title} +
), 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 }) => ( - + id: "leadChannel", + accessorFn: (row) => row.leadChannel ?? "", + header: ({ + column, + }: { + column: Column; + }) => , + cell: ({ row }) => ( + {leadChannelMap.get(row.original.leadChannel ?? "") ?? "-"} ), - cell: ({ row }) => {leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'} }, { - id: 'customer', + id: "customer", accessorFn: (row) => row.customerId, - header: ({ column }: { column: Column }) => ( - - ), + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => {row.original.customerName}, 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 }) => ( - - ), + id: "status", + accessorFn: (row) => + resolveOpportunityOption(referenceData.statuses, row.status)?.code ?? + row.status, + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => { - const status = statusMap.get(row.original.status); - return ; + const status = resolveOpportunityOption( + referenceData.statuses, + row.original.status, + ); + return ( + + ); }, 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 }) => ( - ) - } + ), + }, ]; } @@ -122,198 +149,277 @@ function getOpportunityWorkspaceColumns({ canUpdate, canDelete, canAssign, - canReassign + canReassign, }: SharedColumnsConfig): ColumnDef[] { - 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 }) => ( - - ), + id: "code", + accessorKey: "code", + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( -
+
{row.original.code} - {row.original.title} + + {row.original.title} +
), 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 }) => ( - - ), + id: "status", + accessorFn: (row) => + resolveOpportunityOption(referenceData.statuses, row.status)?.code ?? + row.status, + header: ({ + column, + }: { + column: Column; + }) => , + cell: ({ row }) => { + const status = resolveOpportunityOption( + referenceData.statuses, + row.original.status, + ); + return ( + + ); + }, + 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; + }) => , + cell: ({ row }) => { + const status = resolveOpportunityOption( + referenceData.statuses, + row.original.status, + ); + return ( + + ); + }, + 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; + }) => , cell: ({ row }) => row.original.leadId ? ( - - {row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'} + + {row.original.source ?? + row.original.sourceLeadCode ?? + "Lead linked"} ) : ( - Direct sales - ) + Direct sales + ), }, { - id: 'customer', + id: "customer", accessorFn: (row) => row.customerId, - header: ({ column }: { column: Column }) => ( - - ), + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => {row.original.customerName}, 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 }) => ( - - ), - cell: ({ row }) => {row.original.projectName ?? '-'} + id: "projectName", + accessorFn: (row) => row.projectName ?? "", + header: ({ + column, + }: { + column: Column; + }) => , + cell: ({ row }) => {row.original.projectName ?? "-"}, }, { - id: 'productType', + id: "productType", accessorFn: (row) => row.productType, - header: ({ column }: { column: Column }) => ( - - ), + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( - {productTypeMap.get(row.original.productType) ?? row.original.productType} + + {productTypeMap.get(row.original.productType) ?? + row.original.productType} + ), 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 }) => ( - - ), - cell: ({ row }) => {row.original.assignedToName ?? '-'} + id: "assignedToName", + accessorFn: (row) => row.assignedToName ?? "", + header: ({ + column, + }: { + column: Column; + }) => , + cell: ({ row }) => {row.original.assignedToName ?? "-"}, }, + { - id: 'status', - accessorKey: 'status', - header: ({ column }: { column: Column }) => ( - - ), - cell: ({ row }) => { - const status = statusMap.get(row.original.status); - return ; - }, - 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 }) => ( - - ), + id: "estimatedValue", + accessorKey: "estimatedValue", + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( {row.original.estimatedValue !== null ? formatNumber(row.original.estimatedValue) - : '-'} + : "-"} - ) + ), }, { - id: 'chancePercent', - accessorKey: 'chancePercent', - header: ({ column }: { column: Column }) => ( - - ), + id: "chancePercent", + accessorKey: "chancePercent", + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( - {row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'} - ) + + {row.original.chancePercent !== null + ? `${row.original.chancePercent}%` + : "-"} + + ), }, { - id: 'expectedCloseDate', - accessorKey: 'expectedCloseDate', - header: ({ column }: { column: Column }) => ( - - ), + id: "expectedCloseDate", + accessorKey: "expectedCloseDate", + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( {row.original.expectedCloseDate ? formatDate(row.original.expectedCloseDate) - : '-'} + : "-"} - ) + ), }, { - id: 'nextFollowupDate', - accessorKey: 'nextFollowupDate', - header: ({ column }: { column: Column }) => ( - - ), + id: "nextFollowupDate", + accessorKey: "nextFollowupDate", + header: ({ + column, + }: { + column: Column; + }) => , cell: ({ row }) => ( {row.original.nextFollowupDate ? formatDate(row.original.nextFollowupDate) - : '-'} + : "-"} - ) - }, - { - id: 'createdAt', - accessorKey: 'createdAt', - header: ({ column }: { column: Column }) => ( - ), - cell: ({ row }) => {formatDate(row.original.createdAt)} }, { - id: 'actions', + id: "createdAt", + accessorKey: "createdAt", + header: ({ + column, + }: { + column: Column; + }) => , + cell: ({ row }) => {formatDate(row.original.createdAt)}, + }, + { + id: "actions", cell: ({ row }) => ( - ) - } + ), + }, ]; } @@ -323,10 +429,18 @@ export function getOpportunityColumns({ canUpdate, canDelete, canAssign, - canReassign -}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef[] { - const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign }; - return workspace === 'lead' + canReassign, +}: SharedColumnsConfig & { + workspace: "lead" | "opportunity"; +}): ColumnDef[] { + const sharedConfig = { + referenceData, + canUpdate, + canDelete, + canAssign, + canReassign, + }; + return workspace === "lead" ? getLeadColumns(sharedConfig) : getOpportunityWorkspaceColumns(sharedConfig); } diff --git a/src/features/crm/opportunities/components/opportunity-detail.tsx b/src/features/crm/opportunities/components/opportunity-detail.tsx index 2840b90..a9c8e3f 100644 --- a/src/features/crm/opportunities/components/opportunity-detail.tsx +++ b/src/features/crm/opportunities/components/opportunity-detail.tsx @@ -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 ( -
-
{label}
-
{value || '-'}
+
+
{label}
+
{value || "-"}
); } -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 ( -
+
) : null} -
+
-
-

{opportunity.code}

+
+

{opportunity.code}

-

- Opportunity detail for sales execution, follow-up continuity, and quotation readiness. +

+ Opportunity detail for sales execution, follow-up continuity, and + quotation readiness.

-
+
{canCreateQuotation ? ( ) : null} {sourceLead ? ( - ) : null} {canManageAssignment ? ( - ) : null} {canUpdate ? ( - ) : null}
-
-
+
+
Opportunity Summary - Core sales execution snapshot for this opportunity. + + Core sales execution snapshot for this opportunity. + - - - - - - - - + + + + + + + + - - - - - - Customer / Contact - - - - - - - - - - - - - - Requirement - - - - - -
- - -
-
-
- - - - Sales Qualification - - - - - - - - - - - - - - + - Follow-up Timeline - Linked Quotations - Audit Log + Follow-up + Customer / Contact + Lead + Requirement + Sales Qualification + Linked Quotations + {/* Audit Log */} - - - + + {/* + /> */} - - + + + + Customer / Contact + + + + + + + + + + + + + + + + + + Requirement + + + + + +
+ + +
+
+
+
+ Linked Quotations - + {!canViewRelatedQuotations ? ( -
+
Quotation access is restricted.
) : relatedQuotations.length === 0 ? ( -
No quotations created yet.
+
+ No quotations created yet. +
) : ( relatedQuotations.map((quotation) => ( -
-
+
+
{quotation.code} - {quotation.status} + {quotation.status} +
+
+ {quotation.quotationType}
-
{quotation.quotationType}
)) )} - - + + + + Sales Qualification + + + + + + + + + + + +
-
+
- + {/* Quotation Context - - - + + + {relatedQuotations.length ? ( -
+
{relatedQuotations.slice(0, 3).map((quotation) => ( -
-
+
+
{quotation.code} - Rev {quotation.revision} + + Rev {quotation.revision} +
-
- {quotation.status} · {formatNumber(quotation.totalAmount)}{' '} +
+ {quotation.status} ·{" "} + {formatNumber(quotation.totalAmount)}{" "} {quotation.currency}
@@ -429,7 +530,7 @@ remark: item.remark ?? ''
) : null} - + */}
diff --git a/src/features/crm/opportunities/components/opportunity-form-sheet.tsx b/src/features/crm/opportunities/components/opportunity-form-sheet.tsx index 0ad632b..09a0314 100644 --- a/src/features/crm/opportunities/components/opportunity-form-sheet.tsx +++ b/src/features/crm/opportunities/components/opportunity-form-sheet.tsx @@ -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([]); + const [selectedCustomerId, setSelectedCustomerId] = useState( + defaultValues.customerId, + ); + const [projectParties, setProjectParties] = useState< + ProjectPartyEditorItem[] + >([]); const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField, FormDatePickerField, - FormCurrencyField + FormCurrencyField, } = useFormFields(); 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 ( - + - {isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`} + + {isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`} + - {workspace === 'lead' - ? 'บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย' - : 'บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา'} + {workspace === "lead" + ? "บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย" + : "บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา"} -
+
- + + ({ + value: item.code, + label: item.label, + }))} + /> + ({ + value: item.code, + label: item.label, + }))} + /> ( - ลูกค้าผู้รับใบเสนอราคา * + + ลูกค้าผู้รับใบเสนอราคา * + { - field.handleChange(value === '__none__' ? '' : value); + field.handleChange(value === "__none__" ? "" : value); field.handleBlur(); }} > - - + + {contactsForCustomer.length ? ( contactsForCustomer.map((contact) => ( {contact.name} - {contact.isPrimary ? ' (ผู้ติดต่อหลัก)' : ''} + {contact.isPrimary ? " (ผู้ติดต่อหลัก)" : ""} )) ) : ( - ไม่เลือกผู้ติดต่อ + + ไม่เลือกผู้ติดต่อ + )} @@ -313,108 +400,149 @@ form.setFieldValue('isHotProject', suggested); )} /> ({ value: user.id, - label: user.name + label: user.name, }))} /> - */} + - +
+ {/* */} +
({ - value: branch.id, - label: branch.name - }))} - /> - ({ value: item.id, - label: item.label + label: item.label, }))} /> - ({ - value: item.id, - label: item.label - }))} + ( + + +
+ + โครงการด่วน + + +
+ { + field.handleChange(checked); + field.handleBlur(); + form.setFieldValue( + "hotProjectManuallyOverridden", + true, + ); + form.setFieldValue( + "hotProjectAutoSuggested", + isHotProjectSuggested( + form.getFieldValue("projectCloseDate"), + ), + ); + }} + /> +
+ +
+ )} /> + {/* ({ + value: branch.id, + label: branch.name, + }))} + /> */} + +
+ +
({ value: item.id, - label: item.label + label: item.label, }))} /> ({ value: item.id, - label: item.label + label: item.label, }))} + disabled /> ( โอกาสปิดการขาย % setField("status", value)} + > + + + + + {referenceData.statuses.map((item) => ( + + {item.label} + + ))} + + + + + + setField("quotationType", value)} - > - - - - - {referenceData.quotationTypes.map((item) => ( - - {item.label} - - ))} - - - - - + setField("projectCloseDate", value)} /> - + setField("deliveryDate", value)} /> - + {/* - - - - - + */}