This commit is contained in:
phaichayon
2026-06-16 09:19:17 +07:00
parent b8f13e36b3
commit dff22d75b5
32 changed files with 5786 additions and 68 deletions

View File

@@ -0,0 +1,292 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import {
approveApprovalMutation,
cancelApprovalMutation,
rejectApprovalMutation,
returnApprovalMutation
} from '../mutations';
import type { ApprovalDetailRecord } from '../types';
import { ApprovalStatusBadge } from './approval-status-badge';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='text-sm'>{value || '-'}</div>
</div>
);
}
export function ApprovalRequestPanel({
approval,
canApprove,
canReject,
canReturn,
canCancel,
activeBusinessRole,
isOrgAdmin,
currentUserId,
entityHref
}: {
approval: ApprovalDetailRecord;
canApprove: boolean;
canReject: boolean;
canReturn: boolean;
canCancel: boolean;
activeBusinessRole: string | null;
isOrgAdmin: boolean;
currentUserId: string;
entityHref?: string;
}) {
const [remark, setRemark] = useState('');
const isPending = approval.request.status === 'pending';
const canHandleCurrentStep =
!!approval.currentStep &&
(isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode);
const canCancelCurrentRequest =
canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId);
const approveAction = useMutation({
...approveApprovalMutation,
onSuccess: () => {
toast.success('Approval step completed');
setRemark('');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to approve request')
});
const rejectAction = useMutation({
...rejectApprovalMutation,
onSuccess: () => {
toast.success('Approval request rejected');
setRemark('');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to reject request')
});
const returnAction = useMutation({
...returnApprovalMutation,
onSuccess: () => {
toast.success('Approval request returned to draft');
setRemark('');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to return request')
});
const cancelAction = useMutation({
...cancelApprovalMutation,
onSuccess: () => toast.success('Approval request cancelled'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to cancel request')
});
const isActing =
approveAction.isPending ||
rejectAction.isPending ||
returnAction.isPending ||
cancelAction.isPending;
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Request Information</CardTitle>
<CardDescription>Current workflow state for this approval request.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Workflow' value={approval.workflow.name} />
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>Status</div>
<ApprovalStatusBadge status={approval.request.status} />
</div>
<FieldItem label='Entity Type' value={approval.request.entityType.replaceAll('_', ' ')} />
<FieldItem label='Entity Code' value={approval.entityCode ?? approval.request.entityId} />
<FieldItem label='Entity Title' value={approval.entityTitle} />
<FieldItem
label='Requested At'
value={new Date(approval.request.requestedAt).toLocaleString()}
/>
<FieldItem label='Requested By' value={approval.request.requestedBy} />
<FieldItem
label='Current Step'
value={
approval.currentStep
? `Step ${approval.currentStep.stepNumber} - ${approval.currentStep.roleName}`
: approval.request.status === 'approved'
? 'Completed'
: '-'
}
/>
{entityHref ? (
<div className='md:col-span-2 xl:col-span-4'>
<Button asChild variant='outline'>
<Link href={entityHref}>
<Icons.arrowRight className='mr-2 h-4 w-4' />
Open Related Document
</Link>
</Button>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Workflow</CardTitle>
<CardDescription>Sequential approver chain configured for this document.</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{approval.steps.map((step) => {
const isCurrent = approval.currentStep?.id === step.id && isPending;
const isCompleted = approval.request.currentStep > step.stepNumber || approval.request.status === 'approved';
return (
<div
key={step.id}
className='flex items-center justify-between rounded-lg border p-4'
>
<div>
<div className='font-medium'>
Step {step.stepNumber} - {step.roleName}
</div>
<div className='text-muted-foreground text-sm'>{step.roleCode}</div>
</div>
<Badge variant={isCurrent ? 'secondary' : isCompleted ? 'default' : 'outline'}>
{isCurrent ? 'Current' : isCompleted ? 'Completed' : 'Pending'}
</Badge>
</div>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Actions</CardTitle>
<CardDescription>
Approvers can approve, reject, or return on their assigned step.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<Textarea
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder='Optional remark for approval history'
rows={4}
/>
<div className='flex flex-wrap gap-2'>
{canApprove && canHandleCurrentStep && isPending ? (
<Button
isLoading={approveAction.isPending}
onClick={() =>
approveAction.mutate({ id: approval.request.id, values: { remark } })
}
>
<Icons.circleCheck className='mr-2 h-4 w-4' />
Approve
</Button>
) : null}
{canReject && canHandleCurrentStep && isPending ? (
<Button
variant='destructive'
isLoading={rejectAction.isPending}
onClick={() =>
rejectAction.mutate({ id: approval.request.id, values: { remark } })
}
>
<Icons.warning className='mr-2 h-4 w-4' />
Reject
</Button>
) : null}
{canReturn && canHandleCurrentStep && isPending ? (
<Button
variant='outline'
isLoading={returnAction.isPending}
onClick={() =>
returnAction.mutate({ id: approval.request.id, values: { remark } })
}
>
<Icons.chevronLeft className='mr-2 h-4 w-4' />
Return
</Button>
) : null}
{canCancelCurrentRequest ? (
<Button
variant='outline'
isLoading={cancelAction.isPending}
onClick={() => cancelAction.mutate(approval.request.id)}
>
<Icons.close className='mr-2 h-4 w-4' />
Cancel Request
</Button>
) : null}
</div>
{!canHandleCurrentStep && isPending ? (
<div className='text-muted-foreground text-sm'>
Current step is assigned to{' '}
<span className='font-medium'>{approval.currentStep?.roleName ?? 'another role'}</span>.
</div>
) : null}
{!isPending ? (
<div className='text-muted-foreground text-sm'>
This request is already {approval.request.status.replaceAll('_', ' ')}.
</div>
) : null}
{isActing ? (
<div className='text-muted-foreground text-xs'>Saving approval action...</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Timeline</CardTitle>
<CardDescription>Full audit trail of request submission and approval actions.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{!approval.timeline.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No approval actions recorded yet.
</div>
) : (
approval.timeline.map((item, index) => (
<div key={item.id} className='space-y-4'>
<div className='flex items-start gap-3'>
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{item.action}
</Badge>
<span className='text-sm font-medium'>
{item.actorName ?? item.actedBy}
</span>
<span className='text-muted-foreground text-xs'>
Step {item.stepNumber}
</span>
</div>
<div className='text-muted-foreground text-xs'>
{new Date(item.actedAt).toLocaleString()}
</div>
{item.remark ? <div className='text-sm'>{item.remark}</div> : null}
</div>
</div>
{index < approval.timeline.length - 1 ? <Separator /> : null}
</div>
))
)}
</CardContent>
</Card>
</div>
);
}