369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
/**
|
||
* DeptLeadIOApprovalModal Component
|
||
* Modal for Step 3: Dept Lead Approval and IO Organization
|
||
* Allows department lead to approve request and organize IO details
|
||
*/
|
||
|
||
import { useState, useMemo } from 'react';
|
||
import * as React from 'react';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
|
||
interface DeptLeadIOApprovalModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onApprove: (data: {
|
||
ioNumber: string;
|
||
ioRemark: string;
|
||
comments: string;
|
||
}) => Promise<void>;
|
||
onReject: (comments: string) => Promise<void>;
|
||
requestTitle?: string;
|
||
requestId?: string;
|
||
// Pre-filled IO data from IO table
|
||
preFilledIONumber?: string;
|
||
preFilledIORemark?: string;
|
||
preFilledBlockedAmount?: number;
|
||
preFilledRemainingBalance?: number;
|
||
}
|
||
|
||
export function DeptLeadIOApprovalModal({
|
||
isOpen,
|
||
onClose,
|
||
onApprove,
|
||
onReject,
|
||
requestTitle,
|
||
requestId: _requestId,
|
||
preFilledIONumber,
|
||
preFilledIORemark,
|
||
preFilledBlockedAmount,
|
||
preFilledRemainingBalance,
|
||
}: DeptLeadIOApprovalModalProps) {
|
||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
||
const [ioRemark, setIoRemark] = useState('');
|
||
const [comments, setComments] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
// Get IO number from props (read-only, from IO table)
|
||
const ioNumber = preFilledIONumber || '';
|
||
|
||
// Reset form when modal opens/closes
|
||
React.useEffect(() => {
|
||
if (isOpen) {
|
||
// Prefill IO remark from props if available
|
||
setIoRemark(preFilledIORemark || '');
|
||
setComments('');
|
||
setActionType('approve');
|
||
}
|
||
}, [isOpen, preFilledIORemark]);
|
||
|
||
const ioRemarkChars = ioRemark.length;
|
||
const commentsChars = comments.length;
|
||
const maxIoRemarkChars = 300;
|
||
const maxCommentsChars = 500;
|
||
|
||
// Validate form
|
||
const isFormValid = useMemo(() => {
|
||
if (actionType === 'reject') {
|
||
return comments.trim().length > 0;
|
||
}
|
||
// For approve, need IO number (from table), IO remark, and comments
|
||
return (
|
||
ioNumber.trim().length > 0 && // IO number must exist from IO table
|
||
ioRemark.trim().length > 0 &&
|
||
comments.trim().length > 0
|
||
);
|
||
}, [actionType, ioNumber, ioRemark, comments]);
|
||
|
||
const handleSubmit = async () => {
|
||
if (!isFormValid) {
|
||
if (actionType === 'approve') {
|
||
if (!ioNumber.trim()) {
|
||
toast.error('IO number is required. Please block amount from IO tab first.');
|
||
return;
|
||
}
|
||
if (!ioRemark.trim()) {
|
||
toast.error('Please enter IO remark');
|
||
return;
|
||
}
|
||
}
|
||
if (!comments.trim()) {
|
||
toast.error('Please provide comments');
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
|
||
if (actionType === 'approve') {
|
||
await onApprove({
|
||
ioNumber: ioNumber.trim(),
|
||
ioRemark: ioRemark.trim(),
|
||
comments: comments.trim(),
|
||
});
|
||
} else {
|
||
await onReject(comments.trim());
|
||
}
|
||
|
||
handleReset();
|
||
onClose();
|
||
} catch (error) {
|
||
console.error(`Failed to ${actionType} request:`, error);
|
||
toast.error(`Failed to ${actionType} request. Please try again.`);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setActionType('approve');
|
||
setIoRemark('');
|
||
setComments('');
|
||
};
|
||
|
||
const handleClose = () => {
|
||
if (!submitting) {
|
||
handleReset();
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<div className="p-2 rounded-lg bg-green-100">
|
||
<CircleCheckBig className="w-6 h-6 text-green-600" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<DialogTitle className="font-semibold text-xl">
|
||
Approve and Organise IO
|
||
</DialogTitle>
|
||
<DialogDescription className="text-sm mt-1">
|
||
Review IO details and provide your approval comments
|
||
</DialogDescription>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Request Info Card */}
|
||
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium text-gray-900">Workflow Step:</span>
|
||
<Badge variant="outline" className="font-mono">Step 3</Badge>
|
||
</div>
|
||
<div>
|
||
<span className="font-medium text-gray-900">Title:</span>
|
||
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-gray-900">Action:</span>
|
||
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||
<CircleCheckBig className="w-3 h-3 mr-1" />
|
||
APPROVE
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-3">
|
||
{/* Action Toggle Buttons */}
|
||
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||
<Button
|
||
type="button"
|
||
onClick={() => setActionType('approve')}
|
||
className={`flex-1 ${
|
||
actionType === 'approve'
|
||
? 'bg-green-600 text-white shadow-sm'
|
||
: 'text-gray-700 hover:bg-gray-200'
|
||
}`}
|
||
variant={actionType === 'approve' ? 'default' : 'ghost'}
|
||
>
|
||
<CircleCheckBig className="w-4 h-4 mr-1" />
|
||
Approve
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => setActionType('reject')}
|
||
className={`flex-1 ${
|
||
actionType === 'reject'
|
||
? 'bg-red-600 text-white shadow-sm'
|
||
: 'text-gray-700 hover:bg-gray-200'
|
||
}`}
|
||
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
|
||
>
|
||
<CircleX className="w-4 h-4 mr-1" />
|
||
Reject
|
||
</Button>
|
||
</div>
|
||
|
||
{/* IO Organisation Details - Only shown when approving */}
|
||
{actionType === 'approve' && (
|
||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Receipt className="w-4 h-4 text-blue-600" />
|
||
<h4 className="font-semibold text-blue-900">IO Organisation Details</h4>
|
||
</div>
|
||
|
||
{/* IO Number - Read-only from IO table */}
|
||
<div className="space-y-1">
|
||
<Label htmlFor="ioNumber" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||
IO Number <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Input
|
||
id="ioNumber"
|
||
value={ioNumber || '—'}
|
||
disabled
|
||
readOnly
|
||
className="bg-gray-100 h-8 cursor-not-allowed"
|
||
/>
|
||
{!ioNumber && (
|
||
<p className="text-xs text-red-600 mt-1">
|
||
⚠️ IO number not found. Please block amount from IO tab first.
|
||
</p>
|
||
)}
|
||
{ioNumber && (
|
||
<p className="text-xs text-blue-600 mt-1">
|
||
✓ Loaded from IO table
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* IO Balance Information - Read-only */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{/* Blocked Amount Display */}
|
||
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
|
||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
|
||
<span className="text-sm font-bold text-green-700 mt-1">
|
||
₹{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Remaining Balance Display */}
|
||
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
|
||
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
|
||
<span className="text-sm font-bold text-blue-700 mt-1">
|
||
₹{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
|
||
<div className="space-y-1">
|
||
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||
IO Remark <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Textarea
|
||
id="ioRemark"
|
||
placeholder="Enter remarks about IO organization"
|
||
value={ioRemark}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
if (value.length <= maxIoRemarkChars) {
|
||
setIoRemark(value);
|
||
}
|
||
}}
|
||
rows={3}
|
||
className="bg-white text-sm min-h-[80px] resize-none"
|
||
disabled={false}
|
||
readOnly={false}
|
||
/>
|
||
<div className="flex items-center justify-between text-xs">
|
||
{preFilledIORemark && (
|
||
<span className="text-blue-600">
|
||
✓ Prefilled from IO tab (editable)
|
||
</span>
|
||
)}
|
||
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Comments & Remarks */}
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||
Comments & Remarks <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Textarea
|
||
id="comment"
|
||
placeholder={
|
||
actionType === 'approve'
|
||
? 'Enter your approval comments and any conditions or notes...'
|
||
: 'Enter detailed reasons for rejection...'
|
||
}
|
||
value={comments}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
if (value.length <= maxCommentsChars) {
|
||
setComments(value);
|
||
}
|
||
}}
|
||
rows={4}
|
||
className="text-sm min-h-[80px] resize-none"
|
||
/>
|
||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||
<div className="flex items-center gap-1">
|
||
<TriangleAlert className="w-3 h-3" />
|
||
Required and visible to all
|
||
</div>
|
||
<span>{commentsChars}/{maxCommentsChars}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleClose}
|
||
disabled={submitting}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={handleSubmit}
|
||
disabled={!isFormValid || submitting}
|
||
className={`${
|
||
actionType === 'approve'
|
||
? 'bg-green-600 hover:bg-green-700'
|
||
: 'bg-red-600 hover:bg-red-700'
|
||
} text-white`}
|
||
>
|
||
{submitting ? (
|
||
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
|
||
) : (
|
||
<>
|
||
<CircleCheckBig className="w-4 h-4 mr-2" />
|
||
{actionType === 'approve' ? 'Approve Request' : 'Reject Request'}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|