346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
'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>
|
|
);
|
|
}
|