task-f
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user