task-fixdatetime
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"studio": "npx drizzle-kit studio",
|
"studio": "npx drizzle-kit studio",
|
||||||
"seed:super-admin": "node scripts/seed-super-admin.js",
|
"seed:super-admin": "node scripts/seed-super-admin.js",
|
||||||
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
||||||
|
"seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts",
|
||||||
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
||||||
"migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts",
|
"migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts",
|
||||||
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
|
|||||||
259
plans/task-datetime.md
Normal file
259
plans/task-datetime.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# Task: Global Date & Time Formatting Standard (Hydration-safe)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Eliminate all React hydration mismatch issues caused by inconsistent date formatting between Server Components and Client Components.
|
||||||
|
|
||||||
|
Establish a single global date formatting standard across the entire application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Current implementation uses:
|
||||||
|
|
||||||
|
* Date.toLocaleString()
|
||||||
|
* Date.toLocaleDateString()
|
||||||
|
* Date.toLocaleTimeString()
|
||||||
|
|
||||||
|
These APIs depend on:
|
||||||
|
|
||||||
|
* Browser Locale
|
||||||
|
* Operating System Locale
|
||||||
|
* Server Locale
|
||||||
|
* Server Timezone
|
||||||
|
* Browser Timezone
|
||||||
|
|
||||||
|
This causes inconsistent rendering between SSR and Client Hydration.
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
Server
|
||||||
|
|
||||||
|
16/6/2569 17:00:00
|
||||||
|
|
||||||
|
Client
|
||||||
|
|
||||||
|
6/16/2026, 5:00:00 PM
|
||||||
|
|
||||||
|
Result
|
||||||
|
|
||||||
|
React Hydration Error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### 1. Create Global Date Utility
|
||||||
|
|
||||||
|
Create
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/date-format.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
All UI must use this utility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Never use
|
||||||
|
|
||||||
|
Forbidden
|
||||||
|
|
||||||
|
```
|
||||||
|
toLocaleString()
|
||||||
|
|
||||||
|
toLocaleDateString()
|
||||||
|
|
||||||
|
toLocaleTimeString()
|
||||||
|
|
||||||
|
Intl.DateTimeFormat()
|
||||||
|
|
||||||
|
new Date().toString()
|
||||||
|
```
|
||||||
|
|
||||||
|
directly inside React components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Standard Display Format
|
||||||
|
|
||||||
|
Date only
|
||||||
|
|
||||||
|
```
|
||||||
|
YYYY-MM-DD
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
2026-06-25
|
||||||
|
```
|
||||||
|
|
||||||
|
Date Time
|
||||||
|
|
||||||
|
```
|
||||||
|
YYYY-MM-DD HH:mm:ss
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
2026-06-25 14:35:20
|
||||||
|
```
|
||||||
|
|
||||||
|
Time
|
||||||
|
|
||||||
|
```
|
||||||
|
HH:mm:ss
|
||||||
|
```
|
||||||
|
|
||||||
|
Month
|
||||||
|
|
||||||
|
```
|
||||||
|
YYYY-MM
|
||||||
|
```
|
||||||
|
|
||||||
|
Year
|
||||||
|
|
||||||
|
```
|
||||||
|
YYYY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Timezone
|
||||||
|
|
||||||
|
Always
|
||||||
|
|
||||||
|
```
|
||||||
|
Asia/Bangkok
|
||||||
|
```
|
||||||
|
|
||||||
|
Never depend on browser timezone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Calendar
|
||||||
|
|
||||||
|
Always use
|
||||||
|
|
||||||
|
Gregorian Calendar
|
||||||
|
|
||||||
|
Never Buddhist Calendar.
|
||||||
|
|
||||||
|
Display
|
||||||
|
|
||||||
|
2026
|
||||||
|
|
||||||
|
Never
|
||||||
|
|
||||||
|
2569
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. SSR Safe
|
||||||
|
|
||||||
|
Formatting must produce identical output on
|
||||||
|
|
||||||
|
* Server
|
||||||
|
* Client
|
||||||
|
* Static Render
|
||||||
|
* Hydration
|
||||||
|
|
||||||
|
No locale-dependent output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Usage
|
||||||
|
|
||||||
|
Replace every occurrence of
|
||||||
|
|
||||||
|
```
|
||||||
|
toLocaleString()
|
||||||
|
|
||||||
|
toLocaleDateString()
|
||||||
|
|
||||||
|
toLocaleTimeString()
|
||||||
|
|
||||||
|
Intl.DateTimeFormat()
|
||||||
|
```
|
||||||
|
|
||||||
|
with
|
||||||
|
|
||||||
|
```
|
||||||
|
formatDate()
|
||||||
|
|
||||||
|
formatDateTime()
|
||||||
|
|
||||||
|
formatTime()
|
||||||
|
|
||||||
|
formatMonth()
|
||||||
|
|
||||||
|
formatYear()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Dashboard
|
||||||
|
|
||||||
|
Update all CRM Dashboard components.
|
||||||
|
|
||||||
|
Especially
|
||||||
|
|
||||||
|
* Approval Metrics
|
||||||
|
* Lead Tables
|
||||||
|
* Opportunity Tables
|
||||||
|
* Quotation Tables
|
||||||
|
* Activity Timeline
|
||||||
|
* Follow-up
|
||||||
|
* Reports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Tables
|
||||||
|
|
||||||
|
Default date column
|
||||||
|
|
||||||
|
```
|
||||||
|
YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
Only show time when business requires it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Forms
|
||||||
|
|
||||||
|
Date Picker
|
||||||
|
|
||||||
|
Store
|
||||||
|
|
||||||
|
ISO8601 UTC
|
||||||
|
|
||||||
|
Display
|
||||||
|
|
||||||
|
YYYY-MM-DD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. API
|
||||||
|
|
||||||
|
API returns
|
||||||
|
|
||||||
|
ISO8601
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-06-25T10:35:20.000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
UI handles formatting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
* No Hydration mismatch.
|
||||||
|
* No locale-dependent rendering.
|
||||||
|
* Same output in Server and Client.
|
||||||
|
* All dashboard pages use global formatter.
|
||||||
|
* All table date columns display YYYY-MM-DD.
|
||||||
|
* No usage of toLocaleString(), toLocaleDateString(), toLocaleTimeString() inside UI components.
|
||||||
614
plans/task-gendata.md
Normal file
614
plans/task-gendata.md
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
# Prompt : Generate UAT Seed Data for ALLA CRM
|
||||||
|
|
||||||
|
Role:
|
||||||
|
You are a Senior Solution Architect, Business Analyst, and Data Architect responsible for preparing realistic UAT seed data for the ALLA CRM system.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Generate business-oriented seed data that demonstrates the complete CRM workflow.
|
||||||
|
|
||||||
|
The goal is NOT to create random records.
|
||||||
|
|
||||||
|
Every record must belong to a realistic business scenario so that end users can understand the system immediately during UAT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Rules
|
||||||
|
|
||||||
|
* All data must be internally consistent.
|
||||||
|
* Every document must reference existing related data.
|
||||||
|
* Dates should simulate approximately the last 90 days.
|
||||||
|
* Mix Draft, In Progress, Completed, Won, Lost and Cancelled cases.
|
||||||
|
* Generate believable Thai company names, projects and contacts.
|
||||||
|
* Avoid Lorem Ipsum.
|
||||||
|
* Avoid fake meaningless values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Business Scenarios
|
||||||
|
|
||||||
|
Create at least 15 complete business stories.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
## Story 1
|
||||||
|
|
||||||
|
Lead received from Website
|
||||||
|
|
||||||
|
Customer:
|
||||||
|
Bangkok Food Industry Co.,Ltd.
|
||||||
|
|
||||||
|
Project:
|
||||||
|
Warehouse Crane Installation
|
||||||
|
|
||||||
|
Flow
|
||||||
|
|
||||||
|
Lead
|
||||||
|
↓
|
||||||
|
|
||||||
|
Marketing Follow-up
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Assign to Sales
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Opportunity
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Site Survey
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Requirement Gathering
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Quotation
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Approval
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Sent to Customer
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
PO Received
|
||||||
|
|
||||||
|
Story should contain every related record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 2
|
||||||
|
|
||||||
|
Lead Lost
|
||||||
|
|
||||||
|
Reason
|
||||||
|
|
||||||
|
Budget too high
|
||||||
|
|
||||||
|
Competitor:
|
||||||
|
Konecranes
|
||||||
|
|
||||||
|
Outcome
|
||||||
|
|
||||||
|
Closed Lost
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 3
|
||||||
|
|
||||||
|
Customer requested Revision
|
||||||
|
|
||||||
|
Quotation R01
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Rejected by customer
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Quotation R02
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Approved
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
PO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 4
|
||||||
|
|
||||||
|
No Quotation
|
||||||
|
|
||||||
|
Reason
|
||||||
|
|
||||||
|
Customer cancelled project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 5
|
||||||
|
|
||||||
|
Large project
|
||||||
|
|
||||||
|
Multiple project parties
|
||||||
|
|
||||||
|
Billing Customer
|
||||||
|
|
||||||
|
Contractor
|
||||||
|
|
||||||
|
Consultant
|
||||||
|
|
||||||
|
End Customer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 6
|
||||||
|
|
||||||
|
Quotation waiting approval
|
||||||
|
|
||||||
|
Sales Manager pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 7
|
||||||
|
|
||||||
|
Approval rejected
|
||||||
|
|
||||||
|
Return to Sales
|
||||||
|
|
||||||
|
Create Revision
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 8
|
||||||
|
|
||||||
|
Follow-up overdue
|
||||||
|
|
||||||
|
Sales forgot to contact customer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 9
|
||||||
|
|
||||||
|
High Value Project
|
||||||
|
|
||||||
|
Estimated value
|
||||||
|
|
||||||
|
35 Million THB
|
||||||
|
|
||||||
|
Chance
|
||||||
|
|
||||||
|
80%
|
||||||
|
|
||||||
|
Priority
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Story 10
|
||||||
|
|
||||||
|
Service Project
|
||||||
|
|
||||||
|
Product Type
|
||||||
|
|
||||||
|
Service
|
||||||
|
|
||||||
|
Maintenance Contract
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Continue until at least 15 stories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Master Data
|
||||||
|
|
||||||
|
Generate realistic master data.
|
||||||
|
|
||||||
|
Organizations
|
||||||
|
|
||||||
|
Branches
|
||||||
|
|
||||||
|
Product Types
|
||||||
|
|
||||||
|
Sales Teams
|
||||||
|
|
||||||
|
Marketing Teams
|
||||||
|
|
||||||
|
Users
|
||||||
|
|
||||||
|
Approval Roles
|
||||||
|
|
||||||
|
Customers
|
||||||
|
|
||||||
|
Customer Groups
|
||||||
|
|
||||||
|
Contacts
|
||||||
|
|
||||||
|
Sites
|
||||||
|
|
||||||
|
Industries
|
||||||
|
|
||||||
|
Competitors
|
||||||
|
|
||||||
|
Lead Sources
|
||||||
|
|
||||||
|
Currencies
|
||||||
|
|
||||||
|
Units
|
||||||
|
|
||||||
|
Payment Terms
|
||||||
|
|
||||||
|
Delivery Terms
|
||||||
|
|
||||||
|
Master Options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer Requirements
|
||||||
|
|
||||||
|
Create approximately
|
||||||
|
|
||||||
|
50 Customers
|
||||||
|
|
||||||
|
Each customer has
|
||||||
|
|
||||||
|
1–5 Sites
|
||||||
|
|
||||||
|
Each site has
|
||||||
|
|
||||||
|
2–6 Contacts
|
||||||
|
|
||||||
|
Different industries
|
||||||
|
|
||||||
|
Construction
|
||||||
|
|
||||||
|
Factory
|
||||||
|
|
||||||
|
Food
|
||||||
|
|
||||||
|
Automotive
|
||||||
|
|
||||||
|
Power Plant
|
||||||
|
|
||||||
|
Logistics
|
||||||
|
|
||||||
|
Hospital
|
||||||
|
|
||||||
|
Government
|
||||||
|
|
||||||
|
University
|
||||||
|
|
||||||
|
Commercial Building
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Requirements
|
||||||
|
|
||||||
|
Generate approximately
|
||||||
|
|
||||||
|
80 Leads
|
||||||
|
|
||||||
|
Status distribution
|
||||||
|
|
||||||
|
20 New
|
||||||
|
|
||||||
|
15 Contacted
|
||||||
|
|
||||||
|
10 Assigned
|
||||||
|
|
||||||
|
15 Opportunity
|
||||||
|
|
||||||
|
10 Closed Won
|
||||||
|
|
||||||
|
8 Closed Lost
|
||||||
|
|
||||||
|
2 Cancelled
|
||||||
|
|
||||||
|
Different Lead Sources
|
||||||
|
|
||||||
|
Website
|
||||||
|
|
||||||
|
Facebook
|
||||||
|
|
||||||
|
LINE OA
|
||||||
|
|
||||||
|
Referral
|
||||||
|
|
||||||
|
Existing Customer
|
||||||
|
|
||||||
|
Sales Visit
|
||||||
|
|
||||||
|
Exhibition
|
||||||
|
|
||||||
|
Phone Call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opportunity Requirements
|
||||||
|
|
||||||
|
Generate approximately
|
||||||
|
|
||||||
|
40 Opportunities
|
||||||
|
|
||||||
|
Different pipeline stages
|
||||||
|
|
||||||
|
Requirement Gathering
|
||||||
|
|
||||||
|
Budget Checking
|
||||||
|
|
||||||
|
Competitor Analysis
|
||||||
|
|
||||||
|
Proposal Preparation
|
||||||
|
|
||||||
|
Quotation
|
||||||
|
|
||||||
|
Negotiation
|
||||||
|
|
||||||
|
PO Pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quotation Requirements
|
||||||
|
|
||||||
|
Generate approximately
|
||||||
|
|
||||||
|
60 Quotations
|
||||||
|
|
||||||
|
Mix
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
Pending Approval
|
||||||
|
|
||||||
|
Approved
|
||||||
|
|
||||||
|
Sent
|
||||||
|
|
||||||
|
Following Up
|
||||||
|
|
||||||
|
Won
|
||||||
|
|
||||||
|
Lost
|
||||||
|
|
||||||
|
Cancelled
|
||||||
|
|
||||||
|
Around 20 quotations should have Revision R02 or R03.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Each quotation should contain
|
||||||
|
|
||||||
|
Header
|
||||||
|
|
||||||
|
Customer
|
||||||
|
|
||||||
|
Project
|
||||||
|
|
||||||
|
Project Parties
|
||||||
|
|
||||||
|
Items
|
||||||
|
|
||||||
|
Topics
|
||||||
|
|
||||||
|
Scope
|
||||||
|
|
||||||
|
Exclusion
|
||||||
|
|
||||||
|
Payment Terms
|
||||||
|
|
||||||
|
Delivery
|
||||||
|
|
||||||
|
Warranty
|
||||||
|
|
||||||
|
Attachments
|
||||||
|
|
||||||
|
Follow-ups
|
||||||
|
|
||||||
|
Approval History
|
||||||
|
|
||||||
|
Remarks
|
||||||
|
|
||||||
|
Currency
|
||||||
|
|
||||||
|
Tax
|
||||||
|
|
||||||
|
Discount
|
||||||
|
|
||||||
|
Totals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approval Data
|
||||||
|
|
||||||
|
Generate realistic approval history.
|
||||||
|
|
||||||
|
Sales Manager
|
||||||
|
|
||||||
|
Department Manager
|
||||||
|
|
||||||
|
Managing Director
|
||||||
|
|
||||||
|
Include
|
||||||
|
|
||||||
|
Approved
|
||||||
|
|
||||||
|
Rejected
|
||||||
|
|
||||||
|
Pending
|
||||||
|
|
||||||
|
Returned
|
||||||
|
|
||||||
|
Approval timestamps must be realistic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PO Data
|
||||||
|
|
||||||
|
Generate
|
||||||
|
|
||||||
|
25 Purchase Orders
|
||||||
|
|
||||||
|
Linked to approved quotations only.
|
||||||
|
|
||||||
|
Include
|
||||||
|
|
||||||
|
PO Number
|
||||||
|
|
||||||
|
PO Date
|
||||||
|
|
||||||
|
Currency
|
||||||
|
|
||||||
|
Amount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Distribution
|
||||||
|
|
||||||
|
Data should produce meaningful dashboards.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
|
||||||
|
Sales Pipeline
|
||||||
|
|
||||||
|
Quotation by Status
|
||||||
|
|
||||||
|
Revenue Forecast
|
||||||
|
|
||||||
|
Won vs Lost
|
||||||
|
|
||||||
|
Top Customers
|
||||||
|
|
||||||
|
Top Salespersons
|
||||||
|
|
||||||
|
Follow-up Due Today
|
||||||
|
|
||||||
|
Overdue Follow-up
|
||||||
|
|
||||||
|
Opportunity Value
|
||||||
|
|
||||||
|
Monthly Revenue
|
||||||
|
|
||||||
|
Branch Comparison
|
||||||
|
|
||||||
|
Product Type Comparison
|
||||||
|
|
||||||
|
Lead Source Analysis
|
||||||
|
|
||||||
|
Competitor Analysis
|
||||||
|
|
||||||
|
Conversion Rate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up Activities
|
||||||
|
|
||||||
|
Every Lead, Opportunity and Quotation should contain timeline activities.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
|
||||||
|
Phone Call
|
||||||
|
|
||||||
|
LINE
|
||||||
|
|
||||||
|
Email
|
||||||
|
|
||||||
|
Meeting
|
||||||
|
|
||||||
|
Site Survey
|
||||||
|
|
||||||
|
Customer Visit
|
||||||
|
|
||||||
|
Quotation Sent
|
||||||
|
|
||||||
|
Reminder
|
||||||
|
|
||||||
|
PO Follow-up
|
||||||
|
|
||||||
|
Each activity should contain
|
||||||
|
|
||||||
|
Date
|
||||||
|
|
||||||
|
Owner
|
||||||
|
|
||||||
|
Remark
|
||||||
|
|
||||||
|
Next Follow-up Date
|
||||||
|
|
||||||
|
Status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Quality
|
||||||
|
|
||||||
|
Every foreign key must be valid.
|
||||||
|
|
||||||
|
Every document number must follow project numbering rules.
|
||||||
|
|
||||||
|
No orphan records.
|
||||||
|
|
||||||
|
No duplicated business logic.
|
||||||
|
|
||||||
|
Statuses must match the workflow.
|
||||||
|
|
||||||
|
Approval history must match document status.
|
||||||
|
|
||||||
|
Won quotations must have PO.
|
||||||
|
|
||||||
|
Lost quotations must contain Lost Reason.
|
||||||
|
|
||||||
|
Cancelled documents must contain Cancel Reason.
|
||||||
|
|
||||||
|
No Quotation cases must contain No Quotation Reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Generate the seed in modular files.
|
||||||
|
|
||||||
|
seed/
|
||||||
|
|
||||||
|
master/
|
||||||
|
|
||||||
|
organization.seed.ts
|
||||||
|
|
||||||
|
branch.seed.ts
|
||||||
|
|
||||||
|
users.seed.ts
|
||||||
|
|
||||||
|
customers.seed.ts
|
||||||
|
|
||||||
|
contacts.seed.ts
|
||||||
|
|
||||||
|
lead.seed.ts
|
||||||
|
|
||||||
|
opportunity.seed.ts
|
||||||
|
|
||||||
|
quotation.seed.ts
|
||||||
|
|
||||||
|
approval.seed.ts
|
||||||
|
|
||||||
|
purchase-order.seed.ts
|
||||||
|
|
||||||
|
followup.seed.ts
|
||||||
|
|
||||||
|
attachments.seed.ts
|
||||||
|
|
||||||
|
dashboard.seed.ts
|
||||||
|
|
||||||
|
The seed must be idempotent.
|
||||||
|
|
||||||
|
Running multiple times should not create duplicated business records.
|
||||||
|
|
||||||
|
The generated data should allow UAT users to experience every important workflow in the CRM system without manually creating records.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Terms of Service',
|
title: 'Terms of Service',
|
||||||
@@ -15,12 +16,7 @@ export default function TermsOfServicePage() {
|
|||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<h1 className='text-foreground text-3xl font-bold'>Terms of Service</h1>
|
<h1 className='text-foreground text-3xl font-bold'>Terms of Service</h1>
|
||||||
<p className='text-muted-foreground mt-2 text-sm'>
|
<p className='text-muted-foreground mt-2 text-sm'>
|
||||||
Last updated:{' '}
|
Last updated: {formatDate(new Date())}
|
||||||
{new Date().toLocaleDateString('en-US', {
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// ─── GitHub API helpers (self-contained) ─────────────────────────────────────
|
// ─── GitHub API helpers (self-contained) ─────────────────────────────────────
|
||||||
@@ -38,7 +39,7 @@ function formatCount(count: number): string {
|
|||||||
const value = count / 1_000;
|
const value = count / 1_000;
|
||||||
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}k`;
|
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}k`;
|
||||||
}
|
}
|
||||||
return count.toLocaleString('en-US');
|
return formatNumber(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
@@ -131,7 +132,7 @@ async function GitHubStarsButton({
|
|||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
data-slot='github-stars-button'
|
data-slot='github-stars-button'
|
||||||
aria-label={`${fullName} on GitHub${stars !== null ? ` — ${stars.toLocaleString('en-US')} stars` : ''}`}
|
aria-label={`${fullName} on GitHub${stars !== null ? ` — ${formatNumber(stars)} stars` : ''}`}
|
||||||
className={cn(githubStarsButtonVariants({ variant, size, className }))}
|
className={cn(githubStarsButtonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as RechartsPrimitive from 'recharts';
|
import * as RechartsPrimitive from 'recharts';
|
||||||
|
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
@@ -219,7 +220,7 @@ function ChartTooltipContent({
|
|||||||
</div>
|
</div>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
<span className='text-foreground font-mono font-medium tabular-nums'>
|
<span className='text-foreground font-mono font-medium tabular-nums'>
|
||||||
{item.value.toLocaleString()}
|
{Array.isArray(item.value) ? item.value.join(', ') : formatNumber(item.value)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type NotificationStatus = 'unread' | 'read' | 'archived';
|
export type NotificationStatus = 'unread' | 'read' | 'archived';
|
||||||
@@ -30,22 +31,7 @@ export interface NotificationCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: string | Date): string => {
|
const formatDate = (date: string | Date): string => {
|
||||||
const d = new Date(date);
|
return formatDateTime(date);
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'Just now';
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
|
||||||
|
|
||||||
return d.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionIcon = (actionType: ActionType) => {
|
const getActionIcon = (actionType: ActionType) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export function DataTableSliderFilter<TData>({ column, title }: DataTableSliderF
|
|||||||
}, [columnFilterValue, min, max]);
|
}, [columnFilterValue, min, max]);
|
||||||
|
|
||||||
const formatValue = React.useCallback((value: number) => {
|
const formatValue = React.useCallback((value: number) => {
|
||||||
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
return formatNumber(value, { maximumFractionDigits: 0 });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onFromInputChange = React.useCallback(
|
const onFromInputChange = React.useCallback(
|
||||||
@@ -108,12 +109,9 @@ export function DataTableSliderFilter<TData>({ column, title }: DataTableSliderF
|
|||||||
[column]
|
[column]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onReset = React.useCallback(
|
const onReset = React.useCallback(() => {
|
||||||
() => {
|
|
||||||
column.setFilterValue(undefined);
|
column.setFilterValue(undefined);
|
||||||
},
|
}, [column]);
|
||||||
[column]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-1'>
|
<div className='flex items-center gap-1'>
|
||||||
|
|||||||
2924
src/db/seeds/crm-uat.seed.ts
Normal file
2924
src/db/seeds/crm-uat.seed.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useReducedMotion } from 'motion/react';
|
import { useReducedMotion } from 'motion/react';
|
||||||
|
import { formatTime } from '@/lib/date-format';
|
||||||
import { useChatStore } from '../utils/store';
|
import { useChatStore } from '../utils/store';
|
||||||
import type { Attachment, Message } from '../utils/types';
|
import type { Attachment, Message } from '../utils/types';
|
||||||
import { ConversationList } from './conversation-list';
|
import { ConversationList } from './conversation-list';
|
||||||
@@ -72,10 +73,7 @@ export function Messenger() {
|
|||||||
const delay = shouldReduceMotion ? 0 : 900;
|
const delay = shouldReduceMotion ? 0 : 900;
|
||||||
|
|
||||||
replyTimeoutRef.current = window.setTimeout(() => {
|
replyTimeoutRef.current = window.setTimeout(() => {
|
||||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
const timestamp = formatTime(new Date());
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
const incoming: Message = {
|
const incoming: Message = {
|
||||||
id: 'incoming-' + Date.now().toString(),
|
id: 'incoming-' + Date.now().toString(),
|
||||||
sender: 'contact',
|
sender: 'contact',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { formatTime } from '@/lib/date-format';
|
||||||
// import { persist } from 'zustand/middleware';
|
// import { persist } from 'zustand/middleware';
|
||||||
import type { Attachment, Conversation, Message } from './types';
|
import type { Attachment, Conversation, Message } from './types';
|
||||||
import { initialConversations } from './data';
|
import { initialConversations } from './data';
|
||||||
@@ -38,10 +39,7 @@ export const useChatStore = create<ChatState>()(
|
|||||||
|
|
||||||
sendMessage: (text, attachments) => {
|
sendMessage: (text, attachments) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
const timestamp = formatTime(new Date());
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
const outgoing: Message = {
|
const outgoing: Message = {
|
||||||
id: 'outgoing-' + Date.now().toString(),
|
id: 'outgoing-' + Date.now().toString(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ export function formatCurrency(value: number) {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { formatDate as formatGlobalDate } from '@/lib/date-format';
|
||||||
|
|
||||||
export function formatDate(value: string) {
|
export function formatDate(value: string) {
|
||||||
return new Intl.DateTimeFormat('th-TH', {
|
return formatGlobalDate(value);
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPercent(value: number) {
|
export function formatPercent(value: number) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
import type { CustomerListItem, CustomerReferenceData } from '../api/types';
|
import type { CustomerListItem, CustomerReferenceData } from '../api/types';
|
||||||
import { CustomerCellAction } from './customer-cell-action';
|
import { CustomerCellAction } from './customer-cell-action';
|
||||||
import { CustomerStatusBadge } from './customer-status-badge';
|
import { CustomerStatusBadge } from './customer-status-badge';
|
||||||
@@ -140,7 +141,9 @@ export function getCustomerColumns({
|
|||||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Owner' />
|
<DataTableColumnHeader column={column} title='Owner' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.ownerName ?? userMap.get(row.original.ownerUserId ?? '') ?? '-'}</span>,
|
cell: ({ row }) => (
|
||||||
|
<span>{row.original.ownerName ?? userMap.get(row.original.ownerUserId ?? '') ?? '-'}</span>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Owner',
|
label: 'Owner',
|
||||||
variant: 'multiSelect' as const,
|
variant: 'multiSelect' as const,
|
||||||
@@ -157,7 +160,13 @@ export function getCustomerColumns({
|
|||||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Owner Assigned' />
|
<DataTableColumnHeader column={column} title='Owner Assigned' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.ownerAssignedAt ? new Date(row.original.ownerAssignedAt).toLocaleDateString() : '-'}</span>
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{row.original.ownerAssignedAt
|
||||||
|
? formatDate(row.original.ownerAssignedAt)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'contactCount',
|
id: 'contactCount',
|
||||||
|
|||||||
@@ -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 { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { customerByIdOptions } from '../api/queries';
|
import { customerByIdOptions } from '../api/queries';
|
||||||
import type { CustomerReferenceData } from '../api/types';
|
import type { CustomerReferenceData } from '../api/types';
|
||||||
import { CustomerFormSheet } from './customer-form-sheet';
|
import { CustomerFormSheet } from './customer-form-sheet';
|
||||||
@@ -146,10 +147,7 @@ export function CustomerDetail({
|
|||||||
label='Branch'
|
label='Branch'
|
||||||
value={customer.branchId ? branchMap.get(customer.branchId) : null}
|
value={customer.branchId ? branchMap.get(customer.branchId) : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem label='ที่มาของลีด' value={leadMap.get(customer.leadChannel ?? '')} />
|
||||||
label='ที่มาของลีด'
|
|
||||||
value={leadMap.get(customer.leadChannel ?? '')}
|
|
||||||
/>
|
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='กลุ่มลูกค้า'
|
label='กลุ่มลูกค้า'
|
||||||
value={groupMap.get(customer.customerGroup ?? '')}
|
value={groupMap.get(customer.customerGroup ?? '')}
|
||||||
@@ -236,7 +234,9 @@ export function CustomerDetail({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>เอกสารที่เกี่ยวข้อง</CardTitle>
|
<CardTitle>เอกสารที่เกี่ยวข้อง</CardTitle>
|
||||||
<CardDescription>ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้</CardDescription>
|
<CardDescription>
|
||||||
|
ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!relatedOpportunities.length && !relatedQuotations.length ? (
|
{!relatedOpportunities.length && !relatedQuotations.length ? (
|
||||||
@@ -272,7 +272,7 @@ export function CustomerDetail({
|
|||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<div className='font-medium'>{quotation.code}</div>
|
<div className='font-medium'>{quotation.code}</div>
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='text-muted-foreground text-xs'>
|
||||||
{quotation.totalAmount.toLocaleString()}
|
{formatNumber(quotation.totalAmount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@/components/ui/table';
|
||||||
import type { CrmDashboardResponse } from '../api/types';
|
import type { CrmDashboardResponse } from '../api/types';
|
||||||
|
|
||||||
function Stat({ label, value, suffix }: { label: string; value: number; suffix?: string }) {
|
function Stat({ label, value, suffix }: { label: string; value: number; suffix?: string }) {
|
||||||
@@ -64,7 +72,7 @@ export function DashboardApprovalMetrics({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.workflowName}</TableCell>
|
<TableCell>{row.workflowName}</TableCell>
|
||||||
<TableCell>{row.currentStepRoleName ?? '-'}</TableCell>
|
<TableCell>{row.currentStepRoleName ?? '-'}</TableCell>
|
||||||
<TableCell>{new Date(row.requestedAt).toLocaleString()}</TableCell>
|
<TableCell>{formatDateTime(row.requestedAt)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@/components/ui/table';
|
||||||
import type { CrmDashboardResponse } from '../api/types';
|
import type { CrmDashboardResponse } from '../api/types';
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: number }) {
|
function Stat({ label, value }: { label: string; value: number }) {
|
||||||
@@ -53,7 +61,7 @@ export function DashboardFollowups({
|
|||||||
) : (
|
) : (
|
||||||
followups.upcoming.map((row) => (
|
followups.upcoming.map((row) => (
|
||||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
<TableCell>{formatDate(row.followupDate)}</TableCell>
|
||||||
<TableCell>{row.customerName}</TableCell>
|
<TableCell>{row.customerName}</TableCell>
|
||||||
<TableCell>{row.opportunityName}</TableCell>
|
<TableCell>{row.opportunityName}</TableCell>
|
||||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
|
|||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import type { LeadReferenceData, LeadSummary } from '../api/types';
|
import type { LeadReferenceData, LeadSummary } from '../api/types';
|
||||||
import { LeadCellAction } from './lead-cell-action';
|
import { LeadCellAction } from './lead-cell-action';
|
||||||
|
|
||||||
@@ -28,7 +30,10 @@ export function getLeadColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link href={`/dashboard/crm/leads/${row.original.id}`} className='font-medium hover:underline'>
|
<Link
|
||||||
|
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
{row.original.code}
|
{row.original.code}
|
||||||
</Link>
|
</Link>
|
||||||
<span className='text-muted-foreground text-xs'>{row.original.customerName ?? '-'}</span>
|
<span className='text-muted-foreground text-xs'>{row.original.customerName ?? '-'}</span>
|
||||||
@@ -48,7 +53,9 @@ export function getLeadColumns({
|
|||||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Status' />
|
<DataTableColumnHeader column={column} title='Status' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <Badge variant='outline'>{row.original.statusLabel ?? row.original.status}</Badge>,
|
cell: ({ row }) => (
|
||||||
|
<Badge variant='outline'>{row.original.statusLabel ?? row.original.status}</Badge>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
variant: 'select' as const,
|
variant: 'select' as const,
|
||||||
@@ -136,7 +143,9 @@ export function getLeadColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>
|
||||||
{row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'}
|
{row.original.estimatedValue !== null
|
||||||
|
? formatNumber(row.original.estimatedValue)
|
||||||
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -146,7 +155,7 @@ export function getLeadColumns({
|
|||||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Created' />
|
<DataTableColumnHeader column={column} title='Created' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
cell: ({ row }) => <span>{formatDate(row.original.createdAt)}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { leadByIdOptions } from '../api/queries';
|
import { leadByIdOptions } from '../api/queries';
|
||||||
import type { LeadReferenceData } from '../api/types';
|
import type { LeadReferenceData } from '../api/types';
|
||||||
import { LeadAssignmentDialog } from './lead-assignment-dialog';
|
import { LeadAssignmentDialog } from './lead-assignment-dialog';
|
||||||
@@ -42,7 +44,12 @@ export function LeadDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<LeadForm open={editOpen} onOpenChange={setEditOpen} referenceData={referenceData} lead={lead} />
|
<LeadForm
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
referenceData={referenceData}
|
||||||
|
lead={lead}
|
||||||
|
/>
|
||||||
<LeadAssignmentDialog
|
<LeadAssignmentDialog
|
||||||
lead={lead}
|
lead={lead}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
@@ -93,7 +100,7 @@ export function LeadDetail({
|
|||||||
<FieldItem label='Assigned Sales Owner' value={lead.assignedSalesOwnerName} />
|
<FieldItem label='Assigned Sales Owner' value={lead.assignedSalesOwnerName} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Estimated Value'
|
label='Estimated Value'
|
||||||
value={lead.estimatedValue !== null ? lead.estimatedValue.toLocaleString() : null}
|
value={lead.estimatedValue !== null ? formatNumber(lead.estimatedValue) : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Assignment Remark' value={lead.assignmentRemark} />
|
<FieldItem label='Assignment Remark' value={lead.assignmentRemark} />
|
||||||
<FieldItem label='Description' value={lead.description} />
|
<FieldItem label='Description' value={lead.description} />
|
||||||
@@ -118,7 +125,9 @@ export function LeadDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{lead.linkedOpportunities.length === 0 ? (
|
{lead.linkedOpportunities.length === 0 ? (
|
||||||
<div className='text-muted-foreground text-sm'>No linked opportunities yet.</div>
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
No linked opportunities yet.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
lead.linkedOpportunities.map((opportunity) => (
|
lead.linkedOpportunities.map((opportunity) => (
|
||||||
<div key={opportunity.id} className='border-border rounded-md border p-3'>
|
<div key={opportunity.id} className='border-border rounded-md border p-3'>
|
||||||
@@ -156,7 +165,7 @@ export function LeadDetail({
|
|||||||
<div className='font-medium'>{activity.action}</div>
|
<div className='font-medium'>{activity.action}</div>
|
||||||
<div className='text-muted-foreground mt-1 text-xs'>
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
{activity.actorName ?? activity.userId} -{' '}
|
{activity.actorName ?? activity.userId} -{' '}
|
||||||
{new Date(activity.createdAt).toLocaleString()}
|
{formatDateTime(activity.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -174,10 +183,13 @@ export function LeadDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
<FieldItem label='Current Workspace' value='Lead' />
|
<FieldItem label='Current Workspace' value='Lead' />
|
||||||
<FieldItem label='Related Opportunities' value={String(lead.relatedOpportunityCount)} />
|
<FieldItem
|
||||||
|
label='Related Opportunities'
|
||||||
|
value={String(lead.relatedOpportunityCount)}
|
||||||
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Assigned At'
|
label='Assigned At'
|
||||||
value={lead.assignedAt ? new Date(lead.assignedAt).toLocaleString() : null}
|
value={lead.assignedAt ? formatDateTime(lead.assignedAt) : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Assigned By' value={lead.assignedByName} />
|
<FieldItem label='Assigned By' value={lead.assignedByName} />
|
||||||
<FieldItem label='Deleted' value={canDelete ? 'Allowed' : 'Not allowed'} />
|
<FieldItem label='Deleted' value={canDelete ? 'Allowed' : 'Not allowed'} />
|
||||||
@@ -188,4 +200,3 @@ export function LeadDetail({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
import type { LeadDetailResponse } from '../api/types';
|
import type { LeadDetailResponse } from '../api/types';
|
||||||
import { createLeadFollowupMutation } from '../api/mutations';
|
import { createLeadFollowupMutation } from '../api/mutations';
|
||||||
|
|
||||||
@@ -101,10 +102,10 @@ export function LeadFollowupPanel({
|
|||||||
detail.followups.map((followup) => (
|
detail.followups.map((followup) => (
|
||||||
<div key={followup.id} className='border-border rounded-md border p-3'>
|
<div key={followup.id} className='border-border rounded-md border p-3'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<div className='font-medium'>{followup.followupStatusLabel ?? followup.followupStatus}</div>
|
<div className='font-medium'>
|
||||||
<div className='text-muted-foreground text-xs'>
|
{followup.followupStatusLabel ?? followup.followupStatus}
|
||||||
{new Date(followup.followupDate).toLocaleDateString()}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>{formatDate(followup.followupDate)}</div>
|
||||||
</div>
|
</div>
|
||||||
{followup.note ? <div className='mt-2 text-sm'>{followup.note}</div> : null}
|
{followup.note ? <div className='mt-2 text-sm'>{followup.note}</div> : null}
|
||||||
<div className='text-muted-foreground mt-2 text-xs'>
|
<div className='text-muted-foreground mt-2 text-xs'>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
|
|||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
|
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
|
||||||
import { OpportunityCellAction } from './opportunity-cell-action';
|
import { OpportunityCellAction } from './opportunity-cell-action';
|
||||||
import { OpportunityStatusBadge } from './opportunity-status-badge';
|
import { OpportunityStatusBadge } from './opportunity-status-badge';
|
||||||
@@ -36,7 +38,10 @@ function getLeadColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link href={`/dashboard/crm/leads/${row.original.id}`} className='font-medium hover:underline'>
|
<Link
|
||||||
|
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
{row.original.code}
|
{row.original.code}
|
||||||
</Link>
|
</Link>
|
||||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||||
@@ -156,7 +161,9 @@ function getOpportunityWorkspaceColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.leadId ? (
|
row.original.leadId ? (
|
||||||
<Badge variant='outline'>{row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'}</Badge>
|
<Badge variant='outline'>
|
||||||
|
{row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'}
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className='text-muted-foreground text-sm'>Direct sales</span>
|
<span className='text-muted-foreground text-sm'>Direct sales</span>
|
||||||
)
|
)
|
||||||
@@ -192,7 +199,9 @@ function getOpportunityWorkspaceColumns({
|
|||||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Product Type' />
|
<DataTableColumnHeader column={column} title='Product Type' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>,
|
cell: ({ row }) => (
|
||||||
|
<span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Product Type',
|
label: 'Product Type',
|
||||||
variant: 'multiSelect' as const,
|
variant: 'multiSelect' as const,
|
||||||
@@ -239,7 +248,9 @@ function getOpportunityWorkspaceColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>
|
||||||
{row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'}
|
{row.original.estimatedValue !== null
|
||||||
|
? formatNumber(row.original.estimatedValue)
|
||||||
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -249,7 +260,9 @@ function getOpportunityWorkspaceColumns({
|
|||||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Chance %' />
|
<DataTableColumnHeader column={column} title='Chance %' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
cell: ({ row }) => (
|
||||||
|
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'expectedCloseDate',
|
id: 'expectedCloseDate',
|
||||||
@@ -260,7 +273,7 @@ function getOpportunityWorkspaceColumns({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>
|
||||||
{row.original.expectedCloseDate
|
{row.original.expectedCloseDate
|
||||||
? new Date(row.original.expectedCloseDate).toLocaleDateString()
|
? formatDate(row.original.expectedCloseDate)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -273,7 +286,9 @@ function getOpportunityWorkspaceColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>
|
||||||
{row.original.nextFollowupDate ? new Date(row.original.nextFollowupDate).toLocaleDateString() : '-'}
|
{row.original.nextFollowupDate
|
||||||
|
? formatDate(row.original.nextFollowupDate)
|
||||||
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -283,7 +298,7 @@ function getOpportunityWorkspaceColumns({
|
|||||||
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Created Date' />
|
<DataTableColumnHeader column={column} title='Created Date' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
cell: ({ row }) => <span>{formatDate(row.original.createdAt)}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -311,6 +326,7 @@ export function getOpportunityColumns({
|
|||||||
canReassign
|
canReassign
|
||||||
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] {
|
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] {
|
||||||
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
||||||
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getOpportunityWorkspaceColumns(sharedConfig);
|
return workspace === 'lead'
|
||||||
|
? getLeadColumns(sharedConfig)
|
||||||
|
: getOpportunityWorkspaceColumns(sharedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
|
import type {
|
||||||
|
QuotationReferenceData,
|
||||||
|
QuotationRelationItem
|
||||||
|
} from '@/features/crm/quotations/api/types';
|
||||||
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
|
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
|
||||||
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
||||||
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries';
|
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries';
|
||||||
@@ -76,7 +81,9 @@ export function OpportunityDetail({
|
|||||||
quotationReferenceData = null
|
quotationReferenceData = null
|
||||||
}: OpportunityDetailProps) {
|
}: OpportunityDetailProps) {
|
||||||
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
|
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
|
||||||
const { data: projectPartiesData } = useSuspenseQuery(opportunityProjectPartiesOptions(opportunityId));
|
const { data: projectPartiesData } = useSuspenseQuery(
|
||||||
|
opportunityProjectPartiesOptions(opportunityId)
|
||||||
|
);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
const [quotationOpen, setQuotationOpen] = useState(false);
|
const [quotationOpen, setQuotationOpen] = useState(false);
|
||||||
@@ -142,7 +149,10 @@ export function OpportunityDetail({
|
|||||||
<div>
|
<div>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2>
|
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2>
|
||||||
<OpportunityStatusBadge code={status?.code} label={status?.label ?? opportunity.status} />
|
<OpportunityStatusBadge
|
||||||
|
code={status?.code}
|
||||||
|
label={status?.label ?? opportunity.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground mt-2 text-sm'>
|
<p className='text-muted-foreground mt-2 text-sm'>
|
||||||
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
||||||
@@ -193,13 +203,17 @@ export function OpportunityDetail({
|
|||||||
label='Expected Close Date'
|
label='Expected Close Date'
|
||||||
value={
|
value={
|
||||||
opportunity.expectedCloseDate
|
opportunity.expectedCloseDate
|
||||||
? new Date(opportunity.expectedCloseDate).toLocaleDateString()
|
? formatDate(opportunity.expectedCloseDate)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Estimated Value'
|
label='Estimated Value'
|
||||||
value={opportunity.estimatedValue !== null ? opportunity.estimatedValue.toLocaleString() : null}
|
value={
|
||||||
|
opportunity.estimatedValue !== null
|
||||||
|
? formatNumber(opportunity.estimatedValue)
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -216,7 +230,10 @@ export function OpportunityDetail({
|
|||||||
<FieldItem label='Contact Name' value={contact?.name} />
|
<FieldItem label='Contact Name' value={contact?.name} />
|
||||||
<FieldItem label='Contact Email' value={contact?.email} />
|
<FieldItem label='Contact Email' value={contact?.email} />
|
||||||
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
||||||
<FieldItem label='Branch' value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null} />
|
<FieldItem
|
||||||
|
label='Branch'
|
||||||
|
value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -240,8 +257,14 @@ export function OpportunityDetail({
|
|||||||
<CardTitle>Sales Qualification</CardTitle>
|
<CardTitle>Sales Qualification</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
<FieldItem label='Product Type' value={productTypeMap.get(opportunity.productType) ?? opportunity.productType} />
|
<FieldItem
|
||||||
<FieldItem label='Priority' value={priorityMap.get(opportunity.priority) ?? opportunity.priority} />
|
label='Product Type'
|
||||||
|
value={productTypeMap.get(opportunity.productType) ?? opportunity.productType}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Priority'
|
||||||
|
value={priorityMap.get(opportunity.priority) ?? opportunity.priority}
|
||||||
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Chance %'
|
label='Chance %'
|
||||||
value={opportunity.chancePercent !== null ? `${opportunity.chancePercent}%` : null}
|
value={opportunity.chancePercent !== null ? `${opportunity.chancePercent}%` : null}
|
||||||
@@ -249,7 +272,9 @@ export function OpportunityDetail({
|
|||||||
<FieldItem label='Competitor' value={opportunity.competitor} />
|
<FieldItem label='Competitor' value={opportunity.competitor} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Assigned At'
|
label='Assigned At'
|
||||||
value={opportunity.assignedAt ? new Date(opportunity.assignedAt).toLocaleString() : null}
|
value={
|
||||||
|
opportunity.assignedAt ? formatDateTime(opportunity.assignedAt) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Assigned By' value={opportunity.assignedByName} />
|
<FieldItem label='Assigned By' value={opportunity.assignedByName} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -280,7 +305,9 @@ export function OpportunityDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{!canViewRelatedQuotations ? (
|
{!canViewRelatedQuotations ? (
|
||||||
<div className='text-muted-foreground text-sm'>Quotation access is restricted.</div>
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Quotation access is restricted.
|
||||||
|
</div>
|
||||||
) : relatedQuotations.length === 0 ? (
|
) : relatedQuotations.length === 0 ? (
|
||||||
<div className='text-muted-foreground text-sm'>No quotations created yet.</div>
|
<div className='text-muted-foreground text-sm'>No quotations created yet.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -317,7 +344,7 @@ export function OpportunityDetail({
|
|||||||
<div className='font-medium'>{activity.action}</div>
|
<div className='font-medium'>{activity.action}</div>
|
||||||
<div className='text-muted-foreground mt-1 text-xs'>
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
{activity.actorName ?? activity.userId} -{' '}
|
{activity.actorName ?? activity.userId} -{' '}
|
||||||
{new Date(activity.createdAt).toLocaleString()}
|
{formatDateTime(activity.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -346,23 +373,27 @@ export function OpportunityDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
|
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
|
||||||
|
<FieldItem label='Project Parties' value={String(projectPartiesData.items.length)} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Project Parties'
|
label='Record Type'
|
||||||
value={String(projectPartiesData.items.length)}
|
value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
|
|
||||||
{relatedQuotations.length ? (
|
{relatedQuotations.length ? (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{relatedQuotations.slice(0, 3).map((quotation) => (
|
{relatedQuotations.slice(0, 3).map((quotation) => (
|
||||||
<div key={quotation.id} className='rounded-lg border p-3 text-sm'>
|
<div key={quotation.id} className='rounded-lg border p-3 text-sm'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<Link href={`/dashboard/crm/quotations/${quotation.id}`} className='font-medium hover:underline'>
|
<Link
|
||||||
|
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
{quotation.code}
|
{quotation.code}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge variant='outline'>Rev {quotation.revision}</Badge>
|
<Badge variant='outline'>Rev {quotation.revision}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground mt-1'>
|
<div className='text-muted-foreground mt-1'>
|
||||||
{quotation.status} · {quotation.totalAmount.toLocaleString()} {quotation.currency}
|
{quotation.status} · {formatNumber(quotation.totalAmount)}{' '}
|
||||||
|
{quotation.currency}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Icons } from '@/components/icons';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
import { deleteOpportunityFollowupMutation } from '../api/mutations';
|
import { deleteOpportunityFollowupMutation } from '../api/mutations';
|
||||||
import { opportunityFollowupsOptions } from '../api/queries';
|
import { opportunityFollowupsOptions } from '../api/queries';
|
||||||
import type { OpportunityReferenceData } from '../api/types';
|
import type { OpportunityReferenceData } from '../api/types';
|
||||||
@@ -42,8 +43,7 @@ export function OpportunityFollowupsTab({
|
|||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้')
|
||||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,16 +82,18 @@ export function OpportunityFollowupsTab({
|
|||||||
{followupTypeMap.get(item.followupType) ?? 'Unknown type'}
|
{followupTypeMap.get(item.followupType) ?? 'Unknown type'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className='text-sm font-medium'>
|
<span className='text-sm font-medium'>
|
||||||
{new Date(item.followupDate).toLocaleDateString()}
|
{formatDate(item.followupDate)}
|
||||||
</span>
|
</span>
|
||||||
</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'>การดำเนินการถัดไป: {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'>
|
||||||
นัดติดตามครั้งถัดไป: {new Date(item.nextFollowupDate).toLocaleDateString()}
|
นัดติดตามครั้งถัดไป: {formatDate(item.nextFollowupDate)}
|
||||||
</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}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -117,7 +119,8 @@ export function OpportunityOutcomeCard({
|
|||||||
setReopenOpen(false);
|
setReopenOpen(false);
|
||||||
await invalidateOpportunityMutationQueries(opportunityId);
|
await invalidateOpportunityMutationQueries(opportunityId);
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen opportunity')
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to reopen opportunity')
|
||||||
});
|
});
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -171,34 +174,55 @@ export function OpportunityOutcomeCard({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Outcome</CardTitle>
|
<CardTitle>Outcome</CardTitle>
|
||||||
<CardDescription>Official won/lost lifecycle and purchase order tracking.</CardDescription>
|
<CardDescription>
|
||||||
|
Official won/lost lifecycle and purchase order tracking.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<Badge variant={opportunity.pipelineStage === 'closed_won' ? 'default' : opportunity.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
|
<Badge
|
||||||
|
variant={
|
||||||
|
opportunity.pipelineStage === 'closed_won'
|
||||||
|
? 'default'
|
||||||
|
: opportunity.pipelineStage === 'closed_lost'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
{getOutcomeLabel(opportunity.pipelineStage)}
|
{getOutcomeLabel(opportunity.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{opportunity.pipelineStage === 'opportunity' && canMarkWon ? (
|
{opportunity.pipelineStage === 'opportunity' && canMarkWon ? (
|
||||||
<Button size='sm' onClick={() => {
|
<Button
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
setPoNumber(opportunity.poNumber ?? '');
|
setPoNumber(opportunity.poNumber ?? '');
|
||||||
setPoDate(opportunity.poDate ? opportunity.poDate.slice(0, 10) : '');
|
setPoDate(opportunity.poDate ? opportunity.poDate.slice(0, 10) : '');
|
||||||
setPoAmount(opportunity.poAmount !== null && opportunity.poAmount !== undefined ? String(opportunity.poAmount) : '');
|
setPoAmount(
|
||||||
|
opportunity.poAmount !== null && opportunity.poAmount !== undefined
|
||||||
|
? String(opportunity.poAmount)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
setPoCurrency(opportunity.poCurrency ?? 'THB');
|
setPoCurrency(opportunity.poCurrency ?? 'THB');
|
||||||
setWonRemark('');
|
setWonRemark('');
|
||||||
setWonOpen(true);
|
setWonOpen(true);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Icons.check className='mr-2 h-4 w-4' />
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
Mark Won
|
Mark Won
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{opportunity.pipelineStage === 'opportunity' && canMarkLost ? (
|
{opportunity.pipelineStage === 'opportunity' && canMarkLost ? (
|
||||||
<Button variant='destructive' size='sm' onClick={() => {
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
setLostReason(opportunity.lostReason ?? '');
|
setLostReason(opportunity.lostReason ?? '');
|
||||||
setLostCompetitor(opportunity.lostCompetitor ?? '');
|
setLostCompetitor(opportunity.lostCompetitor ?? '');
|
||||||
setLostRemark(opportunity.lostRemark ?? '');
|
setLostRemark(opportunity.lostRemark ?? '');
|
||||||
setLostOpen(true);
|
setLostOpen(true);
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Icons.xCircle className='mr-2 h-4 w-4' />
|
<Icons.xCircle className='mr-2 h-4 w-4' />
|
||||||
Mark Lost
|
Mark Lost
|
||||||
</Button>
|
</Button>
|
||||||
@@ -215,18 +239,18 @@ export function OpportunityOutcomeCard({
|
|||||||
<FieldItem label='Closed By' value={opportunity.closedByName} />
|
<FieldItem label='Closed By' value={opportunity.closedByName} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Closed Date'
|
label='Closed Date'
|
||||||
value={closedDate ? new Date(closedDate).toLocaleString() : null}
|
value={closedDate ? formatDateTime(closedDate) : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='PO Number' value={opportunity.poNumber} />
|
<FieldItem label='PO Number' value={opportunity.poNumber} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='PO Date'
|
label='PO Date'
|
||||||
value={opportunity.poDate ? new Date(opportunity.poDate).toLocaleDateString() : null}
|
value={opportunity.poDate ? formatDate(opportunity.poDate) : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='PO Amount'
|
label='PO Amount'
|
||||||
value={
|
value={
|
||||||
canViewCommercialData && opportunity.poAmount !== null
|
canViewCommercialData && opportunity.poAmount !== null
|
||||||
? `${opportunity.poAmount.toLocaleString()} ${opportunity.poCurrency ?? ''}`.trim()
|
? `${formatNumber(opportunity.poAmount)} ${opportunity.poCurrency ?? ''}`.trim()
|
||||||
: canViewCommercialData
|
: canViewCommercialData
|
||||||
? null
|
? null
|
||||||
: 'Restricted'
|
: 'Restricted'
|
||||||
@@ -290,17 +314,23 @@ export function OpportunityOutcomeCard({
|
|||||||
<div className='font-medium'>{attachment.originalFileName}</div>
|
<div className='font-medium'>{attachment.originalFileName}</div>
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='text-muted-foreground text-xs'>
|
||||||
{attachment.fileType ?? 'Unknown type'}
|
{attachment.fileType ?? 'Unknown type'}
|
||||||
{attachment.fileSize ? ` • ${attachment.fileSize.toLocaleString()} bytes` : ''}
|
{attachment.fileSize
|
||||||
|
? ` • ${formatNumber(attachment.fileSize)} bytes`
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<Button asChild variant='outline' size='sm'>
|
<Button asChild variant='outline' size='sm'>
|
||||||
<Link href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}`}>
|
<Link
|
||||||
|
href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}`}
|
||||||
|
>
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant='outline' size='sm'>
|
<Button asChild variant='outline' size='sm'>
|
||||||
<Link href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}?download=1`}>
|
<Link
|
||||||
|
href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}?download=1`}
|
||||||
|
>
|
||||||
Download
|
Download
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -318,12 +348,18 @@ export function OpportunityOutcomeCard({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Mark Won</DialogTitle>
|
<DialogTitle>Mark Won</DialogTitle>
|
||||||
<DialogDescription>PO received is the official trigger for closed won.</DialogDescription>
|
<DialogDescription>
|
||||||
|
PO received is the official trigger for closed won.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='grid gap-4 py-2'>
|
<div className='grid gap-4 py-2'>
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<Label htmlFor='po-number'>PO Number</Label>
|
<Label htmlFor='po-number'>PO Number</Label>
|
||||||
<Input id='po-number' value={poNumber} onChange={(e) => setPoNumber(e.target.value)} />
|
<Input
|
||||||
|
id='po-number'
|
||||||
|
value={poNumber}
|
||||||
|
onChange={(e) => setPoNumber(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<Label htmlFor='po-date'>PO Date</Label>
|
<Label htmlFor='po-date'>PO Date</Label>
|
||||||
@@ -391,7 +427,9 @@ export function OpportunityOutcomeCard({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Mark Lost</DialogTitle>
|
<DialogTitle>Mark Lost</DialogTitle>
|
||||||
<DialogDescription>Lost reason is required for every closed lost outcome.</DialogDescription>
|
<DialogDescription>
|
||||||
|
Lost reason is required for every closed lost outcome.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='grid gap-4 py-2'>
|
<div className='grid gap-4 py-2'>
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
@@ -482,4 +520,3 @@ export function OpportunityOutcomeCard({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import type { QuotationListItem, QuotationReferenceData } from '../api/types';
|
import type { QuotationListItem, QuotationReferenceData } from '../api/types';
|
||||||
import { QuotationCellAction } from './quotation-cell-action';
|
import { QuotationCellAction } from './quotation-cell-action';
|
||||||
import { QuotationStatusBadge } from './quotation-status-badge';
|
import { QuotationStatusBadge } from './quotation-status-badge';
|
||||||
@@ -31,7 +32,10 @@ export function getQuotationColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link href={`/dashboard/crm/quotations/${row.original.id}`} className='font-medium hover:underline'>
|
<Link
|
||||||
|
href={`/dashboard/crm/quotations/${row.original.id}`}
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
{row.original.code}
|
{row.original.code}
|
||||||
</Link>
|
</Link>
|
||||||
<span className='text-muted-foreground text-xs'>
|
<span className='text-muted-foreground text-xs'>
|
||||||
@@ -90,7 +94,11 @@ export function getQuotationColumns({
|
|||||||
<DataTableColumnHeader column={column} title='สาขา' />
|
<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: 'สาขา',
|
label: 'สาขา',
|
||||||
@@ -123,7 +131,10 @@ export function getQuotationColumns({
|
|||||||
meta: {
|
meta: {
|
||||||
label: 'โอกาสขาย',
|
label: 'โอกาสขาย',
|
||||||
variant: 'multiSelect' as const,
|
variant: 'multiSelect' as const,
|
||||||
options: referenceData.opportunities.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
|
options: referenceData.opportunities.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.code} - ${item.title}`
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
enableColumnFilter: true
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
@@ -150,7 +161,7 @@ export function getQuotationColumns({
|
|||||||
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
|
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='มูลค่ารวม' />
|
<DataTableColumnHeader column={column} title='มูลค่ารวม' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.original.totalAmount.toLocaleString()
|
cell: ({ row }) => formatNumber(row.original.totalAmount)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -165,4 +176,3 @@ export function getQuotationColumns({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import { useSuspenseQuery } from '@tanstack/react-query';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
|
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
@@ -58,13 +60,13 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<Field label='Revision' value={documentData.quotation.revisionLabel} />
|
<Field label='Revision' value={documentData.quotation.revisionLabel} />
|
||||||
<Field
|
<Field
|
||||||
label='วันที่ออกใบเสนอราคา'
|
label='วันที่ออกใบเสนอราคา'
|
||||||
value={new Date(documentData.quotation.quotationDate).toLocaleDateString()}
|
value={formatDate(documentData.quotation.quotationDate)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label='ใช้ได้ถึง'
|
label='ใช้ได้ถึง'
|
||||||
value={
|
value={
|
||||||
documentData.quotation.validUntil
|
documentData.quotation.validUntil
|
||||||
? new Date(documentData.quotation.validUntil).toLocaleDateString()
|
? formatDate(documentData.quotation.validUntil)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -131,8 +133,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
</div>
|
</div>
|
||||||
<div>{item.quantity}</div>
|
<div>{item.quantity}</div>
|
||||||
<div>{item.unitLabel || '-'}</div>
|
<div>{item.unitLabel || '-'}</div>
|
||||||
<div>{item.unitPrice.toLocaleString()}</div>
|
<div>{formatNumber(item.unitPrice)}</div>
|
||||||
<div>{item.totalPrice.toLocaleString()}</div>
|
<div>{formatNumber(item.totalPrice)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -183,16 +185,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<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'>ก่อนภาษี</span>
|
<span className='text-muted-foreground'>ก่อนภาษี</span>
|
||||||
<span>{documentData.quotation.subtotal.toLocaleString()}</span>
|
<span>{formatNumber(documentData.quotation.subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='text-muted-foreground'>ภาษี</span>
|
<span className='text-muted-foreground'>ภาษี</span>
|
||||||
<span>{documentData.quotation.taxAmount.toLocaleString()}</span>
|
<span>{formatNumber(documentData.quotation.taxAmount)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='text-muted-foreground'>รวมทั้งสิ้น</span>
|
<span className='text-muted-foreground'>รวมทั้งสิ้น</span>
|
||||||
<span className='font-semibold'>
|
<span className='font-semibold'>
|
||||||
{documentData.quotation.totalAmount.toLocaleString()}
|
{formatNumber(documentData.quotation.totalAmount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -221,7 +223,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground'>
|
<div className='text-muted-foreground'>
|
||||||
{item.actorName || '-'}{' '}
|
{item.actorName || '-'}{' '}
|
||||||
{item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
|
{item.actedAt ? `เมื่อ ${formatDateTime(item.actedAt)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -30,20 +30,7 @@ export function formatPdfDate(
|
|||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (language === 'th') {
|
return formatDate(date);
|
||||||
return date.toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
calendar: 'buddhist'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPdfCurrency(
|
export function formatPdfCurrency(
|
||||||
@@ -191,3 +178,4 @@ export function normalizePdfmeTable(
|
|||||||
|
|
||||||
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
|
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
|
||||||
}
|
}
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useMemo } from 'react';
|
|||||||
import { parseAsString, useQueryStates } from 'nuqs';
|
import { parseAsString, useQueryStates } from 'nuqs';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -12,10 +14,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import type {
|
import type { CrmOpportunityAgingReportResponse, CrmLeadAgingReportResponse } from '../api/types';
|
||||||
CrmOpportunityAgingReportResponse,
|
|
||||||
CrmLeadAgingReportResponse
|
|
||||||
} from '../api/types';
|
|
||||||
import {
|
import {
|
||||||
crmOpportunityAgingReportQueryOptions,
|
crmOpportunityAgingReportQueryOptions,
|
||||||
crmLeadAgingReportQueryOptions,
|
crmLeadAgingReportQueryOptions,
|
||||||
@@ -23,10 +22,6 @@ import {
|
|||||||
} from '../api/queries';
|
} from '../api/queries';
|
||||||
import { ReportFilterBar } from './report-filter-bar';
|
import { ReportFilterBar } from './report-filter-bar';
|
||||||
|
|
||||||
function formatNumber(value: number) {
|
|
||||||
return new Intl.NumberFormat('en-US').format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgingReportLayout({
|
function AgingReportLayout({
|
||||||
variant,
|
variant,
|
||||||
canExport,
|
canExport,
|
||||||
@@ -67,8 +62,16 @@ function AgingReportLayout({
|
|||||||
reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_aging'}
|
reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_aging'}
|
||||||
pageLinks={[
|
pageLinks={[
|
||||||
{ href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite' },
|
{ href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite' },
|
||||||
{ href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging', active: variant === 'lead' },
|
{
|
||||||
{ href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging', active: variant === 'opportunity' }
|
href: '/dashboard/crm/reports/lead-aging',
|
||||||
|
label: 'Lead Aging',
|
||||||
|
active: variant === 'lead'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/dashboard/crm/reports/opportunity-aging',
|
||||||
|
label: 'Opportunity Aging',
|
||||||
|
active: variant === 'opportunity'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -87,7 +90,9 @@ function AgingReportLayout({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity Aging Buckets'}</CardTitle>
|
<CardTitle>
|
||||||
|
{variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity Aging Buckets'}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>Bucketed view to spot stagnant records quickly.</CardDescription>
|
<CardDescription>Bucketed view to spot stagnant records quickly.</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'>
|
||||||
@@ -142,7 +147,9 @@ function AgingReportLayout({
|
|||||||
<TableCell>{row.leadCode}</TableCell>
|
<TableCell>{row.leadCode}</TableCell>
|
||||||
<TableCell>{row.customerName}</TableCell>
|
<TableCell>{row.customerName}</TableCell>
|
||||||
<TableCell>{row.assignedUserName ?? '-'}</TableCell>
|
<TableCell>{row.assignedUserName ?? '-'}</TableCell>
|
||||||
<TableCell>{new Date(row.createdDate).toLocaleDateString('en-CA')}</TableCell>
|
<TableCell>
|
||||||
|
{formatDate(row.createdDate)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{formatNumber(row.agingDays)}</TableCell>
|
<TableCell>{formatNumber(row.agingDays)}</TableCell>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -152,7 +159,7 @@ function AgingReportLayout({
|
|||||||
<TableCell>{row.salesmanName ?? '-'}</TableCell>
|
<TableCell>{row.salesmanName ?? '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.lastFollowupDate
|
{row.lastFollowupDate
|
||||||
? new Date(row.lastFollowupDate).toLocaleDateString('en-CA')
|
? formatDate(row.lastFollowupDate)
|
||||||
: '-'}
|
: '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatNumber(row.agingDays)}</TableCell>
|
<TableCell>{formatNumber(row.agingDays)}</TableCell>
|
||||||
@@ -240,4 +247,3 @@ export function AgingReportView({
|
|||||||
<OpportunityAgingReportInner canExport={canExport} />
|
<OpportunityAgingReportInner canExport={canExport} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { format, isValid, parse, parseISO } from 'date-fns';
|
import { isValid, parse, parseISO } from 'date-fns';
|
||||||
|
import { formatDate } from '@/lib/date-format';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
|
|
||||||
function coerceDate(value: string | Date | null | undefined) {
|
function coerceDate(value: string | Date | null | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -20,21 +22,14 @@ function coerceDate(value: string | Date | null | undefined) {
|
|||||||
|
|
||||||
export function formatCrmDate(value: string | Date | null | undefined) {
|
export function formatCrmDate(value: string | Date | null | undefined) {
|
||||||
const date = coerceDate(value);
|
const date = coerceDate(value);
|
||||||
return date ? format(date, 'dd/MM/yyyy') : '';
|
return date ? formatDate(date) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCrmNumber(value: number | string | null | undefined, options?: Intl.NumberFormatOptions) {
|
export function formatCrmNumber(
|
||||||
if (value === '' || value === null || value === undefined) {
|
value: number | string | null | undefined,
|
||||||
return '';
|
options?: Intl.NumberFormatOptions
|
||||||
}
|
) {
|
||||||
|
return formatNumber(value, options);
|
||||||
const parsed = typeof value === 'number' ? value : Number(String(value).replaceAll(',', ''));
|
|
||||||
|
|
||||||
if (Number.isNaN(parsed)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.NumberFormat('en-US', options).format(parsed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeDecimalInput(value: string) {
|
export function sanitizeDecimalInput(value: string) {
|
||||||
@@ -42,6 +37,5 @@ export function sanitizeDecimalInput(value: string) {
|
|||||||
const [head = '', ...tail] = normalized.split('.');
|
const [head = '', ...tail] = normalized.split('.');
|
||||||
const integerPart = head.replace(/(?!^)-/g, '');
|
const integerPart = head.replace(/(?!^)-/g, '');
|
||||||
const decimalPart = tail.join('');
|
const decimalPart = tail.join('');
|
||||||
|
|
||||||
return decimalPart.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
|
return decimalPart.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DataTable } from '@/components/ui/table/data-table';
|
|||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||||
import { useDataTable } from '@/hooks/use-data-table';
|
import { useDataTable } from '@/hooks/use-data-table';
|
||||||
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { getSortingStateParser } from '@/lib/parsers';
|
import { getSortingStateParser } from '@/lib/parsers';
|
||||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
@@ -60,7 +61,7 @@ const columns: ColumnDef<Product>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'price',
|
accessorKey: 'price',
|
||||||
header: 'PRICE',
|
header: 'PRICE',
|
||||||
cell: ({ row }) => `$${row.original.price.toLocaleString()}`
|
cell: ({ row }) => `$${formatNumber(row.original.price)}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
|||||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import type { ApprovalListItem } from '../types';
|
import type { ApprovalListItem } from '../types';
|
||||||
import { ApprovalStatusBadge } from './approval-status-badge';
|
import { ApprovalStatusBadge } from './approval-status-badge';
|
||||||
|
|
||||||
@@ -17,7 +18,10 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link href={`/dashboard/crm/approvals/${row.original.id}`} className='font-medium hover:underline'>
|
<Link
|
||||||
|
href={`/dashboard/crm/approvals/${row.original.id}`}
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
{row.original.entityCode ?? row.original.entityId}
|
{row.original.entityCode ?? row.original.entityId}
|
||||||
</Link>
|
</Link>
|
||||||
<span className='text-muted-foreground text-xs'>
|
<span className='text-muted-foreground text-xs'>
|
||||||
@@ -97,7 +101,7 @@ export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
|
|||||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='วันที่ส่งอนุมัติ' />
|
<DataTableColumnHeader column={column} title='วันที่ส่งอนุมัติ' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString()
|
cell: ({ row }) => formatDateTime(row.original.requestedAt)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import {
|
import {
|
||||||
approveApprovalMutation,
|
approveApprovalMutation,
|
||||||
cancelApprovalMutation,
|
cancelApprovalMutation,
|
||||||
@@ -52,8 +53,7 @@ export function ApprovalRequestPanel({
|
|||||||
const [remark, setRemark] = useState('');
|
const [remark, setRemark] = useState('');
|
||||||
const isPending = approval.request.status === 'pending';
|
const isPending = approval.request.status === 'pending';
|
||||||
const canHandleCurrentStep =
|
const canHandleCurrentStep =
|
||||||
!!approval.currentStep &&
|
!!approval.currentStep && (isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode);
|
||||||
(isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode);
|
|
||||||
const canCancelCurrentRequest =
|
const canCancelCurrentRequest =
|
||||||
canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId);
|
canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId);
|
||||||
|
|
||||||
@@ -63,8 +63,7 @@ export function ApprovalRequestPanel({
|
|||||||
toast.success('อนุมัติขั้นตอนนี้สำเร็จ');
|
toast.success('อนุมัติขั้นตอนนี้สำเร็จ');
|
||||||
setRemark('');
|
setRemark('');
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถอนุมัติคำขอได้')
|
||||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถอนุมัติคำขอได้')
|
|
||||||
});
|
});
|
||||||
const rejectAction = useMutation({
|
const rejectAction = useMutation({
|
||||||
...rejectApprovalMutation,
|
...rejectApprovalMutation,
|
||||||
@@ -72,8 +71,7 @@ export function ApprovalRequestPanel({
|
|||||||
toast.success('ไม่อนุมัติคำขอสำเร็จ');
|
toast.success('ไม่อนุมัติคำขอสำเร็จ');
|
||||||
setRemark('');
|
setRemark('');
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถไม่อนุมัติคำขอได้')
|
||||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถไม่อนุมัติคำขอได้')
|
|
||||||
});
|
});
|
||||||
const returnAction = useMutation({
|
const returnAction = useMutation({
|
||||||
...returnApprovalMutation,
|
...returnApprovalMutation,
|
||||||
@@ -81,14 +79,12 @@ export function ApprovalRequestPanel({
|
|||||||
toast.success('ตีกลับคำขอเพื่อแก้ไขสำเร็จ');
|
toast.success('ตีกลับคำขอเพื่อแก้ไขสำเร็จ');
|
||||||
setRemark('');
|
setRemark('');
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถตีกลับคำขอได้')
|
||||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถตีกลับคำขอได้')
|
|
||||||
});
|
});
|
||||||
const cancelAction = useMutation({
|
const cancelAction = useMutation({
|
||||||
...cancelApprovalMutation,
|
...cancelApprovalMutation,
|
||||||
onSuccess: () => toast.success('ยกเลิกคำขออนุมัติสำเร็จ'),
|
onSuccess: () => toast.success('ยกเลิกคำขออนุมัติสำเร็จ'),
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกคำขอได้')
|
||||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกคำขอได้')
|
|
||||||
});
|
});
|
||||||
const isActing =
|
const isActing =
|
||||||
approveAction.isPending ||
|
approveAction.isPending ||
|
||||||
@@ -109,12 +105,15 @@ export function ApprovalRequestPanel({
|
|||||||
<div className='text-muted-foreground text-xs'>สถานะ</div>
|
<div className='text-muted-foreground text-xs'>สถานะ</div>
|
||||||
<ApprovalStatusBadge status={approval.request.status} />
|
<ApprovalStatusBadge status={approval.request.status} />
|
||||||
</div>
|
</div>
|
||||||
<FieldItem label='ประเภทเอกสาร' value={approval.request.entityType.replaceAll('_', ' ')} />
|
<FieldItem
|
||||||
|
label='ประเภทเอกสาร'
|
||||||
|
value={approval.request.entityType.replaceAll('_', ' ')}
|
||||||
|
/>
|
||||||
<FieldItem label='รหัสเอกสาร' value={approval.entityCode ?? approval.request.entityId} />
|
<FieldItem label='รหัสเอกสาร' value={approval.entityCode ?? approval.request.entityId} />
|
||||||
<FieldItem label='ชื่อเอกสาร' value={approval.entityTitle} />
|
<FieldItem label='ชื่อเอกสาร' value={approval.entityTitle} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='วันที่ส่งอนุมัติ'
|
label='วันที่ส่งอนุมัติ'
|
||||||
value={new Date(approval.request.requestedAt).toLocaleString()}
|
value={formatDateTime(approval.request.requestedAt)}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='ผู้ส่งอนุมัติ' value={approval.request.requestedBy} />
|
<FieldItem label='ผู้ส่งอนุมัติ' value={approval.request.requestedBy} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
@@ -148,7 +147,9 @@ export function ApprovalRequestPanel({
|
|||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{approval.steps.map((step) => {
|
{approval.steps.map((step) => {
|
||||||
const isCurrent = approval.currentStep?.id === step.id && isPending;
|
const isCurrent = approval.currentStep?.id === step.id && isPending;
|
||||||
const isCompleted = approval.request.currentStep > step.stepNumber || approval.request.status === 'approved';
|
const isCompleted =
|
||||||
|
approval.request.currentStep > step.stepNumber ||
|
||||||
|
approval.request.status === 'approved';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -173,9 +174,7 @@ export function ApprovalRequestPanel({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>การดำเนินการ</CardTitle>
|
<CardTitle>การดำเนินการ</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>ผู้อนุมัติสามารถอนุมัติ ไม่อนุมัติ หรือตีกลับในขั้นที่รับผิดชอบได้</CardDescription>
|
||||||
ผู้อนุมัติสามารถอนุมัติ ไม่อนุมัติ หรือตีกลับในขั้นที่รับผิดชอบได้
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -200,9 +199,7 @@ export function ApprovalRequestPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant='destructive'
|
variant='destructive'
|
||||||
isLoading={rejectAction.isPending}
|
isLoading={rejectAction.isPending}
|
||||||
onClick={() =>
|
onClick={() => rejectAction.mutate({ id: approval.request.id, values: { remark } })}
|
||||||
rejectAction.mutate({ id: approval.request.id, values: { remark } })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icons.warning className='mr-2 h-4 w-4' />
|
<Icons.warning className='mr-2 h-4 w-4' />
|
||||||
ไม่อนุมัติ
|
ไม่อนุมัติ
|
||||||
@@ -212,9 +209,7 @@ export function ApprovalRequestPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
isLoading={returnAction.isPending}
|
isLoading={returnAction.isPending}
|
||||||
onClick={() =>
|
onClick={() => returnAction.mutate({ id: approval.request.id, values: { remark } })}
|
||||||
returnAction.mutate({ id: approval.request.id, values: { remark } })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icons.chevronLeft className='mr-2 h-4 w-4' />
|
<Icons.chevronLeft className='mr-2 h-4 w-4' />
|
||||||
ตีกลับ
|
ตีกลับ
|
||||||
@@ -234,7 +229,10 @@ export function ApprovalRequestPanel({
|
|||||||
{!canHandleCurrentStep && isPending ? (
|
{!canHandleCurrentStep && isPending ? (
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
ขั้นตอนปัจจุบันถูกมอบหมายให้{' '}
|
ขั้นตอนปัจจุบันถูกมอบหมายให้{' '}
|
||||||
<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 ? (
|
||||||
@@ -268,16 +266,10 @@ export function ApprovalRequestPanel({
|
|||||||
<Badge variant='outline' className='capitalize'>
|
<Badge variant='outline' className='capitalize'>
|
||||||
{item.action}
|
{item.action}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className='text-sm font-medium'>
|
<span className='text-sm font-medium'>{item.actorName ?? item.actedBy}</span>
|
||||||
{item.actorName ?? item.actedBy}
|
<span className='text-muted-foreground text-xs'>ขั้น {item.stepNumber}</span>
|
||||||
</span>
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
|
||||||
ขั้น {item.stepNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground text-xs'>
|
|
||||||
{new Date(item.actedAt).toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>{formatDateTime(item.actedAt)}</div>
|
||||||
{item.remark ? <div className='text-sm'>{item.remark}</div> : null}
|
{item.remark ? <div className='text-sm'>{item.remark}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import {
|
import {
|
||||||
createDocumentSequenceMutation,
|
createDocumentSequenceMutation,
|
||||||
deleteDocumentSequenceMutation,
|
deleteDocumentSequenceMutation,
|
||||||
@@ -111,25 +112,62 @@ function SequenceDialog({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
|
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
|
||||||
<DialogDescription>Preview never increments the counter. Generation remains server-side only.</DialogDescription>
|
<DialogDescription>
|
||||||
|
Preview never increments the counter. Generation remains server-side only.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||||
<Input placeholder='Document type' value={state.documentType} onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))} />
|
<Input
|
||||||
|
placeholder='Document type'
|
||||||
|
value={state.documentType}
|
||||||
|
onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))}
|
||||||
|
/>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Input placeholder='Prefix' value={state.prefix} onChange={(e) => setState((current) => ({ ...current, prefix: e.target.value }))} />
|
<Input
|
||||||
<Input placeholder='Period' value={state.period} onChange={(e) => setState((current) => ({ ...current, period: e.target.value }))} />
|
placeholder='Prefix'
|
||||||
|
value={state.prefix}
|
||||||
|
onChange={(e) => setState((current) => ({ ...current, prefix: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='Period'
|
||||||
|
value={state.period}
|
||||||
|
onChange={(e) => setState((current) => ({ ...current, period: e.target.value }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-4 md:grid-cols-3'>
|
<div className='grid gap-4 md:grid-cols-3'>
|
||||||
<Input placeholder='Branch ID (optional)' value={state.branchId} onChange={(e) => setState((current) => ({ ...current, branchId: e.target.value }))} />
|
<Input
|
||||||
<Input placeholder='Current number' value={state.currentNumber} onChange={(e) => setState((current) => ({ ...current, currentNumber: e.target.value }))} />
|
placeholder='Branch ID (optional)'
|
||||||
<Input placeholder='Padding length' value={state.paddingLength} onChange={(e) => setState((current) => ({ ...current, paddingLength: e.target.value }))} />
|
value={state.branchId}
|
||||||
|
onChange={(e) => setState((current) => ({ ...current, branchId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='Current number'
|
||||||
|
value={state.currentNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setState((current) => ({ ...current, currentNumber: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='Padding length'
|
||||||
|
value={state.paddingLength}
|
||||||
|
onChange={(e) =>
|
||||||
|
setState((current) => ({ ...current, paddingLength: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>Active</div>
|
<div className='font-medium'>Active</div>
|
||||||
<div className='text-muted-foreground text-sm'>Inactive sequences can stay preserved for legacy numbering.</div>
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Inactive sequences can stay preserved for legacy numbering.
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={state.isActive}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setState((current) => ({ ...current, isActive: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
@@ -147,7 +185,10 @@ function SequenceDialog({
|
|||||||
|
|
||||||
export function DocumentSequenceSettings() {
|
export function DocumentSequenceSettings() {
|
||||||
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
|
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
|
||||||
const [dialogState, setDialogState] = React.useState<{ open: boolean; sequence?: DocumentSequenceListItem }>({
|
const [dialogState, setDialogState] = React.useState<{
|
||||||
|
open: boolean;
|
||||||
|
sequence?: DocumentSequenceListItem;
|
||||||
|
}>({
|
||||||
open: false
|
open: false
|
||||||
});
|
});
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
@@ -166,7 +207,10 @@ export function DocumentSequenceSettings() {
|
|||||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>Document Sequence Admin</div>
|
<div className='font-medium'>Document Sequence Admin</div>
|
||||||
<div className='text-muted-foreground text-sm'>Maintain organization-scoped numbering strategy without rewriting historic document codes.</div>
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Maintain organization-scoped numbering strategy without rewriting historic document
|
||||||
|
codes.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogState({ open: true })}>
|
<Button onClick={() => setDialogState({ open: true })}>
|
||||||
<Icons.add className='mr-2 h-4 w-4' />
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
@@ -185,7 +229,8 @@ export function DocumentSequenceSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<div className='text-xl font-semibold'>{sequence.documentType}</div>
|
<div className='text-xl font-semibold'>{sequence.documentType}</div>
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
{sequence.prefix}{sequence.period} • branch {sequence.branchId || 'default'}
|
{sequence.prefix}
|
||||||
|
{sequence.period} • branch {sequence.branchId || 'default'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
@@ -206,7 +251,9 @@ export function DocumentSequenceSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='rounded-lg bg-muted/40 p-4'>
|
<div className='rounded-lg bg-muted/40 p-4'>
|
||||||
<div className='text-muted-foreground text-xs'>Updated At</div>
|
<div className='text-muted-foreground text-xs'>Updated At</div>
|
||||||
<div className='mt-1 text-sm font-medium'>{new Date(sequence.updatedAt).toLocaleString()}</div>
|
<div className='mt-1 text-sm font-medium'>
|
||||||
|
{formatDateTime(sequence.updatedAt)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-lg bg-muted/40 p-4'>
|
<div className='rounded-lg bg-muted/40 p-4'>
|
||||||
<div className='text-muted-foreground text-xs'>Preview</div>
|
<div className='text-muted-foreground text-xs'>Preview</div>
|
||||||
@@ -229,7 +276,11 @@ export function DocumentSequenceSettings() {
|
|||||||
>
|
>
|
||||||
Reset Counter
|
Reset Counter
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='outline' onClick={() => deleteMutation.mutate(sequence.id)} isLoading={deleteMutation.isPending}>
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => deleteMutation.mutate(sequence.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
95
src/lib/date-format.ts
Normal file
95
src/lib/date-format.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export type DateInput = Date | string | number | null | undefined;
|
||||||
|
|
||||||
|
export const BANGKOK_TIME_ZONE = 'Asia/Bangkok';
|
||||||
|
export const GREGORIAN_CALENDAR = 'gregory';
|
||||||
|
|
||||||
|
const BANGKOK_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface BangkokDateParts {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceDate(value: DateInput): Date | null {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value: number): string {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBangkokDateParts(value: DateInput): BangkokDateParts | null {
|
||||||
|
const date = coerceDate(value);
|
||||||
|
if (!date) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bangkok does not observe DST, so a fixed UTC+7 offset keeps formatting
|
||||||
|
// deterministic across server and client runtimes.
|
||||||
|
const bangkokDate = new Date(date.getTime() + BANGKOK_OFFSET_MS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: bangkokDate.getUTCFullYear(),
|
||||||
|
month: bangkokDate.getUTCMonth() + 1,
|
||||||
|
day: bangkokDate.getUTCDate(),
|
||||||
|
hour: bangkokDate.getUTCHours(),
|
||||||
|
minute: bangkokDate.getUTCMinutes(),
|
||||||
|
second: bangkokDate.getUTCSeconds()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: DateInput): string {
|
||||||
|
const parts = getBangkokDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: DateInput): string {
|
||||||
|
const parts = getBangkokDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)} ${pad2(parts.hour)}:${pad2(parts.minute)}:${pad2(parts.second)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(value: DateInput): string {
|
||||||
|
const parts = getBangkokDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${pad2(parts.hour)}:${pad2(parts.minute)}:${pad2(parts.second)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonth(value: DateInput): string {
|
||||||
|
const parts = getBangkokDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.year}-${pad2(parts.month)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatYear(value: DateInput): string {
|
||||||
|
const parts = getBangkokDateParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(parts.year);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateIso = formatDate;
|
||||||
@@ -1,58 +1,10 @@
|
|||||||
export function formatDate(
|
export {
|
||||||
date: Date | string | number | undefined,
|
BANGKOK_TIME_ZONE,
|
||||||
opts: Intl.DateTimeFormatOptions = {}
|
GREGORIAN_CALENDAR,
|
||||||
) {
|
formatDate,
|
||||||
if (!date) return '';
|
formatDateIso,
|
||||||
|
formatDateTime,
|
||||||
try {
|
formatMonth,
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
formatTime,
|
||||||
month: opts.month ?? 'long',
|
formatYear
|
||||||
day: opts.day ?? 'numeric',
|
} from '@/lib/date-format';
|
||||||
year: opts.year ?? 'numeric',
|
|
||||||
...opts
|
|
||||||
}).format(new Date(date));
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(
|
|
||||||
date: Date | string | number | undefined,
|
|
||||||
opts: Intl.DateTimeFormatOptions = {}
|
|
||||||
) {
|
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('th-TH-u-ca-gregory-nu-latn', {
|
|
||||||
day: opts.day ?? '2-digit',
|
|
||||||
month: opts.month ?? '2-digit',
|
|
||||||
year: opts.year ?? 'numeric',
|
|
||||||
hour: opts.hour ?? '2-digit',
|
|
||||||
minute: opts.minute ?? '2-digit',
|
|
||||||
second: opts.second ?? '2-digit',
|
|
||||||
hour12: opts.hour12 ?? false,
|
|
||||||
timeZone: opts.timeZone ?? 'Asia/Bangkok',
|
|
||||||
...opts
|
|
||||||
}).format(new Date(date));
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date to YYYY-MM-DD format (ISO date).
|
|
||||||
* This is used to prevent hydration mismatches between server and client.
|
|
||||||
*/
|
|
||||||
export function formatDateIso(date: Date | string | number | undefined): string {
|
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const d = new Date(date);
|
|
||||||
const year = d.getFullYear();
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
19
src/lib/number-format.ts
Normal file
19
src/lib/number-format.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type NumberInput = number | string | null | undefined;
|
||||||
|
|
||||||
|
export function formatNumber(
|
||||||
|
value: NumberInput,
|
||||||
|
options?: Intl.NumberFormatOptions
|
||||||
|
): string {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed =
|
||||||
|
typeof value === 'number' ? value : Number(String(value).replaceAll(',', ''));
|
||||||
|
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('en-US', options).format(parsed);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user