task-fixdatetime

This commit is contained in:
phaichayon
2026-06-25 21:09:27 +07:00
parent 3bf3405c48
commit 14acee1127
36 changed files with 4788 additions and 1011 deletions

View File

@@ -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
View 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
View 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
15 Sites
Each site has
26 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.

View File

@@ -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>

View File

@@ -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}
> >

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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',

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { 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' />

View File

@@ -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>
)) ))
)} )}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>
); );
} }

View File

@@ -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'>

View File

@@ -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);
} }

View File

@@ -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>
))} ))}

View File

@@ -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}

View File

@@ -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({
</> </>
); );
} }

View File

@@ -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

View File

@@ -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>
)) ))

View File

@@ -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';

View File

@@ -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} />
); );
} }

View File

@@ -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;
} }

View File

@@ -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',

View File

@@ -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)
} }
]; ];
} }

View File

@@ -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>

View File

@@ -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
View 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;

View File

@@ -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
View 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);
}