293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
'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>
|
|
);
|
|
}
|