task-b
task-b.1 complere
This commit is contained in:
345
src/features/crm-demo/components/quotation-detail.tsx
Normal file
345
src/features/crm-demo/components/quotation-detail.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { quotationByIdOptions } from '../api/queries';
|
||||
import {
|
||||
approveQuotationMutation,
|
||||
createQuotationRevisionMutation,
|
||||
markQuotationStatusMutation,
|
||||
rejectQuotationMutation,
|
||||
submitApprovalMutation
|
||||
} from '../api/mutations';
|
||||
import {
|
||||
AuditTimeline,
|
||||
CustomerRoleCards,
|
||||
InfoGrid,
|
||||
QuotationPreviewPanel,
|
||||
QuotationStatusBadge,
|
||||
SectionCard,
|
||||
TimelineList
|
||||
} from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
function ApprovalDialog({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onConfirm,
|
||||
isPending
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onConfirm: (comment: string) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline'>{actionLabel}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder='ใส่หมายเหตุ (optional)'
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
||||
ยืนยัน
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(id));
|
||||
const baseSubmitApproval = submitApprovalMutation;
|
||||
const baseApproveMutation = approveQuotationMutation;
|
||||
const baseRejectMutation = rejectQuotationMutation;
|
||||
const baseReviseMutation = createQuotationRevisionMutation;
|
||||
const baseMarkStatusMutation = markQuotationStatusMutation;
|
||||
|
||||
const submitApproval = useMutation({
|
||||
...baseSubmitApproval,
|
||||
onSuccess: async (...args) => {
|
||||
await baseSubmitApproval.onSuccess?.(...args);
|
||||
toast.success('ส่งเข้าสายอนุมัติแล้ว');
|
||||
}
|
||||
});
|
||||
const approveMutation = useMutation({
|
||||
...baseApproveMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseApproveMutation.onSuccess?.(...args);
|
||||
toast.success('อนุมัติ mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const rejectMutation = useMutation({
|
||||
...baseRejectMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRejectMutation.onSuccess?.(...args);
|
||||
toast.success('Reject mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const reviseMutation = useMutation({
|
||||
...baseReviseMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseReviseMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง revision mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const markStatusMutation = useMutation({
|
||||
...baseMarkStatusMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseMarkStatusMutation.onSuccess?.(...args);
|
||||
toast.success('อัปเดตสถานะ mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const ownerCustomer = data.customers.find((customer) =>
|
||||
data.quotation.customerRoles.some(
|
||||
(role) => role.role === 'owner' && role.customerId === customer.id
|
||||
)
|
||||
);
|
||||
const ownerContact = data.contacts.find((contact) =>
|
||||
data.quotation.customerRoles.some(
|
||||
(role) => role.role === 'owner' && role.contactId === contact.id
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<SectionCard
|
||||
title={data.quotation.project}
|
||||
description={`${data.quotation.code} / ${data.branch.name}`}
|
||||
action={
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={submitApproval.isPending}
|
||||
onClick={() => submitApproval.mutate(data.quotation.id)}
|
||||
>
|
||||
Submit approval
|
||||
</Button>
|
||||
<ApprovalDialog
|
||||
title='Approve quotation'
|
||||
description='Mock sequential approval for demo.'
|
||||
actionLabel='Approve'
|
||||
isPending={approveMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
approveMutation.mutate({ quotationId: data.quotation.id, comment })
|
||||
}
|
||||
/>
|
||||
<ApprovalDialog
|
||||
title='Reject quotation'
|
||||
description='Mock reject flow for demo.'
|
||||
actionLabel='Reject'
|
||||
isPending={rejectMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
rejectMutation.mutate({ quotationId: data.quotation.id, comment })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={reviseMutation.isPending}
|
||||
onClick={() => reviseMutation.mutate(data.quotation.id)}
|
||||
>
|
||||
Create revision
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'sent'
|
||||
})
|
||||
}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'accepted'
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark accepted
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'lost'
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark lost
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<QuotationStatusBadge status={data.quotation.status} />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Revision R{String(data.quotation.revision).padStart(2, '0')}
|
||||
</span>
|
||||
<span className='text-sm font-medium'>{formatCurrency(data.quotation.totalAmount)}</span>
|
||||
</div>
|
||||
<div className='mt-4'>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Quotation Date', value: data.quotation.quotationDate },
|
||||
{ label: 'Valid Until', value: data.quotation.validUntil },
|
||||
{ label: 'Type', value: data.quotation.quotationType },
|
||||
{ label: 'Chance', value: `${data.quotation.chancePercent}%` },
|
||||
{ label: 'Salesman', value: data.salesman.name },
|
||||
{ label: 'Sales Admin', value: data.saleAdmin.name },
|
||||
{ label: 'Branch', value: data.branch.name },
|
||||
{ label: 'Site', value: data.quotation.siteLocation }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Customer Roles'>
|
||||
<CustomerRoleCards
|
||||
roles={data.quotation.customerRoles}
|
||||
customers={data.customers}
|
||||
contacts={data.contacts}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Item Table' className='xl:col-span-7'>
|
||||
<div className='space-y-3'>
|
||||
{data.quotation.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='grid gap-2 rounded-lg border p-3 md:grid-cols-[1fr_auto_auto]'
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{item.description}</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.topic}</p>
|
||||
</div>
|
||||
<p className='text-sm'>
|
||||
{item.quantity} {item.unit}
|
||||
</p>
|
||||
<p className='text-sm font-semibold'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title='Total Summary' className='xl:col-span-5'>
|
||||
<InfoGrid
|
||||
cols={2}
|
||||
items={[
|
||||
{ label: 'Sub Total', value: formatCurrency(data.quotation.totalAmount) },
|
||||
{ label: 'VAT 7%', value: formatCurrency(data.quotation.totalAmount * 0.07) },
|
||||
{ label: 'Grand Total', value: formatCurrency(data.quotation.totalAmount * 1.07) },
|
||||
{ label: 'Hot Project', value: data.quotation.isHotProject ? 'Yes' : 'No' }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Topics' className='xl:col-span-5'>
|
||||
<div className='space-y-4'>
|
||||
{data.quotation.topics.map((topic) => (
|
||||
<div key={topic.id}>
|
||||
<p className='font-medium capitalize'>{topic.label}</p>
|
||||
<ul className='text-muted-foreground mt-2 space-y-1 text-sm'>
|
||||
{topic.items.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Attachments' className='xl:col-span-3'>
|
||||
<div className='space-y-3'>
|
||||
{data.quotation.attachments.map((attachment) => (
|
||||
<div key={attachment.id} className='rounded-lg border p-3 text-sm'>
|
||||
<p className='font-medium'>{attachment.fileName}</p>
|
||||
<p className='text-muted-foreground'>{attachment.fileType.toUpperCase()}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Follow-ups' className='xl:col-span-4'>
|
||||
<TimelineList
|
||||
items={data.quotation.followUps.map((followUp) => ({
|
||||
id: followUp.id,
|
||||
title: followUp.title,
|
||||
detail: `Owner: ${followUp.ownerName}`,
|
||||
timestamp: followUp.dueDate,
|
||||
tone: followUp.status === 'done' ? 'success' : 'default'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Approval Timeline' className='xl:col-span-5'>
|
||||
<TimelineList
|
||||
items={data.quotation.approvalSteps.map((step) => ({
|
||||
id: step.id,
|
||||
title: `Level ${step.level} - ${step.approverName}`,
|
||||
detail: `${step.approverPosition}${step.comment ? ` / ${step.comment}` : ''}`,
|
||||
timestamp: step.actedAt ?? data.quotation.quotationDate,
|
||||
tone:
|
||||
step.status === 'approved'
|
||||
? 'success'
|
||||
: step.status === 'rejected'
|
||||
? 'danger'
|
||||
: 'default'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Audit Timeline' className='xl:col-span-7'>
|
||||
<AuditTimeline events={data.auditEvents} />
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<QuotationPreviewPanel
|
||||
quotation={data.quotation}
|
||||
ownerCustomer={ownerCustomer}
|
||||
ownerContact={ownerContact}
|
||||
approvalSteps={data.quotation.approvalSteps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user