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

@@ -5,6 +5,7 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Icons } from '@/components/icons';
import { formatDate } from '@/lib/date-format';
import type { CustomerListItem, CustomerReferenceData } from '../api/types';
import { CustomerCellAction } from './customer-cell-action';
import { CustomerStatusBadge } from './customer-status-badge';
@@ -140,7 +141,9 @@ export function getCustomerColumns({
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
<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: {
label: 'Owner',
variant: 'multiSelect' as const,
@@ -157,7 +160,13 @@ export function getCustomerColumns({
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
<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',

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { formatDateTime } from '@/lib/format';
import { formatNumber } from '@/lib/number-format';
import { customerByIdOptions } from '../api/queries';
import type { CustomerReferenceData } from '../api/types';
import { CustomerFormSheet } from './customer-form-sheet';
@@ -146,10 +147,7 @@ export function CustomerDetail({
label='Branch'
value={customer.branchId ? branchMap.get(customer.branchId) : null}
/>
<FieldItem
label='ที่มาของลีด'
value={leadMap.get(customer.leadChannel ?? '')}
/>
<FieldItem label='ที่มาของลีด' value={leadMap.get(customer.leadChannel ?? '')} />
<FieldItem
label='กลุ่มลูกค้า'
value={groupMap.get(customer.customerGroup ?? '')}
@@ -236,7 +234,9 @@ export function CustomerDetail({
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{!relatedOpportunities.length && !relatedQuotations.length ? (
@@ -272,7 +272,7 @@ export function CustomerDetail({
<div className='space-y-1'>
<div className='font-medium'>{quotation.code}</div>
<div className='text-muted-foreground text-xs'>
{quotation.totalAmount.toLocaleString()}
{formatNumber(quotation.totalAmount)}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />

View File

@@ -1,7 +1,15 @@
'use client';
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';
function Stat({ label, value, suffix }: { label: string; value: number; suffix?: string }) {
@@ -64,7 +72,7 @@ export function DashboardApprovalMetrics({
</TableCell>
<TableCell>{row.workflowName}</TableCell>
<TableCell>{row.currentStepRoleName ?? '-'}</TableCell>
<TableCell>{new Date(row.requestedAt).toLocaleString()}</TableCell>
<TableCell>{formatDateTime(row.requestedAt)}</TableCell>
</TableRow>
))
)}

View File

@@ -1,7 +1,15 @@
'use client';
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';
function Stat({ label, value }: { label: string; value: number }) {
@@ -53,7 +61,7 @@ export function DashboardFollowups({
) : (
followups.upcoming.map((row) => (
<TableRow key={`${row.sourceType}-${row.id}`}>
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
<TableCell>{formatDate(row.followupDate)}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.opportunityName}</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 { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { formatDate } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import type { LeadReferenceData, LeadSummary } from '../api/types';
import { LeadCellAction } from './lead-cell-action';
@@ -28,7 +30,10 @@ export function getLeadColumns({
),
cell: ({ row }) => (
<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}
</Link>
<span className='text-muted-foreground text-xs'>{row.original.customerName ?? '-'}</span>
@@ -48,7 +53,9 @@ export function getLeadColumns({
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<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: {
label: 'Status',
variant: 'select' as const,
@@ -136,7 +143,9 @@ export function getLeadColumns({
),
cell: ({ row }) => (
<span>
{row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'}
{row.original.estimatedValue !== null
? formatNumber(row.original.estimatedValue)
: '-'}
</span>
)
},
@@ -146,7 +155,7 @@ export function getLeadColumns({
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
<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',

View File

@@ -7,6 +7,8 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 type { LeadReferenceData } from '../api/types';
import { LeadAssignmentDialog } from './lead-assignment-dialog';
@@ -42,7 +44,12 @@ export function LeadDetail({
return (
<div className='space-y-6'>
<LeadForm open={editOpen} onOpenChange={setEditOpen} referenceData={referenceData} lead={lead} />
<LeadForm
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
lead={lead}
/>
<LeadAssignmentDialog
lead={lead}
referenceData={referenceData}
@@ -93,7 +100,7 @@ export function LeadDetail({
<FieldItem label='Assigned Sales Owner' value={lead.assignedSalesOwnerName} />
<FieldItem
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='Description' value={lead.description} />
@@ -118,7 +125,9 @@ export function LeadDetail({
</CardHeader>
<CardContent className='space-y-3'>
{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) => (
<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='text-muted-foreground mt-1 text-xs'>
{activity.actorName ?? activity.userId} -{' '}
{new Date(activity.createdAt).toLocaleString()}
{formatDateTime(activity.createdAt)}
</div>
</div>
))
@@ -174,10 +183,13 @@ export function LeadDetail({
</CardHeader>
<CardContent className='space-y-3'>
<FieldItem label='Current Workspace' value='Lead' />
<FieldItem label='Related Opportunities' value={String(lead.relatedOpportunityCount)} />
<FieldItem
label='Related Opportunities'
value={String(lead.relatedOpportunityCount)}
/>
<FieldItem
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='Deleted' value={canDelete ? 'Allowed' : 'Not allowed'} />
@@ -188,4 +200,3 @@ export function LeadDetail({
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { formatDate } from '@/lib/date-format';
import type { LeadDetailResponse } from '../api/types';
import { createLeadFollowupMutation } from '../api/mutations';
@@ -101,10 +102,10 @@ export function LeadFollowupPanel({
detail.followups.map((followup) => (
<div key={followup.id} className='border-border rounded-md border p-3'>
<div className='flex items-center justify-between gap-3'>
<div className='font-medium'>{followup.followupStatusLabel ?? followup.followupStatus}</div>
<div className='text-muted-foreground text-xs'>
{new Date(followup.followupDate).toLocaleDateString()}
<div className='font-medium'>
{followup.followupStatusLabel ?? followup.followupStatus}
</div>
<div className='text-muted-foreground text-xs'>{formatDate(followup.followupDate)}</div>
</div>
{followup.note ? <div className='mt-2 text-sm'>{followup.note}</div> : null}
<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 { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { formatDate } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
import { OpportunityCellAction } from './opportunity-cell-action';
import { OpportunityStatusBadge } from './opportunity-status-badge';
@@ -36,7 +38,10 @@ function getLeadColumns({
),
cell: ({ row }) => (
<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}
</Link>
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
@@ -156,7 +161,9 @@ function getOpportunityWorkspaceColumns({
),
cell: ({ row }) =>
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>
)
@@ -192,7 +199,9 @@ function getOpportunityWorkspaceColumns({
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<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: {
label: 'Product Type',
variant: 'multiSelect' as const,
@@ -239,7 +248,9 @@ function getOpportunityWorkspaceColumns({
),
cell: ({ row }) => (
<span>
{row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'}
{row.original.estimatedValue !== null
? formatNumber(row.original.estimatedValue)
: '-'}
</span>
)
},
@@ -249,7 +260,9 @@ function getOpportunityWorkspaceColumns({
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<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',
@@ -260,7 +273,7 @@ function getOpportunityWorkspaceColumns({
cell: ({ row }) => (
<span>
{row.original.expectedCloseDate
? new Date(row.original.expectedCloseDate).toLocaleDateString()
? formatDate(row.original.expectedCloseDate)
: '-'}
</span>
)
@@ -273,7 +286,9 @@ function getOpportunityWorkspaceColumns({
),
cell: ({ row }) => (
<span>
{row.original.nextFollowupDate ? new Date(row.original.nextFollowupDate).toLocaleDateString() : '-'}
{row.original.nextFollowupDate
? formatDate(row.original.nextFollowupDate)
: '-'}
</span>
)
},
@@ -283,7 +298,7 @@ function getOpportunityWorkspaceColumns({
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<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',
@@ -311,6 +326,7 @@ export function getOpportunityColumns({
canReassign
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] {
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries';
@@ -76,7 +81,9 @@ export function OpportunityDetail({
quotationReferenceData = null
}: OpportunityDetailProps) {
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
const { data: projectPartiesData } = useSuspenseQuery(opportunityProjectPartiesOptions(opportunityId));
const { data: projectPartiesData } = useSuspenseQuery(
opportunityProjectPartiesOptions(opportunityId)
);
const [editOpen, setEditOpen] = useState(false);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [quotationOpen, setQuotationOpen] = useState(false);
@@ -142,7 +149,10 @@ export function OpportunityDetail({
<div>
<div className='flex items-center gap-3'>
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2>
<OpportunityStatusBadge code={status?.code} label={status?.label ?? opportunity.status} />
<OpportunityStatusBadge
code={status?.code}
label={status?.label ?? opportunity.status}
/>
</div>
<p className='text-muted-foreground mt-2 text-sm'>
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
@@ -193,13 +203,17 @@ export function OpportunityDetail({
label='Expected Close Date'
value={
opportunity.expectedCloseDate
? new Date(opportunity.expectedCloseDate).toLocaleDateString()
? formatDate(opportunity.expectedCloseDate)
: null
}
/>
<FieldItem
label='Estimated Value'
value={opportunity.estimatedValue !== null ? opportunity.estimatedValue.toLocaleString() : null}
value={
opportunity.estimatedValue !== null
? formatNumber(opportunity.estimatedValue)
: null
}
/>
</CardContent>
</Card>
@@ -216,7 +230,10 @@ export function OpportunityDetail({
<FieldItem label='Contact Name' value={contact?.name} />
<FieldItem label='Contact Email' value={contact?.email} />
<FieldItem label='Contact Mobile' value={contact?.mobile} />
<FieldItem label='Branch' value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null} />
<FieldItem
label='Branch'
value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null}
/>
</CardContent>
</Card>
@@ -240,8 +257,14 @@ export function OpportunityDetail({
<CardTitle>Sales Qualification</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<FieldItem label='Product Type' value={productTypeMap.get(opportunity.productType) ?? opportunity.productType} />
<FieldItem label='Priority' value={priorityMap.get(opportunity.priority) ?? opportunity.priority} />
<FieldItem
label='Product Type'
value={productTypeMap.get(opportunity.productType) ?? opportunity.productType}
/>
<FieldItem
label='Priority'
value={priorityMap.get(opportunity.priority) ?? opportunity.priority}
/>
<FieldItem
label='Chance %'
value={opportunity.chancePercent !== null ? `${opportunity.chancePercent}%` : null}
@@ -249,7 +272,9 @@ export function OpportunityDetail({
<FieldItem label='Competitor' value={opportunity.competitor} />
<FieldItem
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} />
</CardContent>
@@ -280,7 +305,9 @@ export function OpportunityDetail({
</CardHeader>
<CardContent className='space-y-3'>
{!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 ? (
<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='text-muted-foreground mt-1 text-xs'>
{activity.actorName ?? activity.userId} -{' '}
{new Date(activity.createdAt).toLocaleString()}
{formatDateTime(activity.createdAt)}
</div>
</div>
))
@@ -346,23 +373,27 @@ export function OpportunityDetail({
</CardHeader>
<CardContent className='space-y-3'>
<FieldItem label='Related Quotations' value={String(relatedQuotations.length)} />
<FieldItem label='Project Parties' value={String(projectPartiesData.items.length)} />
<FieldItem
label='Project Parties'
value={String(projectPartiesData.items.length)}
label='Record Type'
value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'}
/>
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
{relatedQuotations.length ? (
<div className='space-y-2'>
{relatedQuotations.slice(0, 3).map((quotation) => (
<div key={quotation.id} className='rounded-lg border p-3 text-sm'>
<div className='flex items-center justify-between gap-3'>
<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}
</Link>
<Badge variant='outline'>Rev {quotation.revision}</Badge>
</div>
<div className='text-muted-foreground mt-1'>
{quotation.status} · {quotation.totalAmount.toLocaleString()} {quotation.currency}
{quotation.status} · {formatNumber(quotation.totalAmount)}{' '}
{quotation.currency}
</div>
</div>
))}

View File

@@ -8,6 +8,7 @@ import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatDate } from '@/lib/date-format';
import { deleteOpportunityFollowupMutation } from '../api/mutations';
import { opportunityFollowupsOptions } from '../api/queries';
import type { OpportunityReferenceData } from '../api/types';
@@ -42,8 +43,7 @@ export function OpportunityFollowupsTab({
setDeleteOpen(false);
setSelectedId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้')
onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้')
});
return (
@@ -82,16 +82,18 @@ export function OpportunityFollowupsTab({
{followupTypeMap.get(item.followupType) ?? 'Unknown type'}
</Badge>
<span className='text-sm font-medium'>
{new Date(item.followupDate).toLocaleDateString()}
{formatDate(item.followupDate)}
</span>
</div>
{item.outcome ? <p className='text-sm'>{item.outcome}</p> : null}
{item.nextAction ? (
<p className='text-muted-foreground text-sm'>: {item.nextAction}</p>
<p className='text-muted-foreground text-sm'>
: {item.nextAction}
</p>
) : null}
{item.nextFollowupDate ? (
<p className='text-muted-foreground text-sm'>
: {new Date(item.nextFollowupDate).toLocaleDateString()}
: {formatDate(item.nextFollowupDate)}
</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';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { formatDate, formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import {
Select,
SelectContent,
@@ -117,7 +119,8 @@ export function OpportunityOutcomeCard({
setReopenOpen(false);
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({
mutationFn: async () => {
@@ -171,34 +174,55 @@ export function OpportunityOutcomeCard({
<Card>
<CardHeader>
<CardTitle>Outcome</CardTitle>
<CardDescription>Official won/lost lifecycle and purchase order tracking.</CardDescription>
<CardDescription>
Official won/lost lifecycle and purchase order tracking.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<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)}
</Badge>
<div className='flex flex-wrap gap-2'>
{opportunity.pipelineStage === 'opportunity' && canMarkWon ? (
<Button size='sm' onClick={() => {
setPoNumber(opportunity.poNumber ?? '');
setPoDate(opportunity.poDate ? opportunity.poDate.slice(0, 10) : '');
setPoAmount(opportunity.poAmount !== null && opportunity.poAmount !== undefined ? String(opportunity.poAmount) : '');
setPoCurrency(opportunity.poCurrency ?? 'THB');
setWonRemark('');
setWonOpen(true);
}}>
<Button
size='sm'
onClick={() => {
setPoNumber(opportunity.poNumber ?? '');
setPoDate(opportunity.poDate ? opportunity.poDate.slice(0, 10) : '');
setPoAmount(
opportunity.poAmount !== null && opportunity.poAmount !== undefined
? String(opportunity.poAmount)
: ''
);
setPoCurrency(opportunity.poCurrency ?? 'THB');
setWonRemark('');
setWonOpen(true);
}}
>
<Icons.check className='mr-2 h-4 w-4' />
Mark Won
</Button>
) : null}
{opportunity.pipelineStage === 'opportunity' && canMarkLost ? (
<Button variant='destructive' size='sm' onClick={() => {
setLostReason(opportunity.lostReason ?? '');
setLostCompetitor(opportunity.lostCompetitor ?? '');
setLostRemark(opportunity.lostRemark ?? '');
setLostOpen(true);
}}>
<Button
variant='destructive'
size='sm'
onClick={() => {
setLostReason(opportunity.lostReason ?? '');
setLostCompetitor(opportunity.lostCompetitor ?? '');
setLostRemark(opportunity.lostRemark ?? '');
setLostOpen(true);
}}
>
<Icons.xCircle className='mr-2 h-4 w-4' />
Mark Lost
</Button>
@@ -215,18 +239,18 @@ export function OpportunityOutcomeCard({
<FieldItem label='Closed By' value={opportunity.closedByName} />
<FieldItem
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 Date'
value={opportunity.poDate ? new Date(opportunity.poDate).toLocaleDateString() : null}
value={opportunity.poDate ? formatDate(opportunity.poDate) : null}
/>
<FieldItem
label='PO Amount'
value={
canViewCommercialData && opportunity.poAmount !== null
? `${opportunity.poAmount.toLocaleString()} ${opportunity.poCurrency ?? ''}`.trim()
? `${formatNumber(opportunity.poAmount)} ${opportunity.poCurrency ?? ''}`.trim()
: canViewCommercialData
? null
: 'Restricted'
@@ -290,17 +314,23 @@ export function OpportunityOutcomeCard({
<div className='font-medium'>{attachment.originalFileName}</div>
<div className='text-muted-foreground text-xs'>
{attachment.fileType ?? 'Unknown type'}
{attachment.fileSize ? `${attachment.fileSize.toLocaleString()} bytes` : ''}
{attachment.fileSize
? `${formatNumber(attachment.fileSize)} bytes`
: ''}
</div>
</div>
<div className='flex gap-2'>
<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
</Link>
</Button>
<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
</Link>
</Button>
@@ -318,12 +348,18 @@ export function OpportunityOutcomeCard({
<DialogContent>
<DialogHeader>
<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>
<div className='grid gap-4 py-2'>
<div className='space-y-2'>
<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 className='space-y-2'>
<Label htmlFor='po-date'>PO Date</Label>
@@ -391,7 +427,9 @@ export function OpportunityOutcomeCard({
<DialogContent>
<DialogHeader>
<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>
<div className='grid gap-4 py-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 { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Icons } from '@/components/icons';
import { formatNumber } from '@/lib/number-format';
import type { QuotationListItem, QuotationReferenceData } from '../api/types';
import { QuotationCellAction } from './quotation-cell-action';
import { QuotationStatusBadge } from './quotation-status-badge';
@@ -31,7 +32,10 @@ export function getQuotationColumns({
),
cell: ({ row }) => (
<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}
</Link>
<span className='text-muted-foreground text-xs'>
@@ -90,7 +94,11 @@ export function getQuotationColumns({
<DataTableColumnHeader column={column} title='สาขา' />
),
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: {
label: 'สาขา',
@@ -123,7 +131,10 @@ export function getQuotationColumns({
meta: {
label: 'โอกาสขาย',
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
},
@@ -150,7 +161,7 @@ export function getQuotationColumns({
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='มูลค่ารวม' />
),
cell: ({ row }) => row.original.totalAmount.toLocaleString()
cell: ({ row }) => formatNumber(row.original.totalAmount)
},
{
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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';
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='วันที่ออกใบเสนอราคา'
value={new Date(documentData.quotation.quotationDate).toLocaleDateString()}
value={formatDate(documentData.quotation.quotationDate)}
/>
<Field
label='ใช้ได้ถึง'
value={
documentData.quotation.validUntil
? new Date(documentData.quotation.validUntil).toLocaleDateString()
? formatDate(documentData.quotation.validUntil)
: null
}
/>
@@ -131,8 +133,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
</div>
<div>{item.quantity}</div>
<div>{item.unitLabel || '-'}</div>
<div>{item.unitPrice.toLocaleString()}</div>
<div>{item.totalPrice.toLocaleString()}</div>
<div>{formatNumber(item.unitPrice)}</div>
<div>{formatNumber(item.totalPrice)}</div>
</div>
))}
</CardContent>
@@ -183,16 +185,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<CardContent className='space-y-3 text-sm'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'></span>
<span>{documentData.quotation.subtotal.toLocaleString()}</span>
<span>{formatNumber(documentData.quotation.subtotal)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'></span>
<span>{documentData.quotation.taxAmount.toLocaleString()}</span>
<span>{formatNumber(documentData.quotation.taxAmount)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'></span>
<span className='font-semibold'>
{documentData.quotation.totalAmount.toLocaleString()}
{formatNumber(documentData.quotation.totalAmount)}
</span>
</div>
</CardContent>
@@ -221,7 +223,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
</div>
<div className='text-muted-foreground'>
{item.actorName || '-'}{' '}
{item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
{item.actedAt ? `เมื่อ ${formatDateTime(item.actedAt)}` : ''}
</div>
</div>
))

View File

@@ -30,20 +30,7 @@ export function formatPdfDate(
return '-';
}
if (language === 'th') {
return date.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric',
calendar: 'buddhist'
});
}
return date.toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
return formatDate(date);
}
export function formatPdfCurrency(
@@ -191,3 +178,4 @@ export function normalizePdfmeTable(
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 { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import {
Table,
TableBody,
@@ -12,10 +14,7 @@ import {
TableHeader,
TableRow
} from '@/components/ui/table';
import type {
CrmOpportunityAgingReportResponse,
CrmLeadAgingReportResponse
} from '../api/types';
import type { CrmOpportunityAgingReportResponse, CrmLeadAgingReportResponse } from '../api/types';
import {
crmOpportunityAgingReportQueryOptions,
crmLeadAgingReportQueryOptions,
@@ -23,10 +22,6 @@ import {
} from '../api/queries';
import { ReportFilterBar } from './report-filter-bar';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US').format(value);
}
function AgingReportLayout({
variant,
canExport,
@@ -67,8 +62,16 @@ function AgingReportLayout({
reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_aging'}
pageLinks={[
{ 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>
<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>
</CardHeader>
<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.customerName}</TableCell>
<TableCell>{row.assignedUserName ?? '-'}</TableCell>
<TableCell>{new Date(row.createdDate).toLocaleDateString('en-CA')}</TableCell>
<TableCell>
{formatDate(row.createdDate)}
</TableCell>
<TableCell>{formatNumber(row.agingDays)}</TableCell>
</>
) : (
@@ -152,7 +159,7 @@ function AgingReportLayout({
<TableCell>{row.salesmanName ?? '-'}</TableCell>
<TableCell>
{row.lastFollowupDate
? new Date(row.lastFollowupDate).toLocaleDateString('en-CA')
? formatDate(row.lastFollowupDate)
: '-'}
</TableCell>
<TableCell>{formatNumber(row.agingDays)}</TableCell>
@@ -240,4 +247,3 @@ export function AgingReportView({
<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) {
if (!value) {
@@ -20,21 +22,14 @@ function coerceDate(value: string | Date | null | undefined) {
export function formatCrmDate(value: string | Date | null | undefined) {
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) {
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);
export function formatCrmNumber(
value: number | string | null | undefined,
options?: Intl.NumberFormatOptions
) {
return formatNumber(value, options);
}
export function sanitizeDecimalInput(value: string) {
@@ -42,6 +37,5 @@ export function sanitizeDecimalInput(value: string) {
const [head = '', ...tail] = normalized.split('.');
const integerPart = head.replace(/(?!^)-/g, '');
const decimalPart = tail.join('');
return decimalPart.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
}