task-fixdatetime
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user