thai-uxui

This commit is contained in:
phaichayon
2026-06-19 11:11:17 +07:00
parent c0cc880be2
commit 721653f692
47 changed files with 1135 additions and 538 deletions

View File

@@ -0,0 +1,31 @@
# CRM Terminology Registry
| Code | English | Thai | Notes |
| ---- | ------- | ---- | ----- |
| crm | CRM | CRM | Keep as `CRM` |
| dashboard | Dashboard | Dashboard | Keep as `Dashboard` |
| lead | Lead | ลีด | ข้อมูลลูกค้าเป้าหมายก่อนส่งต่อให้ฝ่ายขาย |
| enquiry | Enquiry | โอกาสขาย | โอกาสทางการขายที่มอบหมายให้ฝ่ายขายดำเนินการ |
| quotation | Quotation | ใบเสนอราคา | ใช้กับทุกหน้าที่เกี่ยวกับเอกสารเสนอราคา |
| approval | Approval | อนุมัติเอกสาร | ใช้กับ workflow และคิวอนุมัติ |
| customer | Customer | ลูกค้า | ใช้กับทะเบียนลูกค้าและบทบาทลูกค้า |
| contact | Contact | ผู้ติดต่อ | ใช้กับบุคคลติดต่อของลูกค้า |
| billing_customer | Billing Customer | ลูกค้าผู้รับใบเสนอราคา | บริษัทที่ใช้สำหรับออกใบเสนอราคาและวางบิล |
| project_parties | Project Parties | ผู้เกี่ยวข้องในโครงการ | ใช้กับ customer roles ใน enquiry และ quotation |
| project_party.customer | Customer | ลูกค้า | บทบาทในโครงการ |
| project_party.billing_customer | Billing Customer | ลูกค้าผู้รับใบเสนอราคา | บทบาทในโครงการ |
| project_party.end_customer | End Customer | เจ้าของโครงการ | บทบาทในโครงการ |
| project_party.contractor | Contractor | ผู้รับเหมา | บทบาทในโครงการ |
| project_party.consultant | Consultant | ที่ปรึกษา | บทบาทในโครงการ |
| lead_count | Lead Count | จำนวนลีด | KPI dashboard |
| enquiry_count | Enquiry Count | จำนวนโอกาสขาย | KPI dashboard |
| won_count | Won Count | จำนวนงานที่ชนะ | KPI dashboard |
| lost_count | Lost Count | จำนวนงานที่แพ้ | KPI dashboard |
| revenue | Revenue | มูลค่าใบเสนอราคา | KPI / report label |
| conversion_rate | Conversion Rate | อัตราการเปลี่ยนสถานะ | KPI / report label |
## Freeze Policy
- เอกสารนี้เป็น source of truth ของคำธุรกิจ CRM ฝั่ง UI
- การเปลี่ยนคำหลังจากนี้ต้องผ่าน ADR หรือ business review ก่อน
- ห้ามใช้เอกสารนี้เพื่อเปลี่ยนชื่อ schema, API route, TypeScript type, หรือ service folder

View File

@@ -0,0 +1,58 @@
# Task UX.1: Thai CRM Terminology Standardization
## Files Added
- `docs/business/crm-terminology.md`
- `docs/implementation/task-ux1-thai-crm-terminology-standardization.md`
- `src/features/crm/shared/terminology.ts`
## Files Modified
- `src/config/nav-config.ts`
- `src/features/crm/shared/project-party.ts`
- `src/app/dashboard/crm/page.tsx`
- `src/app/dashboard/crm/customers/page.tsx`
- `src/app/dashboard/crm/leads/page.tsx`
- `src/app/dashboard/crm/leads/[id]/page.tsx`
- `src/app/dashboard/crm/enquiries/page.tsx`
- `src/app/dashboard/crm/enquiries/[id]/page.tsx`
- `src/app/dashboard/crm/quotations/page.tsx`
- `src/app/dashboard/crm/approvals/page.tsx`
- `src/app/dashboard/crm/approvals/[id]/page.tsx`
- `src/features/crm/dashboard/components/*`
- `src/app/api/crm/dashboard/export/route.ts`
- `src/features/crm/components/project-parties-editor.tsx`
- `src/features/crm/customers/components/*`
- `src/features/crm/enquiries/components/*`
- `src/features/crm/quotations/components/quotation-columns.tsx`
- `src/features/crm/quotations/components/quotation-document-preview.tsx`
- `src/features/foundation/approval/components/*`
## Terminology Registry
- Added `docs/business/crm-terminology.md` as the frozen source of truth for CRM UI business terms.
## Menu Changes
- Standardized CRM side menu labels to Thai business terminology while keeping `CRM` and `Dashboard` in English.
## Dashboard Changes
- Updated KPI cards, funnel labels, follow-up labels, approval analytics labels, revenue tables, and export labels to the frozen Thai terms.
## Form Changes
- Updated business-facing customer, contact, lead, and enquiry form labels and CTA copy without renaming any data fields.
## PDF Changes
- Updated quotation preview labels to Thai business terminology without changing template mapping keys.
## Report Changes
- Updated CRM dashboard export headers and section labels to Thai business terminology while keeping internal query/filter codes unchanged.
## Remaining Risks
- Some technical or admin-facing screens still intentionally remain English because they are out of scope for this task.
- Master option values seeded in the database may still be English in storage, so this task relies on UI copy and display-layer terminology rather than persistence changes.

471
plans/task-ux.1.md Normal file
View File

@@ -0,0 +1,471 @@
# Task UX.1: Thai CRM Terminology Standardization
## Goal
สร้างมาตรฐานคำศัพท์ภาษาไทยสำหรับ ALLA OS CRM
เพื่อให้:
* Menu
* Form
* Detail Page
* Dashboard
* Report
* PDF
* Approval Workflow
ใช้ภาษาธุรกิจชุดเดียวกัน
หลัง Task นี้
Terminology ถือว่า Frozen
การเปลี่ยนคำในอนาคตต้องผ่าน ADR หรือ Business Review
---
# Background
CRM ได้ผ่าน
```txt
Task D
Task E
Task F
Task G
Task H
Task I
Task J
```
แล้ว
ปัจจุบันมีการใช้คำผสมกัน:
```txt
Lead
Enquiry
Opportunity
Quotation
Customer
Project Party
Billing Customer
```
ซึ่งอาจสร้างความสับสนให้ผู้ใช้งาน
โดยเฉพาะ:
```txt
Marketing
Sales
Manager
Admin
```
---
# Scope UX.1.1 Terminology Registry
Create:
```txt
docs/business/crm-terminology.md
```
Document format:
| Code | English | Thai | Notes |
| ---- | ------- | ---- | ----- |
This file becomes the source of truth.
---
# Scope UX.1.2 Frozen CRM Business Terms
## CRM
Keep:
```txt
CRM
```
Do not translate.
---
## Dashboard
Keep:
```txt
Dashboard
```
---
## Lead
Display:
```txt
ลีด
```
Definition:
```txt
ข้อมูลลูกค้าเป้าหมายก่อนส่งต่อให้ฝ่ายขาย
```
---
## Enquiry
Display:
```txt
โอกาสขาย
```
Definition:
```txt
โอกาสทางการขายที่ได้รับมอบหมายให้ฝ่ายขายดำเนินการ
```
---
## Quotation
Display:
```txt
ใบเสนอราคา
```
---
## Approval
Display:
```txt
อนุมัติเอกสาร
```
---
## Customer
Display:
```txt
ลูกค้า
```
---
## Contact
Display:
```txt
ผู้ติดต่อ
```
---
## Billing Customer
Display:
```txt
ลูกค้าผู้รับใบเสนอราคา
```
Definition:
```txt
บริษัทที่ใช้สำหรับออกใบเสนอราคาและวางบิล
```
---
## Project Parties
Display:
```txt
ผู้เกี่ยวข้องในโครงการ
```
---
# Scope UX.1.3 Project Party Roles
## customer
Display:
```txt
ลูกค้า
```
---
## billing_customer
Display:
```txt
ลูกค้าผู้รับใบเสนอราคา
```
---
## end_customer
Display:
```txt
เจ้าของโครงการ
```
---
## contractor
Display:
```txt
ผู้รับเหมา
```
---
## consultant
Display:
```txt
ที่ปรึกษา
```
---
# Scope UX.1.4 Dashboard KPI Terms
## Lead Count
Display:
```txt
จำนวนลีด
```
---
## Enquiry Count
Display:
```txt
จำนวนโอกาสขาย
```
---
## Won Count
Display:
```txt
จำนวนงานที่ชนะ
```
---
## Lost Count
Display:
```txt
จำนวนงานที่แพ้
```
---
## Revenue
Display:
```txt
มูลค่าใบเสนอราคา
```
---
## Conversion Rate
Display:
```txt
อัตราการเปลี่ยนสถานะ
```
---
# Scope UX.1.5 Status Labels
## Lead
```txt
ใหม่
ติดต่อแล้ว
ผ่านการคัดกรอง
ไม่ผ่านการคัดกรอง
ส่งต่อฝ่ายขายแล้ว
```
---
## Enquiry
```txt
โอกาสขาย
ชนะ
แพ้
```
Mapped from:
```txt
lead
enquiry
closed_won
closed_lost
```
---
# Scope UX.1.6 Menu Labels
CRM Menu:
```txt
Dashboard
ลีด
โอกาสขาย
ใบเสนอราคา
อนุมัติเอกสาร
ตั้งค่า CRM
```
---
# Scope UX.1.7 Form Labels
Update labels only.
Do NOT rename:
```txt
database fields
api fields
typescript types
server service names
table names
```
Business label only.
---
# Scope UX.1.8 PDF Terminology
Update document preview and PDF rendering labels.
Examples:
```txt
Billing Customer
→ ลูกค้าผู้รับใบเสนอราคา
Project Parties
→ ผู้เกี่ยวข้องในโครงการ
Quotation
→ ใบเสนอราคา
```
Do not modify template mapping keys.
---
# Scope UX.1.9 Reports
Report headers must use:
```txt
Thai business terms
```
while keeping export column codes unchanged internally.
---
# Explicit Non-Scope
Do NOT:
```txt
Rename database schema
Rename API routes
Rename TS types
Rename service folders
Create i18n system
Translate admin technical screens
```
---
# Output
1. Files Added
2. Files Modified
3. Terminology Registry
4. Menu Changes
5. Dashboard Changes
6. Form Changes
7. PDF Changes
8. Report Changes
9. Remaining Risks
---
# Definition of Done
✔ CRM terminology registry exists
✔ Menu terminology standardized
✔ Lead terminology standardized
✔ Enquiry terminology standardized
✔ Project Party terminology standardized
✔ Dashboard terminology standardized
✔ PDF terminology standardized
✔ Report terminology standardized
✔ No database changes
✔ No API changes
✔ No schema rename

View File

@@ -56,12 +56,12 @@ export async function GET(request: NextRequest) {
if (section === 'revenue-analytics') { if (section === 'revenue-analytics') {
rows = [ rows = [
['Dimension', 'Customer Code', 'Customer Name', 'Revenue', 'Quotation Count', 'Won Revenue'], ['มิติรายงาน', 'รหัสลูกค้า', 'ชื่อลูกค้า', 'มูลค่าใบเสนอราคา', 'จำนวนใบเสนอราคา', 'มูลค่างานที่ชนะ'],
...[ ...[
['End Customer', ...data.revenueAnalytics.topEndCustomers], ['เจ้าของโครงการ', ...data.revenueAnalytics.topEndCustomers],
['Billing Customer', ...data.revenueAnalytics.topBillingCustomers], ['ลูกค้าผู้รับใบเสนอราคา', ...data.revenueAnalytics.topBillingCustomers],
['Contractor', ...data.revenueAnalytics.topContractors], ['ผู้รับเหมา', ...data.revenueAnalytics.topContractors],
['Consultant', ...data.revenueAnalytics.topConsultants] ['ที่ปรึกษา', ...data.revenueAnalytics.topConsultants]
].flatMap(([dimension, ...entries]) => ].flatMap(([dimension, ...entries]) =>
(entries as typeof data.revenueAnalytics.topEndCustomers).map((row) => [ (entries as typeof data.revenueAnalytics.topEndCustomers).map((row) => [
String(dimension), String(dimension),
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
]; ];
} else if (section === 'pipeline-funnel') { } else if (section === 'pipeline-funnel') {
rows = [ rows = [
['Stage', 'Count', 'Value', 'Conversion Rate'], ['ขั้นตอน', 'จำนวน', 'มูลค่า', 'อัตราการเปลี่ยนสถานะ'],
...data.funnel.map((row) => [ ...data.funnel.map((row) => [
row.label, row.label,
String(row.count), String(row.count),
@@ -85,7 +85,7 @@ export async function GET(request: NextRequest) {
]; ];
} else { } else {
rows = [ rows = [
['Sales Person', 'Lead Count', 'Enquiry Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'], ['ฝ่ายขาย', 'จำนวนลีด', 'จำนวนโอกาสขาย', 'จำนวนใบเสนอราคา', 'จำนวนใบเสนอราคาที่อนุมัติแล้ว', 'มูลค่างานที่ชนะ', 'อัตราการเปลี่ยนสถานะ'],
...data.salesRanking.map((row) => [ ...data.salesRanking.map((row) => [
row.salesPersonName, row.salesPersonName,
String(row.leadCount), String(row.leadCount),

View File

@@ -48,8 +48,8 @@ export default async function ApprovalDetailRoute({ params }: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Approval Detail' pageTitle='รายละเอียดการอนุมัติเอกสาร'
pageDescription='Workflow steps, current approver, and timeline for a production approval request.' pageDescription='ลำดับขั้น ผู้อนุมัติปัจจุบัน และไทม์ไลน์ของคำขออนุมัติเอกสาร'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -26,8 +26,8 @@ export default async function ApprovalsRoute(props: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Approvals' pageTitle='อนุมัติเอกสาร'
pageDescription='Production approval queue for quotations, with real workflow routing and action history.' pageDescription='คิวงานอนุมัติเอกสารสำหรับใบเสนอราคา พร้อมลำดับอนุมัติและประวัติการดำเนินการ'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -48,8 +48,8 @@ export default async function CustomersRoute(props: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Customers' pageTitle='ลูกค้า'
pageDescription='Production customer registry with organization scoping, branch filters, contact management, and audit-backed history.' pageDescription='ทะเบียนลูกค้า พร้อมข้อมูลผู้ติดต่อ สาขาที่ดูแล และประวัติการใช้งานใน CRM'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -78,8 +78,8 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Enquiry Detail' pageTitle='รายละเอียดโอกาสขาย'
pageDescription='Lead or enquiry context, follow-up timeline, and ownership lifecycle.' pageDescription='ข้อมูลโอกาสขาย ไทม์ไลน์การติดตาม และสถานะการดูแลของฝ่ายขาย'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -61,8 +61,8 @@ export default async function EnquiriesRoute(props: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle="Enquiries" pageTitle="โอกาสขาย"
pageDescription="Sales workspace for active enquiries, follow-up workflow, and quotation preparation." pageDescription="พื้นที่ทำงานของฝ่ายขายสำหรับดูแลโอกาสขาย ติดตามความคืบหน้า และเตรียมใบเสนอราคา"
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center"> <div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">

View File

@@ -66,8 +66,8 @@ export default async function LeadDetailRoute({ params }: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Lead Detail' pageTitle='รายละเอียดลีด'
pageDescription='Marketing lead information and assignment handoff context.' pageDescription='ข้อมูลลีดสำหรับการคัดกรอง ติดตาม และส่งต่อให้ฝ่ายขาย'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -53,8 +53,8 @@ export default async function LeadsRoute(props: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Leads' pageTitle='ลีด'
pageDescription='Marketing workspace for lead collection, qualification, nurturing, and sales assignment.' pageDescription='พื้นที่ทำงานของการตลาดสำหรับเก็บลีด คัดกรองความพร้อม และส่งต่อให้ฝ่ายขาย'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -49,7 +49,7 @@ export default async function CrmDashboardRoute(props: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='CRM Dashboard' pageTitle='CRM Dashboard'
pageDescription='Production KPI dashboard, revenue analytics, approval visibility, and sales insights from real CRM data.' pageDescription='Dashboard กลางสำหรับติดตาม KPI, มูลค่าใบเสนอราคา, งานอนุมัติเอกสาร, และภาพรวมการขายจากข้อมูล CRM จริง'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -135,8 +135,8 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Quotation Detail' pageTitle='รายละเอียดใบเสนอราคา'
pageDescription='Commercial detail, child records, revisions, and audit activity.' pageDescription='ข้อมูลใบเสนอราคา รายการย่อย revision และประวัติการดำเนินการที่เกี่ยวข้อง'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -48,8 +48,8 @@ export default async function QuotationsRoute(props: PageProps) {
return ( return (
<PageContainer <PageContainer
pageTitle='Quotations' pageTitle='ใบเสนอราคา'
pageDescription='Production quotation register with revisions, related parties, and server-calculated items.' pageDescription='ทะเบียนใบเสนอราคา พร้อม revision, ผู้เกี่ยวข้องในโครงการ, และรายการคำนวณจากระบบ'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -84,7 +84,7 @@ export const navGroups: NavGroup[] = [
label: "CRM", label: "CRM",
items: [ items: [
{ {
title: "Dashboard CRM", title: "Dashboard",
url: "/dashboard/crm", url: "/dashboard/crm",
icon: "dashboard", icon: "dashboard",
isActive: false, isActive: false,
@@ -92,7 +92,7 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.dashboard.read" }, access: { requireOrg: true, permission: "crm.dashboard.read" },
}, },
{ {
title: "Customers", title: "ลูกค้า",
url: "/dashboard/crm/customers", url: "/dashboard/crm/customers",
icon: "teams", icon: "teams",
isActive: false, isActive: false,
@@ -100,7 +100,7 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.customer.read" }, access: { requireOrg: true, permission: "crm.customer.read" },
}, },
{ {
title: "Leads", title: "ลีด",
url: "/dashboard/crm/leads", url: "/dashboard/crm/leads",
icon: "forms", icon: "forms",
isActive: false, isActive: false,
@@ -108,7 +108,7 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.lead.read" }, access: { requireOrg: true, permission: "crm.lead.read" },
}, },
{ {
title: "Enquiries", title: "โอกาสขาย",
url: "/dashboard/crm/enquiries", url: "/dashboard/crm/enquiries",
icon: "forms", icon: "forms",
isActive: false, isActive: false,
@@ -116,7 +116,7 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.enquiry.read" }, access: { requireOrg: true, permission: "crm.enquiry.read" },
}, },
{ {
title: "Quotations", title: "ใบเสนอราคา",
url: "/dashboard/crm/quotations", url: "/dashboard/crm/quotations",
icon: "post", icon: "post",
isActive: false, isActive: false,
@@ -124,7 +124,7 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, role: "admin" }, access: { requireOrg: true, role: "admin" },
}, },
{ {
title: "Approvals", title: "อนุมัติเอกสาร",
url: "/dashboard/crm/approvals", url: "/dashboard/crm/approvals",
icon: "checks", icon: "checks",
isActive: false, isActive: false,
@@ -132,7 +132,7 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.approval.read" }, access: { requireOrg: true, permission: "crm.approval.read" },
}, },
{ {
title: "CRM Settings", title: "ตั้งค่า CRM",
url: "/dashboard/crm/settings/master-options", url: "/dashboard/crm/settings/master-options",
icon: "settings", icon: "settings",
isActive: false, isActive: false,

View File

@@ -75,19 +75,19 @@ export function ProjectPartiesEditor({
<div className='space-y-3'> <div className='space-y-3'>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<div> <div>
<div className='text-sm font-medium'>Project Parties</div> <div className='text-sm font-medium'></div>
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
Add related companies with their role and optional remark.
</p> </p>
</div> </div>
<Button type='button' variant='outline' size='sm' onClick={addItem}> <Button type='button' variant='outline' size='sm' onClick={addItem}>
<Icons.add className='mr-2 h-4 w-4' /> Add Party <Icons.add className='mr-2 h-4 w-4' />
</Button> </Button>
</div> </div>
{!items.length ? ( {!items.length ? (
<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'>
No project parties yet. Billing customer will still stay separate from this list.
</div> </div>
) : null} ) : null}
@@ -95,14 +95,14 @@ export function ProjectPartiesEditor({
{items.map((item, index) => ( {items.map((item, index) => (
<div key={item.key} className='rounded-lg border p-4'> <div key={item.key} className='rounded-lg border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'> <div className='mb-3 flex items-center justify-between gap-3'>
<div className='text-sm font-medium'>Party {index + 1}</div> <div className='text-sm font-medium'> {index + 1}</div>
<Button <Button
type='button' type='button'
variant='ghost' variant='ghost'
size='sm' size='sm'
onClick={() => removeItem(item.key)} onClick={() => removeItem(item.key)}
> >
<Icons.trash className='mr-2 h-4 w-4' /> Remove <Icons.trash className='mr-2 h-4 w-4' />
</Button> </Button>
</div> </div>
<div className='grid gap-3 md:grid-cols-3'> <div className='grid gap-3 md:grid-cols-3'>
@@ -113,10 +113,10 @@ export function ProjectPartiesEditor({
} }
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder='Customer' /> <SelectValue placeholder='ลูกค้า' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__none__'>Select customer</SelectItem> <SelectItem value='__none__'></SelectItem>
{customers.map((customer) => ( {customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}> <SelectItem key={customer.id} value={customer.id}>
{customer.name} ({customer.code}) {customer.name} ({customer.code})
@@ -127,10 +127,10 @@ export function ProjectPartiesEditor({
<Select value={item.role || '__none__'} onValueChange={(value) => updateItem(item.key, 'role', value === '__none__' ? '' : value)}> <Select value={item.role || '__none__'} onValueChange={(value) => updateItem(item.key, 'role', value === '__none__' ? '' : value)}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder='Role' /> <SelectValue placeholder='บทบาท' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__none__'>Select role</SelectItem> <SelectItem value='__none__'></SelectItem>
{roles.map((role) => ( {roles.map((role) => (
<SelectItem key={role.id} value={role.id}> <SelectItem key={role.id} value={role.id}>
{role.label} {role.label}
@@ -142,7 +142,7 @@ export function ProjectPartiesEditor({
<Input <Input
value={item.remark} value={item.remark}
onChange={(event) => updateItem(item.key, 'remark', event.target.value)} onChange={(event) => updateItem(item.key, 'remark', event.target.value)}
placeholder='Remark' placeholder='หมายเหตุ'
/> />
</div> </div>
</div> </div>

View File

@@ -51,21 +51,21 @@ export function ContactFormSheet({
const createMutation = useMutation({ const createMutation = useMutation({
...createCustomerContactMutation, ...createCustomerContactMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Contact created successfully'); toast.success('สร้างผู้ติดต่อสำเร็จ');
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create contact') toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างผู้ติดต่อได้')
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
...updateCustomerContactMutation, ...updateCustomerContactMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Contact updated successfully'); toast.success('อัปเดตผู้ติดต่อสำเร็จ');
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update contact') toast.error(error instanceof Error ? error.message : 'ไม่สามารถอัปเดตผู้ติดต่อได้')
}); });
const form = useAppForm({ const form = useAppForm({
@@ -106,43 +106,41 @@ export function ContactFormSheet({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-2xl'> <SheetContent className='flex flex-col sm:max-w-2xl'>
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? 'Edit Contact' : 'New Contact'}</SheetTitle> <SheetTitle>{isEdit ? 'แก้ไขผู้ติดต่อ' : 'เพิ่มผู้ติดต่อ'}</SheetTitle>
<SheetDescription> <SheetDescription></SheetDescription>
Capture the operational contact person for this customer account.
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 overflow-auto'> <div className='flex-1 overflow-auto'>
<form.AppForm> <form.AppForm>
<form.Form id='customer-contact-form' className='grid gap-4 md:grid-cols-2'> <form.Form id='customer-contact-form' className='grid gap-4 md:grid-cols-2'>
<FormTextField name='name' label='Contact Name' required placeholder='Jane Doe' /> <FormTextField name='name' label='ชื่อผู้ติดต่อ' required placeholder='Jane Doe' />
<FormTextField name='position' label='Position' placeholder='Procurement Manager' /> <FormTextField name='position' label='ตำแหน่ง' placeholder='Procurement Manager' />
<FormTextField name='department' label='Department' placeholder='Procurement' /> <FormTextField name='department' label='แผนก' placeholder='Procurement' />
<FormTextField name='phone' label='Phone' placeholder='02-000-0000' /> <FormTextField name='phone' label='โทรศัพท์' placeholder='02-000-0000' />
<FormTextField name='mobile' label='Mobile' placeholder='081-000-0000' /> <FormTextField name='mobile' label='มือถือ' placeholder='081-000-0000' />
<FormTextField <FormTextField
name='email' name='email'
label='Email' label='อีเมล'
type='email' type='email'
placeholder='jane@example.com' placeholder='jane@example.com'
/> />
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormTextareaField <FormTextareaField
name='notes' name='notes'
label='Notes' label='หมายเหตุ'
placeholder='Preferred communication style, escalation notes, or meeting context' placeholder='รูปแบบการติดต่อที่เหมาะสม หมายเหตุการประสานงาน หรือบริบทการประชุม'
/> />
</div> </div>
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'> <div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
<FormSwitchField <FormSwitchField
name='isPrimary' name='isPrimary'
label='Primary Contact' label='ผู้ติดต่อหลัก'
description='When enabled, this contact becomes the main person for this customer.' description='เมื่อเปิดใช้งาน ผู้ติดต่อนี้จะเป็นผู้ติดต่อหลักของลูกค้ารายนี้'
/> />
<FormSwitchField <FormSwitchField
name='isActive' name='isActive'
label='Active Contact' label='ใช้งานอยู่'
description='Inactive contacts remain in history and can be restored later.' description='ผู้ติดต่อที่ปิดใช้งานยังคงอยู่ในประวัติและเปิดกลับมาใช้ได้ภายหลัง'
/> />
</div> </div>
</form.Form> </form.Form>
@@ -151,11 +149,11 @@ export function ContactFormSheet({
<SheetFooter> <SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}> <Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button> </Button>
<Button type='submit' form='customer-contact-form' isLoading={isPending}> <Button type='submit' form='customer-contact-form' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' /> <Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Contact' : 'Create Contact'} {isEdit ? 'อัปเดตผู้ติดต่อ' : 'สร้างผู้ติดต่อ'}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>

View File

@@ -36,11 +36,11 @@ export function CustomerCellAction({
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...deleteCustomerMutation, ...deleteCustomerMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Customer deleted successfully'); toast.success('ลบลูกค้าสำเร็จ');
setDeleteOpen(false); setDeleteOpen(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete customer') toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบลูกค้าได้')
}); });
return ( return (
@@ -65,15 +65,15 @@ export function CustomerCellAction({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/customers/${data.id}`)}> <DropdownMenuItem onClick={() => router.push(`/dashboard/crm/customers/${data.id}`)}>
<Icons.arrowRight className='mr-2 h-4 w-4' /> View <Icons.arrowRight className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}> <DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit <Icons.edit className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}> <DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete <Icons.trash className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -29,7 +29,7 @@ export function getCustomerColumns({
id: 'name', id: 'name',
accessorKey: 'name', accessorKey: 'name',
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => ( header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' /> <DataTableColumnHeader column={column} title='ลูกค้า' />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className='flex flex-col'>
@@ -46,8 +46,8 @@ export function getCustomerColumns({
</div> </div>
), ),
meta: { meta: {
label: 'Customer', label: 'ลูกค้า',
placeholder: 'Search customers...', placeholder: 'ค้นหาลูกค้า...',
variant: 'text' as const, variant: 'text' as const,
icon: Icons.search icon: Icons.search
}, },
@@ -85,7 +85,7 @@ export function getCustomerColumns({
</Badge> </Badge>
), ),
meta: { meta: {
label: 'Type', label: 'ประเภท',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.customerTypes.map((item) => ({ options: referenceData.customerTypes.map((item) => ({
value: item.id, value: item.id,
@@ -108,7 +108,7 @@ export function getCustomerColumns({
</span> </span>
), ),
meta: { meta: {
label: 'Branch', label: 'สาขา',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.branches.map((item) => ({ options: referenceData.branches.map((item) => ({
value: item.id, value: item.id,
@@ -137,7 +137,7 @@ export function getCustomerColumns({
id: 'contactCount', id: 'contactCount',
accessorKey: 'contactCount', accessorKey: 'contactCount',
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => ( header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Contacts' /> <DataTableColumnHeader column={column} title='ผู้ติดต่อ' />
), ),
cell: ({ row }) => <span>{row.original.contactCount}</span> cell: ({ row }) => <span>{row.original.contactCount}</span>
}, },

View File

@@ -32,20 +32,20 @@ export function CustomerContactsTab({
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...deleteCustomerContactMutation, ...deleteCustomerContactMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Contact deleted successfully'); toast.success('ลบผู้ติดต่อสำเร็จ');
setDeleteOpen(false); setDeleteOpen(false);
setSelectedId(null); setSelectedId(null);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete contact') toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบผู้ติดต่อได้')
}); });
return ( return (
<Card> <Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'> <CardHeader className='flex flex-row items-start justify-between gap-4'>
<div> <div>
<CardTitle>Contacts</CardTitle> <CardTitle></CardTitle>
<CardDescription>Manage the people attached to this customer account.</CardDescription> <CardDescription></CardDescription>
</div> </div>
{canCreate ? ( {canCreate ? (
<Button <Button
@@ -55,14 +55,14 @@ export function CustomerContactsTab({
setOpen(true); setOpen(true);
}} }}
> >
<Icons.add className='mr-2 h-4 w-4' /> Add Contact <Icons.add className='mr-2 h-4 w-4' />
</Button> </Button>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{!data.items.length ? ( {!data.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No contacts have been added yet.
</div> </div>
) : ( ) : (
data.items.map((contact) => ( data.items.map((contact) => (
@@ -73,16 +73,16 @@ export function CustomerContactsTab({
<div className='space-y-1'> <div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{contact.name}</span> <span className='font-medium'>{contact.name}</span>
{contact.isPrimary ? <Badge>Primary</Badge> : null} {contact.isPrimary ? <Badge></Badge> : null}
{!contact.isActive ? <Badge variant='outline'>Inactive</Badge> : null} {!contact.isActive ? <Badge variant='outline'></Badge> : null}
</div> </div>
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
{[contact.position, contact.department].filter(Boolean).join(' • ') || {[contact.position, contact.department].filter(Boolean).join(' • ') ||
'No role details'} 'ยังไม่มีข้อมูลตำแหน่ง'}
</div> </div>
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
{[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') || {[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') ||
'No contact methods'} 'ยังไม่มีช่องทางติดต่อ'}
</div> </div>
{contact.notes ? <p className='text-sm'>{contact.notes}</p> : null} {contact.notes ? <p className='text-sm'>{contact.notes}</p> : null}
</div> </div>
@@ -96,7 +96,7 @@ export function CustomerContactsTab({
setOpen(true); setOpen(true);
}} }}
> >
<Icons.edit className='mr-2 h-4 w-4' /> Edit <Icons.edit className='mr-2 h-4 w-4' />
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -107,7 +107,7 @@ export function CustomerContactsTab({
setDeleteOpen(true); setDeleteOpen(true);
}} }}
> >
<Icons.trash className='mr-2 h-4 w-4' /> Delete <Icons.trash className='mr-2 h-4 w-4' />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -100,14 +100,14 @@ export function CustomerDetail({
<div> <div>
<h2 className='text-2xl font-semibold'>{customer.name}</h2> <h2 className='text-2xl font-semibold'>{customer.name}</h2>
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
{customer.abbr || customer.email || customer.phone || 'Customer detail record'} {customer.abbr || customer.email || customer.phone || 'รายละเอียดข้อมูลลูกค้า'}
</p> </p>
</div> </div>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{canUpdate ? ( {canUpdate ? (
<Button variant='outline' onClick={() => setEditOpen(true)}> <Button variant='outline' onClick={() => setEditOpen(true)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit Customer <Icons.edit className='mr-2 h-4 w-4' />
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -119,18 +119,16 @@ export function CustomerDetail({
<CardContent className='pt-6'> <CardContent className='pt-6'>
<Tabs defaultValue='overview' className='gap-4'> <Tabs defaultValue='overview' className='gap-4'>
<TabsList> <TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger> <TabsTrigger value='overview'></TabsTrigger>
{canReadContacts ? <TabsTrigger value='contacts'>Contacts</TabsTrigger> : null} {canReadContacts ? <TabsTrigger value='contacts'></TabsTrigger> : null}
<TabsTrigger value='activity'>Activity</TabsTrigger> <TabsTrigger value='activity'></TabsTrigger>
<TabsTrigger value='related'>Related Documents</TabsTrigger> <TabsTrigger value='related'></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='overview'> <TabsContent value='overview'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Overview</CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription> CRM</CardDescription>
Core account data, location, and CRM classification.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Tax ID' value={customer.taxId} /> <FieldItem label='Tax ID' value={customer.taxId} />
@@ -142,21 +140,21 @@ export function CustomerDetail({
value={customer.branchId ? branchMap.get(customer.branchId) : null} value={customer.branchId ? branchMap.get(customer.branchId) : null}
/> />
<FieldItem <FieldItem
label='Lead Channel' label='ที่มาของลีด'
value={leadMap.get(customer.leadChannel ?? '')} value={leadMap.get(customer.leadChannel ?? '')}
/> />
<FieldItem <FieldItem
label='Customer Group' label='กลุ่มลูกค้า'
value={groupMap.get(customer.customerGroup ?? '')} value={groupMap.get(customer.customerGroup ?? '')}
/> />
<FieldItem <FieldItem
label='Customer Sub Group' label='กลุ่มย่อยลูกค้า'
value={subGroupMap.get(customer.customerSubGroup ?? '')} value={subGroupMap.get(customer.customerSubGroup ?? '')}
/> />
<FieldItem label='Active' value={customer.isActive ? 'Yes' : 'No'} /> <FieldItem label='ใช้งานอยู่' value={customer.isActive ? 'ใช่' : 'ไม่ใช่'} />
<div className='md:col-span-2 xl:col-span-4'> <div className='md:col-span-2 xl:col-span-4'>
<FieldItem <FieldItem
label='Address' label='ที่อยู่'
value={ value={
[ [
customer.address, customer.address,
@@ -172,7 +170,7 @@ export function CustomerDetail({
/> />
</div> </div>
<div className='md:col-span-2 xl:col-span-4'> <div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Notes' value={customer.notes} /> <FieldItem label='หมายเหตุ' value={customer.notes} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -190,15 +188,13 @@ export function CustomerDetail({
<TabsContent value='activity'> <TabsContent value='activity'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Activity</CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
Audit log entries captured from customer mutations.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{!data.activity.length ? ( {!data.activity.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No activity recorded yet.
</div> </div>
) : ( ) : (
data.activity.map((item, index) => ( data.activity.map((item, index) => (
@@ -229,15 +225,13 @@ export function CustomerDetail({
<TabsContent value='related'> <TabsContent value='related'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Related Documents</CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
Production enquiry and quotation links for this customer.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!relatedEnquiries.length && !relatedQuotations.length ? ( {!relatedEnquiries.length && !relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No enquiries or quotations have been linked to this customer yet.
</div> </div>
) : ( ) : (
<div className='space-y-3'> <div className='space-y-3'>
@@ -288,8 +282,8 @@ export function CustomerDetail({
<div className='space-y-6'> <div className='space-y-6'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Record Snapshot</CardTitle> <CardTitle></CardTitle>
<CardDescription>Operational metadata for this customer row.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<FieldItem label='Created By' value={customer.createdBy} /> <FieldItem label='Created By' value={customer.createdBy} />

View File

@@ -73,21 +73,21 @@ export function CustomerFormSheet({
const createMutation = useMutation({ const createMutation = useMutation({
...createCustomerMutation, ...createCustomerMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Customer created successfully'); toast.success('สร้างลูกค้าสำเร็จ');
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create customer') toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างลูกค้าได้')
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
...updateCustomerMutation, ...updateCustomerMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Customer updated successfully'); toast.success('อัปเดตลูกค้าสำเร็จ');
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update customer') toast.error(error instanceof Error ? error.message : 'ไม่สามารถอัปเดตลูกค้าได้')
}); });
const form = useAppForm({ const form = useAppForm({
@@ -146,10 +146,8 @@ export function CustomerFormSheet({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-3xl'> <SheetContent className='flex flex-col sm:max-w-3xl'>
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? 'Edit Customer' : 'New Customer'}</SheetTitle> <SheetTitle>{isEdit ? 'แก้ไขลูกค้า' : 'เพิ่มลูกค้า'}</SheetTitle>
<SheetDescription> <SheetDescription> CRM</SheetDescription>
Maintain the core account profile, ownership branch, and CRM classification here.
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 overflow-auto'> <div className='flex-1 overflow-auto'>
@@ -157,25 +155,25 @@ export function CustomerFormSheet({
<form.Form id='customer-form-sheet' className='grid gap-4 md:grid-cols-2'> <form.Form id='customer-form-sheet' className='grid gap-4 md:grid-cols-2'>
<FormTextField <FormTextField
name='name' name='name'
label='Customer Name' label='ชื่อลูกค้า'
required required
placeholder='ALLA Service Co., Ltd.' placeholder='ALLA Service Co., Ltd.'
/> />
<FormTextField name='abbr' label='Abbreviation' placeholder='ALLA' /> <FormTextField name='abbr' label='ชื่อย่อ' placeholder='ALLA' />
<FormTextField name='taxId' label='Tax ID' placeholder='0105556...' /> <FormTextField name='taxId' label='เลขประจำตัวผู้เสียภาษี' placeholder='0105556...' />
<FormTextField name='phone' label='Phone' placeholder='02-000-0000' /> <FormTextField name='phone' label='โทรศัพท์' placeholder='02-000-0000' />
<FormTextField name='fax' label='Fax' placeholder='02-000-0001' /> <FormTextField name='fax' label='แฟกซ์' placeholder='02-000-0001' />
<FormTextField <FormTextField
name='email' name='email'
label='Email' label='อีเมล'
type='email' type='email'
placeholder='sales@example.com' placeholder='sales@example.com'
/> />
<FormTextField name='website' label='Website' placeholder='https://example.com' /> <FormTextField name='website' label='เว็บไซต์' placeholder='https://example.com' />
<FormTextField name='country' label='Country' placeholder='Thailand' /> <FormTextField name='country' label='ประเทศ' placeholder='Thailand' />
<FormSelectField <FormSelectField
name='customerType' name='customerType'
label='Customer Type' label='ประเภทลูกค้า'
required required
options={referenceData.customerTypes.map((item) => ({ options={referenceData.customerTypes.map((item) => ({
value: item.id, value: item.id,
@@ -184,7 +182,7 @@ export function CustomerFormSheet({
/> />
<FormSelectField <FormSelectField
name='customerStatus' name='customerStatus'
label='Customer Status' label='สถานะลูกค้า'
required required
options={referenceData.customerStatuses.map((item) => ({ options={referenceData.customerStatuses.map((item) => ({
value: item.id, value: item.id,
@@ -193,7 +191,7 @@ export function CustomerFormSheet({
/> />
<FormSelectField <FormSelectField
name='branchId' name='branchId'
label='Branch' label='สาขา'
options={referenceData.branches.map((branch) => ({ options={referenceData.branches.map((branch) => ({
value: branch.id, value: branch.id,
label: branch.name label: branch.name
@@ -201,7 +199,7 @@ export function CustomerFormSheet({
/> />
<FormSelectField <FormSelectField
name='leadChannel' name='leadChannel'
label='Lead Channel' label='ที่มาของลีด'
options={referenceData.leadChannels.map((item) => ({ options={referenceData.leadChannels.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label
@@ -209,7 +207,7 @@ export function CustomerFormSheet({
/> />
<FormSelectField <FormSelectField
name='customerGroup' name='customerGroup'
label='Customer Group' label='กลุ่มลูกค้า'
options={referenceData.customerGroups.map((item) => ({ options={referenceData.customerGroups.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label
@@ -217,10 +215,10 @@ export function CustomerFormSheet({
/> />
<FormSelectField <FormSelectField
name='customerSubGroup' name='customerSubGroup'
label='Customer Sub Group' label='กลุ่มย่อยลูกค้า'
disabled={!selectedCustomerGroup} disabled={!selectedCustomerGroup}
placeholder={ placeholder={
selectedCustomerGroup ? 'Select customer sub group' : 'Select customer group first' selectedCustomerGroup ? 'เลือกกลุ่มย่อยลูกค้า' : 'เลือกกลุ่มลูกค้าก่อน'
} }
options={filteredCustomerSubGroups.map((item) => ({ options={filteredCustomerSubGroups.map((item) => ({
value: item.id, value: item.id,
@@ -228,24 +226,24 @@ export function CustomerFormSheet({
}))} }))}
/> />
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormTextField name='address' label='Address' placeholder='123 Main Road' /> <FormTextField name='address' label='ที่อยู่' placeholder='123 Main Road' />
</div> </div>
<FormTextField name='province' label='Province' placeholder='Bangkok' /> <FormTextField name='province' label='จังหวัด' placeholder='Bangkok' />
<FormTextField name='district' label='District' placeholder='Bang Kapi' /> <FormTextField name='district' label='เขต / อำเภอ' placeholder='Bang Kapi' />
<FormTextField name='subDistrict' label='Sub District' placeholder='Hua Mak' /> <FormTextField name='subDistrict' label='แขวง / ตำบล' placeholder='Hua Mak' />
<FormTextField name='postalCode' label='Postal Code' placeholder='10240' /> <FormTextField name='postalCode' label='รหัสไปรษณีย์' placeholder='10240' />
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormTextareaField <FormTextareaField
name='notes' name='notes'
label='Notes' label='หมายเหตุ'
placeholder='Internal notes about this customer relationship' placeholder='หมายเหตุภายในเกี่ยวกับความสัมพันธ์กับลูกค้ารายนี้'
/> />
</div> </div>
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormSwitchField <FormSwitchField
name='isActive' name='isActive'
label='Active Record' label='ใช้งานอยู่'
description='Inactive customers remain visible in history but should not be used for new work.' description='ลูกค้าที่ปิดใช้งานยังคงอยู่ในประวัติ แต่ไม่ควรนำไปใช้กับงานใหม่'
/> />
</div> </div>
</form.Form> </form.Form>
@@ -254,11 +252,11 @@ export function CustomerFormSheet({
<SheetFooter> <SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}> <Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button> </Button>
<Button type='submit' form='customer-form-sheet' isLoading={isPending}> <Button type='submit' form='customer-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' /> <Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Customer' : 'Create Customer'} {isEdit ? 'อัปเดตลูกค้า' : 'สร้างลูกค้า'}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -276,7 +274,7 @@ export function CustomerFormSheetTrigger({
return ( return (
<> <>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> Add Customer <Icons.add className='mr-2 h-4 w-4' />
</Button> </Button>
<CustomerFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} /> <CustomerFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
</> </>

View File

@@ -24,42 +24,42 @@ export function DashboardApprovalMetrics({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Approval Analytics</CardTitle> <CardTitle></CardTitle>
<CardDescription>Live workflow counts plus the current approver queue.</CardDescription> <CardDescription> workflow </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='space-y-6'>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-5'> <div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-5'>
<Stat label='Pending Approvals' value={approvals.summary.pendingApprovals} /> <Stat label='รออนุมัติ' value={approvals.summary.pendingApprovals} />
<Stat label='Approved Today' value={approvals.summary.approvedToday} /> <Stat label='อนุมัติวันนี้' value={approvals.summary.approvedToday} />
<Stat label='Rejected Today' value={approvals.summary.rejectedToday} /> <Stat label='ไม่อนุมัติวันนี้' value={approvals.summary.rejectedToday} />
<Stat label='Returned Today' value={approvals.summary.returnedToday} /> <Stat label='ตีกลับวันนี้' value={approvals.summary.returnedToday} />
<Stat <Stat
label='Average Approval Time' label='เวลาอนุมัติเฉลี่ย'
value={approvals.summary.averageApprovalTimeHours} value={approvals.summary.averageApprovalTimeHours}
suffix='hrs' suffix='ชม.'
/> />
</div> </div>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Document</TableHead> <TableHead></TableHead>
<TableHead>Workflow</TableHead> <TableHead>Workflow</TableHead>
<TableHead>Current Step</TableHead> <TableHead></TableHead>
<TableHead>Requested At</TableHead> <TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{approvals.myPendingApprovals.length === 0 ? ( {approvals.myPendingApprovals.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className='text-muted-foreground text-center'> <TableCell colSpan={4} className='text-muted-foreground text-center'>
No pending approvals are assigned to your current approver scope.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
approvals.myPendingApprovals.map((row) => ( approvals.myPendingApprovals.map((row) => (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell> <TableCell>
<div className='font-medium'>{row.entityCode ?? 'Approval'}</div> <div className='font-medium'>{row.entityCode ?? 'อนุมัติเอกสาร'}</div>
<div className='text-muted-foreground text-xs'>{row.entityTitle ?? '-'}</div> <div className='text-muted-foreground text-xs'>{row.entityTitle ?? '-'}</div>
</TableCell> </TableCell>
<TableCell>{row.workflowName}</TableCell> <TableCell>{row.workflowName}</TableCell>

View File

@@ -62,10 +62,10 @@ export function DashboardFilters({
onValueChange={(value) => void setParams({ branch: value === '__all__' ? null : value })} onValueChange={(value) => void setParams({ branch: value === '__all__' ? null : value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder='Branch' /> <SelectValue placeholder='สาขา' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__all__'>All Branches</SelectItem> <SelectItem value='__all__'></SelectItem>
{referenceData.branches.map((item) => ( {referenceData.branches.map((item) => (
<SelectItem key={item.id} value={item.id}> <SelectItem key={item.id} value={item.id}>
{item.label} {item.label}
@@ -78,10 +78,10 @@ export function DashboardFilters({
onValueChange={(value) => void setParams({ salesman: value === '__all__' ? null : value })} onValueChange={(value) => void setParams({ salesman: value === '__all__' ? null : value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder='Sales Person' /> <SelectValue placeholder='ฝ่ายขาย' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__all__'>All Sales</SelectItem> <SelectItem value='__all__'></SelectItem>
{referenceData.salesmen.map((item) => ( {referenceData.salesmen.map((item) => (
<SelectItem key={item.id} value={item.id}> <SelectItem key={item.id} value={item.id}>
{item.name} {item.name}
@@ -96,10 +96,10 @@ export function DashboardFilters({
} }
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder='Project Party Role' /> <SelectValue placeholder='บทบาทผู้เกี่ยวข้องในโครงการ' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__all__'>All Party Roles</SelectItem> <SelectItem value='__all__'></SelectItem>
{referenceData.projectPartyRoles.map((item) => ( {referenceData.projectPartyRoles.map((item) => (
<SelectItem key={item.id} value={item.id}> <SelectItem key={item.id} value={item.id}>
{item.label} {item.label}
@@ -112,10 +112,10 @@ export function DashboardFilters({
onValueChange={(value) => void setParams({ productType: value === '__all__' ? null : value })} onValueChange={(value) => void setParams({ productType: value === '__all__' ? null : value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder='Product Type' /> <SelectValue placeholder='ประเภทสินค้า' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__all__'>All Product Types</SelectItem> <SelectItem value='__all__'></SelectItem>
{referenceData.productTypes.map((item) => ( {referenceData.productTypes.map((item) => (
<SelectItem key={item.id} value={item.id}> <SelectItem key={item.id} value={item.id}>
{item.label} {item.label}
@@ -138,7 +138,7 @@ export function DashboardFilters({
}) })
} }
> >
Reset Filters
</Button> </Button>
{canExport ? ( {canExport ? (
<> <>
@@ -148,7 +148,7 @@ export function DashboardFilters({
exportQuery ? `&${exportQuery}` : '' exportQuery ? `&${exportQuery}` : ''
}`} }`}
> >
<Icons.download className='mr-2 h-4 w-4' /> Export Sales CSV <Icons.download className='mr-2 h-4 w-4' /> CSV
</Link> </Link>
</Button> </Button>
<Button asChild variant='outline'> <Button asChild variant='outline'>
@@ -157,7 +157,7 @@ export function DashboardFilters({
exportQuery ? `&${exportQuery}` : '' exportQuery ? `&${exportQuery}` : ''
}`} }`}
> >
<Icons.download className='mr-2 h-4 w-4' /> Export Revenue Excel <Icons.download className='mr-2 h-4 w-4' /> Excel
</Link> </Link>
</Button> </Button>
<Button asChild variant='outline'> <Button asChild variant='outline'>
@@ -166,7 +166,7 @@ export function DashboardFilters({
exportQuery ? `&${exportQuery}` : '' exportQuery ? `&${exportQuery}` : ''
}`} }`}
> >
<Icons.download className='mr-2 h-4 w-4' /> Export Funnel CSV <Icons.download className='mr-2 h-4 w-4' /> CSV Funnel
</Link> </Link>
</Button> </Button>
</> </>

View File

@@ -21,31 +21,31 @@ export function DashboardFollowups({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Follow-up Dashboard</CardTitle> <CardTitle>Dashboard </CardTitle>
<CardDescription>Upcoming and completed CRM follow-ups across enquiries and quotations.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='space-y-6'>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'> <div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
<Stat label='Due Today' value={followups.summary.dueToday} /> <Stat label='ครบกำหนดวันนี้' value={followups.summary.dueToday} />
<Stat label='Due This Week' value={followups.summary.dueThisWeek} /> <Stat label='ครบกำหนดสัปดาห์นี้' value={followups.summary.dueThisWeek} />
<Stat label='Overdue' value={followups.summary.overdue} /> <Stat label='เกินกำหนด' value={followups.summary.overdue} />
<Stat label='Completed' value={followups.summary.completed} /> <Stat label='เสร็จแล้ว' value={followups.summary.completed} />
</div> </div>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Date</TableHead> <TableHead></TableHead>
<TableHead>Customer</TableHead> <TableHead></TableHead>
<TableHead>Enquiry</TableHead> <TableHead></TableHead>
<TableHead>Enquiry Owner</TableHead> <TableHead></TableHead>
<TableHead>Source</TableHead> <TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{followups.upcoming.length === 0 ? ( {followups.upcoming.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className='text-muted-foreground text-center'> <TableCell colSpan={5} className='text-muted-foreground text-center'>
No upcoming follow-ups match the current filters.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (

View File

@@ -13,8 +13,8 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Sales Pipeline Funnel</CardTitle> <CardTitle>Funnel </CardTitle>
<CardDescription>Lead, enquiry, quotation, approval, and won stages from the current pipeline model.</CardDescription> <CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{steps.map((step) => ( {steps.map((step) => (
@@ -23,12 +23,12 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
<div> <div>
<div className='font-medium'>{step.label}</div> <div className='font-medium'>{step.label}</div>
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground text-xs'>
{formatNumber(step.count)} items, value {formatNumber(step.value)} {formatNumber(step.count)} , {formatNumber(step.value)}
</div> </div>
</div> </div>
<div className='text-right text-sm'> <div className='text-right text-sm'>
<div>{step.conversionRate === null ? '-' : `${formatNumber(step.conversionRate)}%`}</div> <div>{step.conversionRate === null ? '-' : `${formatNumber(step.conversionRate)}%`}</div>
<div className='text-muted-foreground text-xs'>conversion</div> <div className='text-muted-foreground text-xs'></div>
</div> </div>
</div> </div>
<div className='bg-muted h-2 overflow-hidden rounded-full'> <div className='bg-muted h-2 overflow-hidden rounded-full'>

View File

@@ -12,25 +12,25 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Top Active Enquiries</CardTitle> <CardTitle></CardTitle>
<CardDescription>Sales-owned hot enquiries ranked by estimated commercial value.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Project</TableHead> <TableHead></TableHead>
<TableHead>Customer</TableHead> <TableHead></TableHead>
<TableHead className='text-right'>Value</TableHead> <TableHead className='text-right'></TableHead>
<TableHead>Enquiry Owner</TableHead> <TableHead></TableHead>
<TableHead className='text-right'>Probability</TableHead> <TableHead className='text-right'></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className='text-muted-foreground text-center'> <TableCell colSpan={5} className='text-muted-foreground text-center'>
No hot opportunities match the current filters.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (

View File

@@ -28,16 +28,16 @@ function RevenueTable({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Customer</TableHead> <TableHead>Customer</TableHead>
<TableHead className='text-right'>Revenue</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Quotations</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Won Revenue</TableHead> <TableHead className='text-right'></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className='text-muted-foreground text-center'> <TableCell colSpan={4} className='text-muted-foreground text-center'>
No revenue rows match the current filters.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -68,23 +68,23 @@ export function DashboardRevenueCards({
return ( return (
<div className='grid gap-4 xl:grid-cols-2'> <div className='grid gap-4 xl:grid-cols-2'>
<RevenueTable <RevenueTable
title='Top End Customers' title='เจ้าของโครงการมูลค่าสูงสุด'
description='Revenue owner attribution with billing-customer fallback when end-customer is missing.' description='จัดอันดับตามเจ้าของโครงการ โดยใช้ลูกค้าผู้รับใบเสนอราคาเป็น fallback เมื่อไม่มี end customer'
rows={revenueAnalytics.topEndCustomers} rows={revenueAnalytics.topEndCustomers}
/> />
<RevenueTable <RevenueTable
title='Top Billing Customers' title='ลูกค้าผู้รับใบเสนอราคามูลค่าสูงสุด'
description='Commercial relationship view grouped by billing customer.' description='มุมมองเชิงพาณิชย์แบบจัดกลุ่มตามลูกค้าผู้รับใบเสนอราคา'
rows={revenueAnalytics.topBillingCustomers} rows={revenueAnalytics.topBillingCustomers}
/> />
<RevenueTable <RevenueTable
title='Top Contractors' title='ผู้รับเหมามูลค่าสูงสุด'
description='Relationship analytics with full attribution for each contractor role.' description='วิเคราะห์มูลค่าตามบทบาทผู้รับเหมาในโครงการ'
rows={revenueAnalytics.topContractors} rows={revenueAnalytics.topContractors}
/> />
<RevenueTable <RevenueTable
title='Top Consultants' title='ที่ปรึกษามูลค่าสูงสุด'
description='Consultant-side revenue relationships from project parties.' description='วิเคราะห์มูลค่าตามบทบาทที่ปรึกษาในโครงการ'
rows={revenueAnalytics.topConsultants} rows={revenueAnalytics.topConsultants}
/> />
</div> </div>

View File

@@ -12,27 +12,27 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Sales Ranking</CardTitle> <CardTitle></CardTitle>
<CardDescription>Lead and enquiry ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription> <CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Sales Person</TableHead> <TableHead></TableHead>
<TableHead className='text-right'>Lead</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Enquiry</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Quotation</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Approved</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Won Revenue</TableHead> <TableHead className='text-right'></TableHead>
<TableHead className='text-right'>Conversion</TableHead> <TableHead className='text-right'></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className='text-muted-foreground text-center'> <TableCell colSpan={7} className='text-muted-foreground text-center'>
No sales ranking rows match the current filters.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (

View File

@@ -52,47 +52,47 @@ export function DashboardSummaryCards({
return ( return (
<div className={`grid gap-4 ${canViewCommercialData ? 'xl:grid-cols-2 2xl:grid-cols-4' : 'xl:grid-cols-2'}`}> <div className={`grid gap-4 ${canViewCommercialData ? 'xl:grid-cols-2 2xl:grid-cols-4' : 'xl:grid-cols-2'}`}>
<SummaryCard <SummaryCard
title='Lead' title='ลีด'
description='Marketing-owned lead-stage records based on pipeline stage.' description='รายการที่ยังอยู่ในขั้นลีดและอยู่ในความรับผิดชอบของการตลาด'
href='/dashboard/crm/leads' href='/dashboard/crm/leads'
items={[ items={[
{ label: 'Lead Count', value: summary.lead.leadCount }, { label: 'จำนวนลีด', value: summary.lead.leadCount },
{ label: 'New Leads', value: summary.lead.newLeads }, { label: 'ลีดใหม่', value: summary.lead.newLeads },
{ label: 'Unassigned Leads', value: summary.lead.unassignedLeads }, { label: 'ลีดยังไม่มอบหมาย', value: summary.lead.unassignedLeads },
{ label: 'Lead Aging Avg', value: summary.lead.averageLeadAgingDays, suffix: 'days' } { label: 'อายุลีดเฉลี่ย', value: summary.lead.averageLeadAgingDays, suffix: 'วัน' }
]} ]}
/> />
<SummaryCard <SummaryCard
title='Enquiry' title='โอกาสขาย'
description='Sales-owned enquiry-stage records plus closed outcomes.' description='รายการที่อยู่ในขั้นโอกาสขาย รวมถึงผลลัพธ์ชนะและแพ้'
href='/dashboard/crm/enquiries' href='/dashboard/crm/enquiries'
items={[ items={[
{ label: 'Enquiry Count', value: summary.enquiry.enquiryCount }, { label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
{ label: 'Active Enquiries', value: summary.enquiry.activeEnquiries }, { label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
{ label: 'Closed Won', value: summary.enquiry.wonCount }, { label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
{ label: 'Closed Lost', value: summary.enquiry.lostCount } { label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount }
]} ]}
/> />
{canViewCommercialData ? ( {canViewCommercialData ? (
<SummaryCard <SummaryCard
title='Quotation' title='ใบเสนอราคา'
description='Live quotation status counts from production data.' description='สถานะของใบเสนอราคาจากข้อมูลจริงในระบบ'
items={[ items={[
{ label: 'Draft Quotations', value: summary.quotation.draftQuotations }, { label: 'ฉบับร่าง', value: summary.quotation.draftQuotations },
{ label: 'Pending Approval', value: summary.quotation.pendingApproval }, { label: 'รออนุมัติ', value: summary.quotation.pendingApproval },
{ label: 'Approved Quotations', value: summary.quotation.approvedQuotations }, { label: 'อนุมัติแล้ว', value: summary.quotation.approvedQuotations },
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations } { label: 'ส่งแล้ว', value: summary.quotation.sentQuotations }
]} ]}
/> />
) : null} ) : null}
{canViewCommercialData ? ( {canViewCommercialData ? (
<SummaryCard <SummaryCard
title='Revenue' title='มูลค่าใบเสนอราคา'
description='Quotation, won, and lost values using governed revenue rules.' description='มูลค่าใบเสนอราคา งานที่ชนะ และงานที่แพ้ตามกติกา revenue attribution'
items={[ items={[
{ label: 'Quotation Value', value: summary.revenue.quotationValue }, { label: 'มูลค่าใบเสนอราคา', value: summary.revenue.quotationValue },
{ label: 'Won Value', value: summary.revenue.wonValue }, { label: 'มูลค่างานที่ชนะ', value: summary.revenue.wonValue },
{ label: 'Lost Value', value: summary.revenue.lostValue } { label: 'มูลค่างานที่แพ้', value: summary.revenue.lostValue }
]} ]}
/> />
) : null} ) : null}

View File

@@ -30,6 +30,7 @@ import {
getRevenueByContractor, getRevenueByContractor,
getRevenueByEndCustomer getRevenueByEndCustomer
} from '@/features/crm/reporting/server/service'; } from '@/features/crm/reporting/server/service';
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
import type { import type {
CrmDashboardApprovalRow, CrmDashboardApprovalRow,
CrmDashboardApprovalSummary, CrmDashboardApprovalSummary,
@@ -332,19 +333,19 @@ function buildFunnel(
const steps = [ const steps = [
{ {
key: 'lead', key: 'lead',
label: 'Lead', label: getPipelineStageThaiLabel('lead'),
count: summary.lead.leadCount, count: summary.lead.leadCount,
value: 0 value: 0
}, },
{ {
key: 'enquiry', key: 'enquiry',
label: 'Enquiry', label: getPipelineStageThaiLabel('enquiry'),
count: summary.enquiry.enquiryCount, count: summary.enquiry.enquiryCount,
value: summary.enquiry.enquiryValue value: summary.enquiry.enquiryValue
}, },
{ {
key: 'quotation', key: 'quotation',
label: 'Quotation', label: 'ใบเสนอราคา',
count: scopedQuotations.filter( count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled' (row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled'
).length, ).length,
@@ -354,7 +355,7 @@ function buildFunnel(
}, },
{ {
key: 'pending_approval', key: 'pending_approval',
label: 'Pending Approval', label: 'รออนุมัติ',
count: scopedQuotations.filter( count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'pending_approval' (row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'pending_approval'
).length, ).length,
@@ -364,7 +365,7 @@ function buildFunnel(
}, },
{ {
key: 'approved', key: 'approved',
label: 'Approved', label: 'อนุมัติแล้ว',
count: scopedQuotations.filter( count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'approved' (row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'approved'
).length, ).length,
@@ -374,7 +375,7 @@ function buildFunnel(
}, },
{ {
key: 'closed_won', key: 'closed_won',
label: 'Closed Won', label: getPipelineStageThaiLabel('closed_won'),
count: summary.enquiry.wonCount, count: summary.enquiry.wonCount,
value: scopedQuotations value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted') .filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')

View File

@@ -57,21 +57,21 @@ export function EnquiryAssignmentDialog({
const assignMutation = useMutation({ const assignMutation = useMutation({
...assignEnquiryMutation, ...assignEnquiryMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Lead converted to enquiry successfully'); toast.success('ส่งต่อลีดไปยังฝ่ายขายสำเร็จ');
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to assign enquiry') toast.error(error instanceof Error ? error.message : 'ไม่สามารถมอบหมายโอกาสขายได้')
}); });
const reassignMutation = useMutation({ const reassignMutation = useMutation({
...reassignEnquiryMutation, ...reassignEnquiryMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Enquiry reassigned successfully'); toast.success('เปลี่ยนผู้ดูแลโอกาสขายสำเร็จ');
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to reassign enquiry') toast.error(error instanceof Error ? error.message : 'ไม่สามารถเปลี่ยนผู้ดูแลโอกาสขายได้')
}); });
const form = useAppForm({ const form = useAppForm({
@@ -109,11 +109,11 @@ export function EnquiryAssignmentDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'}</DialogTitle> <DialogTitle>{mode === 'assign' ? 'ส่งต่อให้ฝ่ายขาย' : 'เปลี่ยนผู้ดูแลโอกาสขาย'}</DialogTitle>
<DialogDescription> <DialogDescription>
{mode === 'assign' {mode === 'assign'
? 'Assign this lead to sales and convert it into an enquiry.' ? 'มอบหมายลีดนี้ให้ฝ่ายขายเพื่อเปลี่ยนเป็นโอกาสขาย'
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'} : 'เปลี่ยนผู้ดูแลโอกาสขายและเก็บบันทึกการส่งต่องานไว้'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -121,24 +121,24 @@ export function EnquiryAssignmentDialog({
<form.Form id='enquiry-assignment-form' className='px-0 md:px-0'> <form.Form id='enquiry-assignment-form' className='px-0 md:px-0'>
{noUsersAvailable ? ( {noUsersAvailable ? (
<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'>
No assignable sales users are available in this workspace yet. workspace
</div> </div>
) : ( ) : (
<> <>
<FormSelectField <FormSelectField
name='assignedToUserId' name='assignedToUserId'
label='Enquiry Owner' label='ผู้ดูแลโอกาสขาย'
required required
options={referenceData.assignableUsers.map((user) => ({ options={referenceData.assignableUsers.map((user) => ({
value: user.id, value: user.id,
label: `${user.name} (${user.businessRole.replaceAll('_', ' ')})` label: `${user.name} (${user.businessRole.replaceAll('_', ' ')})`
}))} }))}
placeholder='Select sales user' placeholder='เลือกผู้ใช้ฝ่ายขาย'
/> />
<FormTextareaField <FormTextareaField
name='remark' name='remark'
label='Remark' label='หมายเหตุ'
placeholder='Optional handoff context or assignment note' placeholder='บริบทเพิ่มเติมหรือหมายเหตุประกอบการส่งต่องาน'
/> />
</> </>
)} )}
@@ -147,7 +147,7 @@ export function EnquiryAssignmentDialog({
<DialogFooter> <DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}> <Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button> </Button>
<Button <Button
type='submit' type='submit'
@@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({
disabled={noUsersAvailable} disabled={noUsersAvailable}
> >
<Icons.userPen className='mr-2 h-4 w-4' /> <Icons.userPen className='mr-2 h-4 w-4' />
{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'} {mode === 'assign' ? 'ส่งต่อให้ฝ่ายขาย' : 'เปลี่ยนผู้ดูแล'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -42,14 +42,14 @@ export function EnquiryCellAction({
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...deleteEnquiryMutation, ...deleteEnquiryMutation,
onSuccess: () => { onSuccess: () => {
toast.success(`${workspace === 'lead' ? 'Lead' : 'Enquiry'} deleted successfully`); toast.success(`${workspace === 'lead' ? 'ลบลีดสำเร็จ' : 'ลบโอกาสขายสำเร็จ'}`);
setDeleteOpen(false); setDeleteOpen(false);
}, },
onError: (error) => onError: (error) =>
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
: `Failed to delete ${workspace === 'lead' ? 'lead' : 'enquiry'}` : `${workspace === 'lead' ? 'ไม่สามารถลบลีดได้' : 'ไม่สามารถลบโอกาสขายได้'}`
) )
}); });
@@ -76,7 +76,7 @@ export function EnquiryCellAction({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
router.push( router.push(
@@ -86,13 +86,13 @@ export function EnquiryCellAction({
) )
} }
> >
<Icons.arrowRight className='mr-2 h-4 w-4' /> View <Icons.arrowRight className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}> <DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Edit Lead' : 'Edit Enquiry'} <Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'แก้ไขลีด' : 'แก้ไขโอกาสขาย'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}> <DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete <Icons.trash className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -203,11 +203,11 @@ function getEnquiryWorkspaceColumns({
id: 'customer', id: 'customer',
accessorFn: (row) => row.customerId, accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => ( header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Billing Customer' /> <DataTableColumnHeader column={column} title='ลูกค้าผู้รับใบเสนอราคา' />
), ),
cell: ({ row }) => <span>{row.original.customerName}</span>, cell: ({ row }) => <span>{row.original.customerName}</span>,
meta: { meta: {
label: 'Billing Customer', label: 'ลูกค้าผู้รับใบเสนอราคา',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.customers.map((item) => ({ options: referenceData.customers.map((item) => ({
value: item.id, value: item.id,

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries'; import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types'; import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog'; import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
@@ -27,18 +28,7 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
} }
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) { function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
switch (stage) { return getPipelineStageThaiLabel(stage);
case 'lead':
return 'Lead';
case 'enquiry':
return 'Enquiry';
case 'closed_won':
return 'Closed Won';
case 'closed_lost':
return 'Closed Lost';
default:
return stage;
}
} }
interface EnquiryDetailProps { interface EnquiryDetailProps {
@@ -109,26 +99,26 @@ export function EnquiryDetail({
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage); const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
const isLeadWorkspace = workspace === 'lead'; const isLeadWorkspace = workspace === 'lead';
const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries'; const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries';
const recordLabel = isLeadWorkspace ? 'Lead' : 'Enquiry'; const recordLabel = isLeadWorkspace ? 'ลีด' : 'โอกาสขาย';
const ownerLabel = isLeadWorkspace ? 'Assigned Sales' : 'Enquiry Owner'; const ownerLabel = isLeadWorkspace ? 'ฝ่ายขายที่รับผิดชอบ' : 'ผู้ดูแลโอกาสขาย';
const infoCardTitle = isLeadWorkspace ? 'Lead Information' : 'Enquiry Information'; const infoCardTitle = isLeadWorkspace ? 'ข้อมูลลีด' : 'ข้อมูลโอกาสขาย';
const infoCardDescription = isLeadWorkspace const infoCardDescription = isLeadWorkspace
? 'Lead source, qualification context, and sales handoff readiness.' ? 'ที่มาของลีด ข้อมูลการคัดกรอง และความพร้อมในการส่งต่อให้ฝ่ายขาย'
: 'Project scope, ownership context, and active sales execution details.'; : 'ขอบเขตงาน ข้อมูลผู้รับผิดชอบ และรายละเอียดการดูแลของฝ่ายขาย';
const assignmentButtonLabel = const assignmentButtonLabel =
assignmentMode === 'assign' assignmentMode === 'assign'
? isLeadWorkspace ? isLeadWorkspace
? 'Assign To Sales' ? 'ส่งต่อให้ฝ่ายขาย'
: 'Assign Enquiry Owner' : 'มอบหมายผู้ดูแลโอกาสขาย'
: isLeadWorkspace : isLeadWorkspace
? 'Reassign Sales Owner' ? 'เปลี่ยนฝ่ายขายที่รับผิดชอบ'
: 'Reassign Enquiry Owner'; : 'เปลี่ยนผู้ดูแลโอกาสขาย';
const projectSectionLabel = isLeadWorkspace ? 'Lead Scope' : 'Project Information'; const projectSectionLabel = isLeadWorkspace ? 'ขอบเขตลีด' : 'ข้อมูลโครงการ';
const followupTabLabel = isLeadWorkspace ? 'Lead Follow-ups' : 'Follow-ups'; const followupTabLabel = isLeadWorkspace ? 'งานติดตามลีด' : 'งานติดตาม';
const relatedTabLabel = isLeadWorkspace ? 'Related Quotations' : 'Quotations'; const relatedTabLabel = isLeadWorkspace ? 'ใบเสนอราคาที่เกี่ยวข้อง' : 'ใบเสนอราคา';
const snapshotDescription = isLeadWorkspace const snapshotDescription = isLeadWorkspace
? 'Operational metadata and lead ownership handoff state for this record.' ? 'ข้อมูลระบบและสถานะการส่งต่อของลีดรายการนี้'
: 'Operational metadata and assignment state for this enquiry record.'; : 'ข้อมูลระบบและสถานะการรับผิดชอบของโอกาสขายรายการนี้';
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
@@ -147,7 +137,7 @@ export function EnquiryDetail({
<div> <div>
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2> <h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
{pipelineStageLabel} | {customer?.name ?? 'Unknown customer'} {pipelineStageLabel} | {customer?.name ?? 'ไม่ทราบชื่อลูกค้า'}
{contact ? ` | ${contact.name}` : ''} {contact ? ` | ${contact.name}` : ''}
</p> </p>
</div> </div>
@@ -174,9 +164,9 @@ export function EnquiryDetail({
<CardContent className='pt-6'> <CardContent className='pt-6'>
<Tabs defaultValue='overview' className='gap-4'> <Tabs defaultValue='overview' className='gap-4'>
<TabsList> <TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger> <TabsTrigger value='overview'></TabsTrigger>
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger> <TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger> <TabsTrigger value='activity'></TabsTrigger>
{canViewRelatedQuotations ? ( {canViewRelatedQuotations ? (
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger> <TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
) : null} ) : null}
@@ -189,29 +179,29 @@ export function EnquiryDetail({
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem <FieldItem
label={isLeadWorkspace ? 'Company' : 'Billing Customer'} label={isLeadWorkspace ? 'ลูกค้า' : 'ลูกค้าผู้รับใบเสนอราคา'}
value={customer?.name} value={customer?.name}
/> />
<FieldItem label='Contact' value={contact?.name} /> <FieldItem label='ผู้ติดต่อ' value={contact?.name} />
<FieldItem <FieldItem
label='Branch' label='สาขา'
value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null} value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null}
/> />
<FieldItem label='Active' value={enquiry.isActive ? 'Yes' : 'No'} /> <FieldItem label='ใช้งานอยู่' value={enquiry.isActive ? 'ใช่' : 'ไม่ใช่'} />
<FieldItem <FieldItem
label='Product Type' label='ประเภทสินค้า'
value={productTypeMap.get(enquiry.productType)?.label} value={productTypeMap.get(enquiry.productType)?.label}
/> />
<FieldItem <FieldItem
label='Priority' label='ความสำคัญ'
value={priorityMap.get(enquiry.priority)?.label} value={priorityMap.get(enquiry.priority)?.label}
/> />
<FieldItem <FieldItem
label='Lead Source' label='ที่มาของลีด'
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label} value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
/> />
<FieldItem <FieldItem
label='Expected Close Date' label='วันที่คาดว่าจะปิดการขาย'
value={ value={
enquiry.expectedCloseDate enquiry.expectedCloseDate
? new Date(enquiry.expectedCloseDate).toLocaleDateString() ? new Date(enquiry.expectedCloseDate).toLocaleDateString()
@@ -219,7 +209,7 @@ export function EnquiryDetail({
} }
/> />
<FieldItem <FieldItem
label='Estimated Value' label='มูลค่าประมาณการ'
value={ value={
enquiry.estimatedValue !== null enquiry.estimatedValue !== null
? enquiry.estimatedValue.toLocaleString() ? enquiry.estimatedValue.toLocaleString()
@@ -227,31 +217,31 @@ export function EnquiryDetail({
} }
/> />
<FieldItem <FieldItem
label='Chance %' label='โอกาสปิดการขาย %'
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null} value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
/> />
<FieldItem label='Competitor' value={enquiry.competitor} /> <FieldItem label='คู่แข่ง' value={enquiry.competitor} />
<FieldItem label='Source' value={enquiry.source} /> <FieldItem label='ที่มา' value={enquiry.source} />
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} /> <FieldItem label='ขั้นตอนใน Pipeline' value={pipelineStageLabel} />
<FieldItem label={ownerLabel} value={enquiry.assignedToName} /> <FieldItem label={ownerLabel} value={enquiry.assignedToName} />
<FieldItem <FieldItem
label='Assigned At' label='มอบหมายเมื่อ'
value={ value={
enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null
} }
/> />
<FieldItem label='Assigned By' value={enquiry.assignedByName} /> <FieldItem label='มอบหมายโดย' value={enquiry.assignedByName} />
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} /> <FieldItem label='หมายเหตุการมอบหมาย' value={enquiry.assignmentRemark} />
<div className='md:col-span-2 xl:col-span-4'> <div className='md:col-span-2 xl:col-span-4'>
<div className='space-y-3'> <div className='space-y-3'>
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground text-xs'>
{isLeadWorkspace ? 'Lead Parties' : 'Project Parties'} {isLeadWorkspace ? 'ผู้เกี่ยวข้องของลีด' : 'ผู้เกี่ยวข้องในโครงการ'}
</div> </div>
{!projectPartiesData.items.length ? ( {!projectPartiesData.items.length ? (
<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'>
{isLeadWorkspace {isLeadWorkspace
? 'No lead parties have been added yet.' ? 'ยังไม่มีผู้เกี่ยวข้องของลีด'
: 'No project parties have been added yet.'} : 'ยังไม่มีผู้เกี่ยวข้องในโครงการ'}
</div> </div>
) : ( ) : (
<div className='space-y-3'> <div className='space-y-3'>
@@ -259,7 +249,7 @@ export function EnquiryDetail({
<div key={item.id} className='rounded-lg border p-4 text-sm'> <div key={item.id} className='rounded-lg border p-4 text-sm'>
<div className='font-medium'>{item.customerName}</div> <div className='font-medium'>{item.customerName}</div>
<div className='text-muted-foreground'> <div className='text-muted-foreground'>
Role: {item.roleLabel ?? item.roleCode} : {item.roleLabel ?? item.roleCode}
</div> </div>
{item.remark ? <div className='mt-1'>{item.remark}</div> : null} {item.remark ? <div className='mt-1'>{item.remark}</div> : null}
</div> </div>
@@ -278,13 +268,13 @@ export function EnquiryDetail({
/> />
</div> </div>
<div className='md:col-span-2 xl:col-span-4'> <div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Description' value={enquiry.description} /> <FieldItem label='รายละเอียด' value={enquiry.description} />
</div> </div>
<div className='md:col-span-2 xl:col-span-4'> <div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Requirement' value={enquiry.requirement} /> <FieldItem label='ความต้องการ' value={enquiry.requirement} />
</div> </div>
<div className='md:col-span-2 xl:col-span-4'> <div className='md:col-span-2 xl:col-span-4'>
<FieldItem label='Notes' value={enquiry.notes} /> <FieldItem label='หมายเหตุ' value={enquiry.notes} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -302,15 +292,13 @@ export function EnquiryDetail({
<TabsContent value='activity'> <TabsContent value='activity'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{isLeadWorkspace ? 'Lead Activity' : 'Activity'}</CardTitle> <CardTitle>{isLeadWorkspace ? 'กิจกรรมของลีด' : 'กิจกรรม'}</CardTitle>
<CardDescription> <CardDescription></CardDescription>
Audit events for this record and its follow-up history.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{!data.activity.length ? ( {!data.activity.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No activity recorded yet.
</div> </div>
) : ( ) : (
data.activity.map((item, index) => ( data.activity.map((item, index) => (
@@ -347,13 +335,13 @@ export function EnquiryDetail({
<CardHeader> <CardHeader>
<CardTitle>{relatedTabLabel}</CardTitle> <CardTitle>{relatedTabLabel}</CardTitle>
<CardDescription> <CardDescription>
Production quotations already linked to this record.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!relatedQuotations.length ? ( {!relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No quotations have been linked to this record yet.
</div> </div>
) : ( ) : (
<div className='space-y-3'> <div className='space-y-3'>
@@ -388,21 +376,21 @@ export function EnquiryDetail({
<div className='space-y-6'> <div className='space-y-6'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Record Snapshot</CardTitle> <CardTitle></CardTitle>
<CardDescription>{snapshotDescription}</CardDescription> <CardDescription>{snapshotDescription}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} /> <FieldItem label='ขั้นตอนใน Pipeline' value={pipelineStageLabel} />
<FieldItem label={ownerLabel} value={enquiry.assignedToName} /> <FieldItem label={ownerLabel} value={enquiry.assignedToName} />
<FieldItem label='Assigned By' value={enquiry.assignedByName} /> <FieldItem label='มอบหมายโดย' value={enquiry.assignedByName} />
<FieldItem label='Created By' value={enquiry.createdBy} /> <FieldItem label='สร้างโดย' value={enquiry.createdBy} />
<FieldItem label='Updated By' value={enquiry.updatedBy} /> <FieldItem label='อัปเดตโดย' value={enquiry.updatedBy} />
<FieldItem <FieldItem
label='Assigned At' label='มอบหมายเมื่อ'
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null} value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
/> />
<FieldItem label='Created At' value={new Date(enquiry.createdAt).toLocaleString()} /> <FieldItem label='สร้างเมื่อ' value={new Date(enquiry.createdAt).toLocaleString()} />
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} /> <FieldItem label='อัปเดตเมื่อ' value={new Date(enquiry.updatedAt).toLocaleString()} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -38,22 +38,20 @@ export function EnquiryFollowupsTab({
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...deleteEnquiryFollowupMutation, ...deleteEnquiryFollowupMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Follow-up deleted successfully'); toast.success('ลบงานติดตามสำเร็จ');
setDeleteOpen(false); setDeleteOpen(false);
setSelectedId(null); setSelectedId(null);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete follow-up') toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้')
}); });
return ( return (
<Card> <Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'> <CardHeader className='flex flex-row items-start justify-between gap-4'>
<div> <div>
<CardTitle>Follow-ups</CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription> </CardDescription>
Track the latest contact moments, outcomes, and next steps for this enquiry.
</CardDescription>
</div> </div>
{canCreate ? ( {canCreate ? (
<Button <Button
@@ -63,14 +61,14 @@ export function EnquiryFollowupsTab({
setOpen(true); setOpen(true);
}} }}
> >
<Icons.add className='mr-2 h-4 w-4' /> Add Follow-up <Icons.add className='mr-2 h-4 w-4' />
</Button> </Button>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{!data.items.length ? ( {!data.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No follow-ups recorded yet.
</div> </div>
) : ( ) : (
data.items.map((item) => ( data.items.map((item) => (
@@ -89,11 +87,11 @@ export function EnquiryFollowupsTab({
</div> </div>
{item.outcome ? <p className='text-sm'>{item.outcome}</p> : null} {item.outcome ? <p className='text-sm'>{item.outcome}</p> : null}
{item.nextAction ? ( {item.nextAction ? (
<p className='text-muted-foreground text-sm'>Next action: {item.nextAction}</p> <p className='text-muted-foreground text-sm'>: {item.nextAction}</p>
) : null} ) : null}
{item.nextFollowupDate ? ( {item.nextFollowupDate ? (
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
Next follow-up: {new Date(item.nextFollowupDate).toLocaleDateString()} : {new Date(item.nextFollowupDate).toLocaleDateString()}
</p> </p>
) : null} ) : null}
{item.notes ? <p className='text-muted-foreground text-sm'>{item.notes}</p> : null} {item.notes ? <p className='text-muted-foreground text-sm'>{item.notes}</p> : null}
@@ -108,7 +106,7 @@ export function EnquiryFollowupsTab({
setOpen(true); setOpen(true);
}} }}
> >
<Icons.edit className='mr-2 h-4 w-4' /> Edit <Icons.edit className='mr-2 h-4 w-4' />
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -119,7 +117,7 @@ export function EnquiryFollowupsTab({
setDeleteOpen(true); setDeleteOpen(true);
}} }}
> >
<Icons.trash className='mr-2 h-4 w-4' /> Delete <Icons.trash className='mr-2 h-4 w-4' />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -90,21 +90,21 @@ export function EnquiryFormSheet({
const createMutation = useMutation({ const createMutation = useMutation({
...createEnquiryMutation, ...createEnquiryMutation,
onSuccess: () => { onSuccess: () => {
toast.success(`${recordLabel} created successfully`); toast.success(`สร้าง${recordLabel}สำเร็จ`);
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : `Failed to create ${recordLabel.toLowerCase()}`) toast.error(error instanceof Error ? error.message : `ไม่สามารถสร้าง${recordLabel}ได้`)
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
...updateEnquiryMutation, ...updateEnquiryMutation,
onSuccess: () => { onSuccess: () => {
toast.success(`${recordLabel} updated successfully`); toast.success(`อัปเดต${recordLabel}สำเร็จ`);
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : `Failed to update ${recordLabel.toLowerCase()}`) toast.error(error instanceof Error ? error.message : `ไม่สามารถอัปเดต${recordLabel}ได้`)
}); });
const contactsForCustomer = referenceData.contacts.filter( const contactsForCustomer = referenceData.contacts.filter(
@@ -179,17 +179,17 @@ export function EnquiryFormSheet({
const isPending = createMutation.isPending || updateMutation.isPending; const isPending = createMutation.isPending || updateMutation.isPending;
const recordLabel = workspace === 'lead' ? 'Lead' : 'Enquiry'; const recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย';
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-4xl'> <SheetContent className='flex flex-col sm:max-w-4xl'>
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`}</SheetTitle> <SheetTitle>{isEdit ? `แก้ไข${recordLabel}` : `เพิ่ม${recordLabel}`}</SheetTitle>
<SheetDescription> <SheetDescription>
{workspace === 'lead' {workspace === 'lead'
? 'Capture marketing lead details before assignment to sales.' ? 'บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย'
: 'Capture sales enquiry details before quotation work begins.'} : 'บันทึกข้อมูลโอกาสขายของฝ่ายขายก่อนเริ่มทำใบเสนอราคา'}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -201,7 +201,7 @@ export function EnquiryFormSheet({
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel>Billing Customer *</field.FieldLabel> <field.FieldLabel> *</field.FieldLabel>
<Select <Select
value={field.state.value ?? ''} value={field.state.value ?? ''}
onValueChange={(value) => { onValueChange={(value) => {
@@ -211,8 +211,8 @@ export function EnquiryFormSheet({
form.setFieldValue('contactId', ''); form.setFieldValue('contactId', '');
}} }}
> >
<SelectTrigger aria-label='Customer'> <SelectTrigger aria-label='ลูกค้า'>
<SelectValue placeholder='Select customer' /> <SelectValue placeholder='เลือกลูกค้า' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{referenceData.customers.map((customer) => ( {referenceData.customers.map((customer) => (
@@ -233,7 +233,7 @@ export function EnquiryFormSheet({
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel>Contact</field.FieldLabel> <field.FieldLabel></field.FieldLabel>
<Select <Select
value={field.state.value ?? ''} value={field.state.value ?? ''}
onValueChange={(value) => { onValueChange={(value) => {
@@ -241,19 +241,19 @@ export function EnquiryFormSheet({
field.handleBlur(); field.handleBlur();
}} }}
> >
<SelectTrigger aria-label='Contact'> <SelectTrigger aria-label='ผู้ติดต่อ'>
<SelectValue placeholder='Select contact' /> <SelectValue placeholder='เลือกผู้ติดต่อ' />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{contactsForCustomer.length ? ( {contactsForCustomer.length ? (
contactsForCustomer.map((contact) => ( contactsForCustomer.map((contact) => (
<SelectItem key={contact.id} value={contact.id}> <SelectItem key={contact.id} value={contact.id}>
{contact.name} {contact.name}
{contact.isPrimary ? ' (Primary)' : ''} {contact.isPrimary ? ' (ผู้ติดต่อหลัก)' : ''}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
<SelectItem value='__none__'>No contact selected</SelectItem> <SelectItem value='__none__'></SelectItem>
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -265,28 +265,28 @@ export function EnquiryFormSheet({
<FormTextField <FormTextField
name='title' name='title'
label='Title' label='ชื่อรายการ'
required required
placeholder='Warehouse crane upgrade enquiry' placeholder='Warehouse crane upgrade enquiry'
/> />
<FormTextField <FormTextField
name='projectName' name='projectName'
label='Project Name' label='ชื่อโครงการ'
placeholder='Bangna Warehouse Expansion' placeholder='Bangna Warehouse Expansion'
/> />
<FormTextField <FormTextField
name='projectLocation' name='projectLocation'
label='Project Location' label='สถานที่โครงการ'
placeholder='Bangkok' placeholder='Bangkok'
/> />
<FormTextField <FormTextField
name='source' name='source'
label='Source' label='ที่มา'
placeholder='Existing customer referral' placeholder='Existing customer referral'
/> />
<FormSelectField <FormSelectField
name='branchId' name='branchId'
label='Branch' label='สาขา'
options={referenceData.branches.map((branch) => ({ options={referenceData.branches.map((branch) => ({
value: branch.id, value: branch.id,
label: branch.name label: branch.name
@@ -294,7 +294,7 @@ export function EnquiryFormSheet({
/> />
<FormSelectField <FormSelectField
name='productType' name='productType'
label='Product Type' label='ประเภทสินค้า'
required required
options={referenceData.productTypes.map((item) => ({ options={referenceData.productTypes.map((item) => ({
value: item.id, value: item.id,
@@ -303,7 +303,7 @@ export function EnquiryFormSheet({
/> />
<FormSelectField <FormSelectField
name='status' name='status'
label='Status' label='สถานะ'
required required
options={referenceData.statuses.map((item) => ({ options={referenceData.statuses.map((item) => ({
value: item.id, value: item.id,
@@ -312,7 +312,7 @@ export function EnquiryFormSheet({
/> />
<FormSelectField <FormSelectField
name='priority' name='priority'
label='Priority' label='ความสำคัญ'
required required
options={referenceData.priorities.map((item) => ({ options={referenceData.priorities.map((item) => ({
value: item.id, value: item.id,
@@ -321,7 +321,7 @@ export function EnquiryFormSheet({
/> />
<FormSelectField <FormSelectField
name='leadChannel' name='leadChannel'
label='Lead Channel' label='ที่มาของลีด'
options={referenceData.leadChannels.map((item) => ({ options={referenceData.leadChannels.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label
@@ -329,48 +329,48 @@ export function EnquiryFormSheet({
/> />
<FormTextField <FormTextField
name='estimatedValue' name='estimatedValue'
label='Estimated Value' label='มูลค่าประมาณการ'
type='number' type='number'
placeholder='2500000' placeholder='2500000'
/> />
<FormTextField name='chancePercent' label='Chance %' type='number' placeholder='60' /> <FormTextField name='chancePercent' label='โอกาสปิดการขาย %' type='number' placeholder='60' />
<FormTextField <FormTextField
name='expectedCloseDate' name='expectedCloseDate'
label='Expected Close Date' label='วันที่คาดว่าจะปิดการขาย'
placeholder='YYYY-MM-DD' placeholder='YYYY-MM-DD'
/> />
<FormTextField name='competitor' label='Competitor' placeholder='Other vendor name' /> <FormTextField name='competitor' label='คู่แข่ง' placeholder='Other vendor name' />
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormTextareaField <FormTextareaField
name='description' name='description'
label='Description' label='รายละเอียด'
placeholder='High-level summary of the lead or enquiry' placeholder='สรุปภาพรวมของลีดหรือโอกาสขาย'
/> />
</div> </div>
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormTextareaField <FormTextareaField
name='requirement' name='requirement'
label='Requirement' label='ความต้องการ'
placeholder='Specific technical or commercial requirements' placeholder='ความต้องการเชิงเทคนิคหรือเชิงพาณิชย์'
/> />
</div> </div>
<div className='md:col-span-2'> <div className='md:col-span-2'>
<FormTextareaField <FormTextareaField
name='notes' name='notes'
label='Notes' label='หมายเหตุ'
placeholder='Internal notes, blockers, or handoff guidance' placeholder='หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน'
/> />
</div> </div>
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'> <div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
<FormSwitchField <FormSwitchField
name='isHotProject' name='isHotProject'
label='Hot Project' label='โครงการด่วน'
description='Use for urgent or highly strategic enquiries.' description='ใช้สำหรับงานเร่งด่วนหรือโครงการสำคัญเชิงกลยุทธ์'
/> />
<FormSwitchField <FormSwitchField
name='isActive' name='isActive'
label='Active Record' label='ใช้งานอยู่'
description='Inactive leads and enquiries stay in history but should not progress further.' description='รายการที่ปิดใช้งานจะยังอยู่ในประวัติ แต่ไม่ควรถูกดำเนินการต่อ'
/> />
</div> </div>
<div className='md:col-span-2'> <div className='md:col-span-2'>
@@ -387,11 +387,11 @@ export function EnquiryFormSheet({
<SheetFooter> <SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}> <Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button> </Button>
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}> <Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' /> <Icons.check className='mr-2 h-4 w-4' />
{isEdit ? `Update ${recordLabel}` : `Create ${recordLabel}`} {isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -411,7 +411,7 @@ export function EnquiryFormSheetTrigger({
return ( return (
<> <>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Add Lead' : 'Add Enquiry'} <Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'เพิ่มลีด' : 'เพิ่มโอกาสขาย'}
</Button> </Button>
<EnquiryFormSheet <EnquiryFormSheet
open={open} open={open}

View File

@@ -36,11 +36,11 @@ export function QuotationCellAction({
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...deleteQuotationMutation, ...deleteQuotationMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Quotation deleted successfully'); toast.success('ลบใบเสนอราคาสำเร็จ');
setDeleteOpen(false); setDeleteOpen(false);
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete quotation') toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบใบเสนอราคาได้')
}); });
return ( return (
@@ -65,15 +65,15 @@ export function QuotationCellAction({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/quotations/${data.id}`)}> <DropdownMenuItem onClick={() => router.push(`/dashboard/crm/quotations/${data.id}`)}>
<Icons.arrowRight className='mr-2 h-4 w-4' /> View <Icons.arrowRight className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}> <DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit <Icons.edit className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}> <DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete <Icons.trash className='mr-2 h-4 w-4' />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -27,7 +27,7 @@ export function getQuotationColumns({
id: 'code', id: 'code',
accessorKey: 'code', accessorKey: 'code',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Quotation' /> <DataTableColumnHeader column={column} title='ใบเสนอราคา' />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className='flex flex-col'>
@@ -41,8 +41,8 @@ export function getQuotationColumns({
</div> </div>
), ),
meta: { meta: {
label: 'Quotation', label: 'ใบเสนอราคา',
placeholder: 'Search quotations...', placeholder: 'ค้นหาใบเสนอราคา...',
variant: 'text' as const, variant: 'text' as const,
icon: Icons.search icon: Icons.search
}, },
@@ -69,7 +69,7 @@ export function getQuotationColumns({
id: 'quotationType', id: 'quotationType',
accessorKey: 'quotationType', accessorKey: 'quotationType',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Type' /> <DataTableColumnHeader column={column} title='ประเภท' />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant='outline'> <Badge variant='outline'>
@@ -77,7 +77,7 @@ export function getQuotationColumns({
</Badge> </Badge>
), ),
meta: { meta: {
label: 'Type', label: 'ประเภท',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.quotationTypes.map((item) => ({ value: item.id, label: item.label })) options: referenceData.quotationTypes.map((item) => ({ value: item.id, label: item.label }))
}, },
@@ -87,13 +87,13 @@ export function getQuotationColumns({
id: 'branch', id: 'branch',
accessorFn: (row) => row.branchId ?? '', accessorFn: (row) => row.branchId ?? '',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Branch' /> <DataTableColumnHeader column={column} title='สาขา' />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span>{row.original.branchId ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') : 'Unassigned'}</span> <span>{row.original.branchId ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') : 'Unassigned'}</span>
), ),
meta: { meta: {
label: 'Branch', label: 'สาขา',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.branches.map((item) => ({ value: item.id, label: item.name })) options: referenceData.branches.map((item) => ({ value: item.id, label: item.name }))
}, },
@@ -103,11 +103,11 @@ export function getQuotationColumns({
id: 'customer', id: 'customer',
accessorFn: (row) => row.customerId, accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' /> <DataTableColumnHeader column={column} title='ลูกค้าผู้รับใบเสนอราคา' />
), ),
cell: ({ row }) => row.original.customerName, cell: ({ row }) => row.original.customerName,
meta: { meta: {
label: 'Customer', label: 'ลูกค้าผู้รับใบเสนอราคา',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.customers.map((item) => ({ value: item.id, label: item.name })) options: referenceData.customers.map((item) => ({ value: item.id, label: item.name }))
}, },
@@ -117,11 +117,11 @@ export function getQuotationColumns({
id: 'enquiry', id: 'enquiry',
accessorFn: (row) => row.enquiryId ?? '', accessorFn: (row) => row.enquiryId ?? '',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Enquiry' /> <DataTableColumnHeader column={column} title='โอกาสขาย' />
), ),
cell: ({ row }) => row.original.enquiryCode ?? '-', cell: ({ row }) => row.original.enquiryCode ?? '-',
meta: { meta: {
label: 'Enquiry', label: 'โอกาสขาย',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` })) options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
}, },
@@ -131,15 +131,15 @@ export function getQuotationColumns({
id: 'isHotProject', id: 'isHotProject',
accessorFn: (row) => String(row.isHotProject), accessorFn: (row) => String(row.isHotProject),
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Hot' /> <DataTableColumnHeader column={column} title='ด่วน' />
), ),
cell: ({ row }) => (row.original.isHotProject ? <Badge>Hot</Badge> : <span>-</span>), cell: ({ row }) => (row.original.isHotProject ? <Badge>Hot</Badge> : <span>-</span>),
meta: { meta: {
label: 'Hot Project', label: 'โครงการด่วน',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: [ options: [
{ value: 'true', label: 'Hot' }, { value: 'true', label: 'ด่วน' },
{ value: 'false', label: 'Normal' } { value: 'false', label: 'ปกติ' }
] ]
}, },
enableColumnFilter: true enableColumnFilter: true
@@ -148,7 +148,7 @@ export function getQuotationColumns({
id: 'totalAmount', id: 'totalAmount',
accessorKey: 'totalAmount', accessorKey: 'totalAmount',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => ( header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Total' /> <DataTableColumnHeader column={column} title='มูลค่ารวม' />
), ),
cell: ({ row }) => row.original.totalAmount.toLocaleString() cell: ({ row }) => row.original.totalAmount.toLocaleString()
}, },

View File

@@ -1324,7 +1324,7 @@ export function QuotationDetail({
<TabsList className="flex flex-wrap"> <TabsList className="flex flex-wrap">
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="items">Items</TabsTrigger> <TabsTrigger value="items">Items</TabsTrigger>
<TabsTrigger value="customers">Project Parties</TabsTrigger> <TabsTrigger value="customers"></TabsTrigger>
<TabsTrigger value="topics">Topics</TabsTrigger> <TabsTrigger value="topics">Topics</TabsTrigger>
<TabsTrigger value="followups">Follow-ups</TabsTrigger> <TabsTrigger value="followups">Follow-ups</TabsTrigger>
<TabsTrigger value="attachments">Attachments</TabsTrigger> <TabsTrigger value="attachments">Attachments</TabsTrigger>
@@ -1343,7 +1343,7 @@ export function QuotationDetail({
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<FieldItem <FieldItem
label="Billing Customer" label="ลูกค้าผู้รับใบเสนอราคา"
value={customer?.name} value={customer?.name}
/> />
<FieldItem label="Contact" value={contact?.name} /> <FieldItem label="Contact" value={contact?.name} />
@@ -1406,7 +1406,7 @@ export function QuotationDetail({
<div className="md:col-span-2 xl:col-span-4"> <div className="md:col-span-2 xl:col-span-4">
<div className="space-y-3"> <div className="space-y-3">
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
Project Parties
</div> </div>
{!customersData.items.length ? ( {!customersData.items.length ? (
<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">
@@ -1534,7 +1534,7 @@ export function QuotationDetail({
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>Project Parties</CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription>
Related companies for this quotation, each with a Related companies for this quotation, each with a
visible role. visible role.

View File

@@ -19,9 +19,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId)); const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId));
const { documentData, template } = data.preview; const { documentData, template } = data.preview;
const topicGroups: Array<{ key: 'scope' | 'exclusion' | 'payment'; title: string }> = [ const topicGroups: Array<{ key: 'scope' | 'exclusion' | 'payment'; title: string }> = [
{ key: 'scope', title: 'Scope' }, { key: 'scope', title: 'ขอบเขตงาน' },
{ key: 'exclusion', title: 'Exclusions' }, { key: 'exclusion', title: 'ข้อยกเว้น' },
{ key: 'payment', title: 'Payment Terms' } { key: 'payment', title: 'เงื่อนไขการชำระเงิน' }
]; ];
return ( return (
@@ -32,14 +32,14 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='space-y-1'> <div className='space-y-1'>
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle> <CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
<CardDescription> <CardDescription>
Quotation Preview using {template.template.templateName} v{template.version.version} {template.template.templateName} v{template.version.version}
</CardDescription> </CardDescription>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{documentData.watermarkStatus ? ( {documentData.watermarkStatus ? (
<Badge variant='destructive'>{documentData.watermarkStatus}</Badge> <Badge variant='destructive'>{documentData.watermarkStatus}</Badge>
) : ( ) : (
<Badge>Approved Ready</Badge> <Badge></Badge>
)} )}
<Badge variant='outline'>{template.template.fileType}</Badge> <Badge variant='outline'>{template.template.fileType}</Badge>
</div> </div>
@@ -49,41 +49,41 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='grid gap-6 lg:grid-cols-2'> <div className='grid gap-6 lg:grid-cols-2'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Quotation Header</CardTitle> <CardTitle></CardTitle>
<CardDescription>Commercial identity and issue information.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'> <CardContent className='grid gap-4 md:grid-cols-2'>
<Field label='Quotation Code' value={documentData.quotation.code} /> <Field label='เลขที่ใบเสนอราคา' value={documentData.quotation.code} />
<Field label='Revision' value={documentData.quotation.revisionLabel} /> <Field label='Revision' value={documentData.quotation.revisionLabel} />
<Field <Field
label='Quotation Date' label='วันที่ออกใบเสนอราคา'
value={new Date(documentData.quotation.quotationDate).toLocaleDateString()} value={new Date(documentData.quotation.quotationDate).toLocaleDateString()}
/> />
<Field <Field
label='Valid Until' label='ใช้ได้ถึง'
value={ value={
documentData.quotation.validUntil documentData.quotation.validUntil
? new Date(documentData.quotation.validUntil).toLocaleDateString() ? new Date(documentData.quotation.validUntil).toLocaleDateString()
: null : null
} }
/> />
<Field label='Type' value={documentData.quotation.quotationTypeLabel} /> <Field label='ประเภทเอกสาร' value={documentData.quotation.quotationTypeLabel} />
<Field label='Branch' value={documentData.branch?.label} /> <Field label='สาขา' value={documentData.branch?.label} />
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Customer Block</CardTitle> <CardTitle></CardTitle>
<CardDescription>Bill-to and attention context for the document.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'> <CardContent className='grid gap-4 md:grid-cols-2'>
<Field label='Billing Customer' value={documentData.customer.name} /> <Field label='ลูกค้าผู้รับใบเสนอราคา' value={documentData.customer.name} />
<Field label='Contact' value={documentData.contact?.name} /> <Field label='ผู้ติดต่อ' value={documentData.contact?.name} />
<Field label='Phone' value={documentData.customer.phone} /> <Field label='โทรศัพท์' value={documentData.customer.phone} />
<Field label='Email' value={documentData.customer.email} /> <Field label='อีเมล' value={documentData.customer.email} />
<div className='md:col-span-2'> <div className='md:col-span-2'>
<Field label='Address' value={documentData.customer.address} /> <Field label='ที่อยู่' value={documentData.customer.address} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -91,30 +91,30 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Project Information</CardTitle> <CardTitle></CardTitle>
<CardDescription>Scope summary and reference data for the customer.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<Field label='Project Name' value={documentData.quotation.projectName} /> <Field label='ชื่อโครงการ' value={documentData.quotation.projectName} />
<Field label='Site Location' value={documentData.quotation.projectLocation} /> <Field label='สถานที่ติดตั้ง' value={documentData.quotation.projectLocation} />
<Field label='Attention' value={documentData.quotation.attention} /> <Field label='เรียน' value={documentData.quotation.attention} />
<Field label='Reference' value={documentData.quotation.reference} /> <Field label='อ้างอิง' value={documentData.quotation.reference} />
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Items Table</CardTitle> <CardTitle></CardTitle>
<CardDescription>Normalized line items prepared for template mapping.</CardDescription> <CardDescription> template mapping</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className='space-y-3'>
<div className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border bg-muted/20 px-4 py-3 text-xs font-medium uppercase tracking-wide'> <div className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border bg-muted/20 px-4 py-3 text-xs font-medium uppercase tracking-wide'>
<div>Item</div> <div></div>
<div>Description</div> <div></div>
<div>Qty</div> <div></div>
<div>Unit</div> <div></div>
<div>Unit Price</div> <div></div>
<div>Total</div> <div></div>
</div> </div>
{documentData.items.map((item) => ( {documentData.items.map((item) => (
<div <div
@@ -138,8 +138,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='grid gap-6 lg:grid-cols-[2fr_1fr]'> <div className='grid gap-6 lg:grid-cols-[2fr_1fr]'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Topics</CardTitle> <CardTitle></CardTitle>
<CardDescription>Scope, exclusions, and payment terms from topic records.</CardDescription> <CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='space-y-6'>
{topicGroups.map((group) => { {topicGroups.map((group) => {
@@ -148,7 +148,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div key={group.key} className='space-y-3'> <div key={group.key} className='space-y-3'>
<div className='font-medium'>{group.title}</div> <div className='font-medium'>{group.title}</div>
{!entries.length ? ( {!entries.length ? (
<div className='text-muted-foreground text-sm'>No entries</div> <div className='text-muted-foreground text-sm'></div>
) : ( ) : (
entries.map((entry) => ( entries.map((entry) => (
<div key={`${group.key}-${entry.title}`} className='rounded-lg border p-4'> <div key={`${group.key}-${entry.title}`} className='rounded-lg border p-4'>
@@ -170,19 +170,19 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='space-y-6'> <div className='space-y-6'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Price Summary</CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-3 text-sm'> <CardContent className='space-y-3 text-sm'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground'>Subtotal</span> <span className='text-muted-foreground'></span>
<span>{documentData.quotation.subtotal.toLocaleString()}</span> <span>{documentData.quotation.subtotal.toLocaleString()}</span>
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground'>Tax</span> <span className='text-muted-foreground'></span>
<span>{documentData.quotation.taxAmount.toLocaleString()}</span> <span>{documentData.quotation.taxAmount.toLocaleString()}</span>
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground'>Total</span> <span className='text-muted-foreground'></span>
<span className='font-semibold'>{documentData.quotation.totalAmount.toLocaleString()}</span> <span className='font-semibold'>{documentData.quotation.totalAmount.toLocaleString()}</span>
</div> </div>
</CardContent> </CardContent>
@@ -190,24 +190,24 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Approval Block</CardTitle> <CardTitle></CardTitle>
<CardDescription>Prepared for watermark and approval snapshot flows.</CardDescription> <CardDescription> watermark approved snapshot</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<Field label='Status' value={documentData.approval.status} /> <Field label='สถานะ' value={documentData.approval.status} />
<Field label='Workflow' value={documentData.approval.workflowName} /> <Field label='Workflow' value={documentData.approval.workflowName} />
<Field label='Approved At' value={documentData.approval.approvedAt} /> <Field label='อนุมัติเมื่อ' value={documentData.approval.approvedAt} />
<Separator /> <Separator />
{!documentData.approval.approvers.length ? ( {!documentData.approval.approvers.length ? (
<div className='text-muted-foreground text-sm'>No approval actions recorded.</div> <div className='text-muted-foreground text-sm'></div>
) : ( ) : (
documentData.approval.approvers.map((item) => ( documentData.approval.approvers.map((item) => (
<div key={`${item.stepNumber}-${item.roleCode}`} className='rounded-lg border p-3 text-sm'> <div key={`${item.stepNumber}-${item.roleCode}`} className='rounded-lg border p-3 text-sm'>
<div className='font-medium'> <div className='font-medium'>
Step {item.stepNumber} - {item.roleName} {item.stepNumber} - {item.roleName}
</div> </div>
<div className='text-muted-foreground'> <div className='text-muted-foreground'>
{item.actorName || '-'} {item.actedAt ? `on ${new Date(item.actedAt).toLocaleString()}` : ''} {item.actorName || '-'} {item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
</div> </div>
</div> </div>
)) ))
@@ -217,17 +217,17 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Signature Placeholders</CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-3 text-sm'> <CardContent className='space-y-3 text-sm'>
<Field label='Prepared By' value={documentData.signatures.preparedBy} /> <Field label='ผู้จัดทำ' value={documentData.signatures.preparedBy} />
<Field label='Requested By' value={documentData.signatures.requestedBy} /> <Field label='ผู้ขออนุมัติ' value={documentData.signatures.requestedBy} />
<Field label='Sales Manager' value={documentData.signatures.salesManager} /> <Field label='ผู้จัดการฝ่ายขาย' value={documentData.signatures.salesManager} />
<Field <Field
label='Department Manager' label='ผู้จัดการฝ่าย'
value={documentData.signatures.departmentManager} value={documentData.signatures.departmentManager}
/> />
<Field label='Top Manager' value={documentData.signatures.topManager} /> <Field label='ผู้บริหารสูงสุด' value={documentData.signatures.topManager} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -268,7 +268,7 @@ export function QuotationFormSheet({
</Select> </Select>
</Field> </Field>
<Field label='Billing Customer'> <Field label='ลูกค้าผู้รับใบเสนอราคา'>
<Select <Select
value={state.customerId} value={state.customerId}
onValueChange={(value) => { onValueChange={(value) => {

View File

@@ -1,3 +1,5 @@
import { getProjectPartyRoleThaiLabel } from './terminology';
export const PROJECT_PARTY_OPTION_CATEGORY = 'crm_project_party_role'; export const PROJECT_PARTY_OPTION_CATEGORY = 'crm_project_party_role';
const LEGACY_PROJECT_PARTY_ROLE_CODE_MAP = { const LEGACY_PROJECT_PARTY_ROLE_CODE_MAP = {
@@ -8,12 +10,12 @@ const LEGACY_PROJECT_PARTY_ROLE_CODE_MAP = {
} as const; } as const;
export const PROJECT_PARTY_CODE_LABELS: Record<string, string> = { export const PROJECT_PARTY_CODE_LABELS: Record<string, string> = {
billing_customer: 'Billing Customer', billing_customer: getProjectPartyRoleThaiLabel('billing_customer'),
customer: 'Customer', customer: getProjectPartyRoleThaiLabel('customer'),
consultant: 'Consultant', consultant: getProjectPartyRoleThaiLabel('consultant'),
contractor: 'Contractor', contractor: getProjectPartyRoleThaiLabel('contractor'),
end_customer: 'End Customer', end_customer: getProjectPartyRoleThaiLabel('end_customer'),
other: 'Other' other: getProjectPartyRoleThaiLabel('other')
}; };
export interface ProjectPartyRoleOptionLike { export interface ProjectPartyRoleOptionLike {

View File

@@ -0,0 +1,51 @@
export const CRM_TERMS = {
crm: 'CRM',
dashboard: 'Dashboard',
lead: 'ลีด',
leads: 'ลีด',
enquiry: 'โอกาสขาย',
enquiries: 'โอกาสขาย',
quotation: 'ใบเสนอราคา',
quotations: 'ใบเสนอราคา',
approval: 'อนุมัติเอกสาร',
approvals: 'อนุมัติเอกสาร',
customer: 'ลูกค้า',
customers: 'ลูกค้า',
contact: 'ผู้ติดต่อ',
contacts: 'ผู้ติดต่อ',
billingCustomer: 'ลูกค้าผู้รับใบเสนอราคา',
projectParties: 'ผู้เกี่ยวข้องในโครงการ',
crmSettings: 'ตั้งค่า CRM',
leadCount: 'จำนวนลีด',
enquiryCount: 'จำนวนโอกาสขาย',
wonCount: 'จำนวนงานที่ชนะ',
lostCount: 'จำนวนงานที่แพ้',
revenue: 'มูลค่าใบเสนอราคา',
conversionRate: 'อัตราการเปลี่ยนสถานะ'
} as const;
export const PIPELINE_STAGE_THAI_LABELS = {
lead: 'ลีด',
enquiry: 'โอกาสขาย',
closed_won: 'ชนะ',
closed_lost: 'แพ้'
} as const;
export const PROJECT_PARTY_ROLE_THAI_LABELS: Record<string, string> = {
billing_customer: 'ลูกค้าผู้รับใบเสนอราคา',
customer: 'ลูกค้า',
consultant: 'ที่ปรึกษา',
contractor: 'ผู้รับเหมา',
end_customer: 'เจ้าของโครงการ',
other: 'อื่น ๆ'
};
export function getProjectPartyRoleThaiLabel(roleCode: string, fallback?: string | null) {
return PROJECT_PARTY_ROLE_THAI_LABELS[roleCode] ?? fallback ?? roleCode;
}
export function getPipelineStageThaiLabel(
stage: keyof typeof PIPELINE_STAGE_THAI_LABELS | string
) {
return PIPELINE_STAGE_THAI_LABELS[stage as keyof typeof PIPELINE_STAGE_THAI_LABELS] ?? stage;
}

View File

@@ -13,7 +13,7 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
id: 'entityCode', id: 'entityCode',
accessorFn: (row) => row.entityCode ?? row.entityId, accessorFn: (row) => row.entityCode ?? row.entityId,
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => ( header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Request' /> <DataTableColumnHeader column={column} title='คำขอ' />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className='flex flex-col'>
@@ -27,8 +27,8 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
</div> </div>
), ),
meta: { meta: {
label: 'Request', label: 'คำขอ',
placeholder: 'Search approvals...', placeholder: 'ค้นหางานอนุมัติ...',
variant: 'text' as const, variant: 'text' as const,
icon: Icons.search icon: Icons.search
}, },
@@ -45,11 +45,11 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
label: 'Status', label: 'Status',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: [ options: [
{ value: 'pending', label: 'Pending' }, { value: 'pending', label: 'รออนุมัติ' },
{ value: 'approved', label: 'Approved' }, { value: 'approved', label: 'อนุมัติแล้ว' },
{ value: 'rejected', label: 'Rejected' }, { value: 'rejected', label: 'ไม่อนุมัติ' },
{ value: 'returned', label: 'Returned' }, { value: 'returned', label: 'ตีกลับ' },
{ value: 'cancelled', label: 'Cancelled' } { value: 'cancelled', label: 'ยกเลิก' }
] ]
}, },
enableColumnFilter: true enableColumnFilter: true
@@ -58,13 +58,13 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
id: 'entityType', id: 'entityType',
accessorKey: 'entityType', accessorKey: 'entityType',
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => ( header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Entity' /> <DataTableColumnHeader column={column} title='เอกสาร' />
), ),
cell: ({ row }) => row.original.entityType.replaceAll('_', ' '), cell: ({ row }) => row.original.entityType.replaceAll('_', ' '),
meta: { meta: {
label: 'Entity', label: 'เอกสาร',
variant: 'multiSelect' as const, variant: 'multiSelect' as const,
options: [{ value: 'quotation', label: 'Quotation' }] options: [{ value: 'quotation', label: 'ใบเสนอราคา' }]
}, },
enableColumnFilter: true enableColumnFilter: true
}, },
@@ -72,11 +72,11 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
id: 'currentStep', id: 'currentStep',
accessorKey: 'currentStep', accessorKey: 'currentStep',
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => ( header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Current Step' /> <DataTableColumnHeader column={column} title='ขั้นตอนปัจจุบัน' />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className='flex flex-col'> <div className='flex flex-col'>
<span>Step {row.original.currentStep}</span> <span> {row.original.currentStep}</span>
<span className='text-muted-foreground text-xs'> <span className='text-muted-foreground text-xs'>
{row.original.currentStepRoleName ?? '-'} {row.original.currentStepRoleName ?? '-'}
</span> </span>
@@ -87,7 +87,7 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
id: 'requestedBy', id: 'requestedBy',
accessorFn: (row) => row.requestedByName ?? row.requestedBy, accessorFn: (row) => row.requestedByName ?? row.requestedBy,
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => ( header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Requested By' /> <DataTableColumnHeader column={column} title='ผู้ส่งอนุมัติ' />
), ),
cell: ({ row }) => row.original.requestedByName ?? row.original.requestedBy cell: ({ row }) => row.original.requestedByName ?? row.original.requestedBy
}, },
@@ -95,7 +95,7 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
id: 'requestedAt', id: 'requestedAt',
accessorKey: 'requestedAt', accessorKey: 'requestedAt',
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => ( header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Requested At' /> <DataTableColumnHeader column={column} title='วันที่ส่งอนุมัติ' />
), ),
cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString() cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString()
} }

View File

@@ -60,35 +60,35 @@ export function ApprovalRequestPanel({
const approveAction = useMutation({ const approveAction = useMutation({
...approveApprovalMutation, ...approveApprovalMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Approval step completed'); toast.success('อนุมัติขั้นตอนนี้สำเร็จ');
setRemark(''); setRemark('');
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to approve request') toast.error(error instanceof Error ? error.message : 'ไม่สามารถอนุมัติคำขอได้')
}); });
const rejectAction = useMutation({ const rejectAction = useMutation({
...rejectApprovalMutation, ...rejectApprovalMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Approval request rejected'); toast.success('ไม่อนุมัติคำขอสำเร็จ');
setRemark(''); setRemark('');
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to reject request') toast.error(error instanceof Error ? error.message : 'ไม่สามารถไม่อนุมัติคำขอได้')
}); });
const returnAction = useMutation({ const returnAction = useMutation({
...returnApprovalMutation, ...returnApprovalMutation,
onSuccess: () => { onSuccess: () => {
toast.success('Approval request returned to draft'); toast.success('ตีกลับคำขอเพื่อแก้ไขสำเร็จ');
setRemark(''); setRemark('');
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to return request') toast.error(error instanceof Error ? error.message : 'ไม่สามารถตีกลับคำขอได้')
}); });
const cancelAction = useMutation({ const cancelAction = useMutation({
...cancelApprovalMutation, ...cancelApprovalMutation,
onSuccess: () => toast.success('Approval request cancelled'), onSuccess: () => toast.success('ยกเลิกคำขออนุมัติสำเร็จ'),
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to cancel request') toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกคำขอได้')
}); });
const isActing = const isActing =
approveAction.isPending || approveAction.isPending ||
@@ -100,30 +100,30 @@ export function ApprovalRequestPanel({
<div className='space-y-6'> <div className='space-y-6'>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Request Information</CardTitle> <CardTitle></CardTitle>
<CardDescription>Current workflow state for this approval request.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Workflow' value={approval.workflow.name} /> <FieldItem label='Workflow' value={approval.workflow.name} />
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-muted-foreground text-xs'>Status</div> <div className='text-muted-foreground text-xs'></div>
<ApprovalStatusBadge status={approval.request.status} /> <ApprovalStatusBadge status={approval.request.status} />
</div> </div>
<FieldItem label='Entity Type' value={approval.request.entityType.replaceAll('_', ' ')} /> <FieldItem label='ประเภทเอกสาร' value={approval.request.entityType.replaceAll('_', ' ')} />
<FieldItem label='Entity Code' value={approval.entityCode ?? approval.request.entityId} /> <FieldItem label='รหัสเอกสาร' value={approval.entityCode ?? approval.request.entityId} />
<FieldItem label='Entity Title' value={approval.entityTitle} /> <FieldItem label='ชื่อเอกสาร' value={approval.entityTitle} />
<FieldItem <FieldItem
label='Requested At' label='วันที่ส่งอนุมัติ'
value={new Date(approval.request.requestedAt).toLocaleString()} value={new Date(approval.request.requestedAt).toLocaleString()}
/> />
<FieldItem label='Requested By' value={approval.request.requestedBy} /> <FieldItem label='ผู้ส่งอนุมัติ' value={approval.request.requestedBy} />
<FieldItem <FieldItem
label='Current Step' label='ขั้นตอนปัจจุบัน'
value={ value={
approval.currentStep approval.currentStep
? `Step ${approval.currentStep.stepNumber} - ${approval.currentStep.roleName}` ? `Step ${approval.currentStep.stepNumber} - ${approval.currentStep.roleName}`
: approval.request.status === 'approved' : approval.request.status === 'approved'
? 'Completed' ? 'เสร็จสิ้น'
: '-' : '-'
} }
/> />
@@ -132,7 +132,7 @@ export function ApprovalRequestPanel({
<Button asChild variant='outline'> <Button asChild variant='outline'>
<Link href={entityHref}> <Link href={entityHref}>
<Icons.arrowRight className='mr-2 h-4 w-4' /> <Icons.arrowRight className='mr-2 h-4 w-4' />
Open Related Document
</Link> </Link>
</Button> </Button>
</div> </div>
@@ -143,7 +143,7 @@ export function ApprovalRequestPanel({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Workflow</CardTitle> <CardTitle>Workflow</CardTitle>
<CardDescription>Sequential approver chain configured for this document.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className='space-y-3'>
{approval.steps.map((step) => { {approval.steps.map((step) => {
@@ -157,12 +157,12 @@ export function ApprovalRequestPanel({
> >
<div> <div>
<div className='font-medium'> <div className='font-medium'>
Step {step.stepNumber} - {step.roleName} {step.stepNumber} - {step.roleName}
</div> </div>
<div className='text-muted-foreground text-sm'>{step.roleCode}</div> <div className='text-muted-foreground text-sm'>{step.roleCode}</div>
</div> </div>
<Badge variant={isCurrent ? 'secondary' : isCompleted ? 'default' : 'outline'}> <Badge variant={isCurrent ? 'secondary' : isCompleted ? 'default' : 'outline'}>
{isCurrent ? 'Current' : isCompleted ? 'Completed' : 'Pending'} {isCurrent ? 'ปัจจุบัน' : isCompleted ? 'เสร็จสิ้น' : 'รอดำเนินการ'}
</Badge> </Badge>
</div> </div>
); );
@@ -172,16 +172,16 @@ export function ApprovalRequestPanel({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Actions</CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription>
Approvers can approve, reject, or return on their assigned step.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<Textarea <Textarea
value={remark} value={remark}
onChange={(event) => setRemark(event.target.value)} onChange={(event) => setRemark(event.target.value)}
placeholder='Optional remark for approval history' placeholder='หมายเหตุเพิ่มเติมสำหรับประวัติการอนุมัติ'
rows={4} rows={4}
/> />
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
@@ -193,7 +193,7 @@ export function ApprovalRequestPanel({
} }
> >
<Icons.circleCheck className='mr-2 h-4 w-4' /> <Icons.circleCheck className='mr-2 h-4 w-4' />
Approve
</Button> </Button>
) : null} ) : null}
{canReject && canHandleCurrentStep && isPending ? ( {canReject && canHandleCurrentStep && isPending ? (
@@ -205,7 +205,7 @@ export function ApprovalRequestPanel({
} }
> >
<Icons.warning className='mr-2 h-4 w-4' /> <Icons.warning className='mr-2 h-4 w-4' />
Reject
</Button> </Button>
) : null} ) : null}
{canReturn && canHandleCurrentStep && isPending ? ( {canReturn && canHandleCurrentStep && isPending ? (
@@ -217,7 +217,7 @@ export function ApprovalRequestPanel({
} }
> >
<Icons.chevronLeft className='mr-2 h-4 w-4' /> <Icons.chevronLeft className='mr-2 h-4 w-4' />
Return
</Button> </Button>
) : null} ) : null}
{canCancelCurrentRequest ? ( {canCancelCurrentRequest ? (
@@ -227,36 +227,36 @@ export function ApprovalRequestPanel({
onClick={() => cancelAction.mutate(approval.request.id)} onClick={() => cancelAction.mutate(approval.request.id)}
> >
<Icons.close className='mr-2 h-4 w-4' /> <Icons.close className='mr-2 h-4 w-4' />
Cancel Request
</Button> </Button>
) : null} ) : null}
</div> </div>
{!canHandleCurrentStep && isPending ? ( {!canHandleCurrentStep && isPending ? (
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
Current step is assigned to{' '} {' '}
<span className='font-medium'>{approval.currentStep?.roleName ?? 'another role'}</span>. <span className='font-medium'>{approval.currentStep?.roleName ?? 'another role'}</span>.
</div> </div>
) : null} ) : null}
{!isPending ? ( {!isPending ? (
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
This request is already {approval.request.status.replaceAll('_', ' ')}. {approval.request.status.replaceAll('_', ' ')}
</div> </div>
) : null} ) : null}
{isActing ? ( {isActing ? (
<div className='text-muted-foreground text-xs'>Saving approval action...</div> <div className='text-muted-foreground text-xs'>...</div>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Timeline</CardTitle> <CardTitle></CardTitle>
<CardDescription>Full audit trail of request submission and approval actions.</CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{!approval.timeline.length ? ( {!approval.timeline.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No approval actions recorded yet.
</div> </div>
) : ( ) : (
approval.timeline.map((item, index) => ( approval.timeline.map((item, index) => (
@@ -272,7 +272,7 @@ export function ApprovalRequestPanel({
{item.actorName ?? item.actedBy} {item.actorName ?? item.actedBy}
</span> </span>
<span className='text-muted-foreground text-xs'> <span className='text-muted-foreground text-xs'>
Step {item.stepNumber} {item.stepNumber}
</span> </span>
</div> </div>
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground text-xs'>

View File

@@ -5,6 +5,13 @@ import { Icons } from '@/components/icons';
export function ApprovalStatusBadge({ status }: { status: string }) { export function ApprovalStatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase(); const normalized = status.toLowerCase();
const labelMap: Record<string, string> = {
pending: 'รออนุมัติ',
approved: 'อนุมัติแล้ว',
rejected: 'ไม่อนุมัติ',
returned: 'ตีกลับ',
cancelled: 'ยกเลิก'
};
const variant = const variant =
normalized === 'approved' normalized === 'approved'
? 'default' ? 'default'
@@ -25,7 +32,7 @@ export function ApprovalStatusBadge({ status }: { status: string }) {
return ( return (
<Badge variant={variant} className='capitalize'> <Badge variant={variant} className='capitalize'>
<Icon className='h-3 w-3' /> <Icon className='h-3 w-3' />
{status.replaceAll('_', ' ')} {labelMap[normalized] ?? status.replaceAll('_', ' ')}
</Badge> </Badge>
); );
} }