539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
// components/DeleteRequestButton.tsx
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { FaTrash, FaCheckCircle, FaExclamationTriangle, FaSpinner, FaTimes, FaChevronDown, FaBan } from 'react-icons/fa';
|
|
import { useDeleteRequest, type DeleteStatus } from '../hooks/useDeleteRequest';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
interface DeleteRequestButtonProps {
|
|
doctype: string;
|
|
docname: string | null | undefined;
|
|
currentDeleteStatus: DeleteStatus;
|
|
userRoles: string[];
|
|
isSystemManager: boolean;
|
|
/** Called after any successful status change — use to refetch your document */
|
|
onStatusChange?: (newStatus: DeleteStatus) => void;
|
|
/** Extra classes on the button wrapper div */
|
|
className?: string;
|
|
/** If true, renders buttons inline (row). Default: column (stacked). */
|
|
inline?: boolean;
|
|
redirectOnDelete?: string;
|
|
triggerMode?: boolean; // ← ADD THIS
|
|
}
|
|
|
|
// ─── Confirmation Modal ───────────────────────────────────────────────────────
|
|
|
|
interface ConfirmModalProps {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel: string;
|
|
confirmClass: string;
|
|
icon: React.ReactNode;
|
|
loading: boolean;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
|
title,
|
|
message,
|
|
confirmLabel,
|
|
confirmClass,
|
|
icon,
|
|
loading,
|
|
onConfirm,
|
|
onCancel,
|
|
}) => (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
|
<div className="flex items-start gap-3 mb-4">
|
|
<span className="mt-0.5 flex-shrink-0 text-xl">{icon}</span>
|
|
<div>
|
|
<h3 className="text-base font-semibold text-gray-800 dark:text-white">{title}</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{message}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={onCancel}
|
|
disabled={loading}
|
|
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={loading}
|
|
className={`px-4 py-2 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 ${confirmClass}`}
|
|
>
|
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : null}
|
|
{confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── Split Dropdown Button ────────────────────────────────────────────────────
|
|
|
|
interface SplitDropdownButtonProps {
|
|
primaryLabel: string;
|
|
primaryClass: string;
|
|
primaryIcon: React.ReactNode;
|
|
onPrimary: () => void;
|
|
secondaryLabel: string;
|
|
secondaryClass: string;
|
|
secondaryIcon: React.ReactNode;
|
|
onSecondary: () => void;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const SplitDropdownButton: React.FC<SplitDropdownButtonProps> = ({
|
|
primaryLabel,
|
|
primaryClass,
|
|
primaryIcon,
|
|
onPrimary,
|
|
secondaryLabel,
|
|
secondaryClass,
|
|
secondaryIcon,
|
|
onSecondary,
|
|
disabled = false,
|
|
loading = false,
|
|
}) => {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
if (open) document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [open]);
|
|
|
|
return (
|
|
<div ref={ref} className="relative inline-flex rounded-lg overflow-visible">
|
|
{/* Primary action button */}
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onPrimary}
|
|
className={`px-4 py-2 text-white text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors rounded-l-lg ${primaryClass}`}
|
|
>
|
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : primaryIcon}
|
|
{primaryLabel}
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
<span className="w-px bg-white/30" />
|
|
|
|
{/* Chevron toggle */}
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setOpen((v) => !v)}
|
|
className={`px-2 py-2 text-white text-sm font-medium flex items-center disabled:opacity-50 transition-colors rounded-r-lg ${primaryClass}`}
|
|
aria-label="More options"
|
|
>
|
|
<FaChevronDown size={11} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
{open && (
|
|
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg min-w-[160px] overflow-hidden">
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => { setOpen(false); onSecondary(); }}
|
|
className={`w-full px-4 py-2.5 text-sm font-medium flex items-center gap-2 transition-colors ${secondaryClass}`}
|
|
>
|
|
{secondaryIcon}
|
|
{secondaryLabel}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Status Badge ─────────────────────────────────────────────────────────────
|
|
|
|
const StatusBadge: React.FC<{ status: DeleteStatus }> = ({ status }) => {
|
|
if (!status) return null;
|
|
|
|
const config: Record<string, { bg: string; text: string; label: string }> = {
|
|
'Delete Request With Supervisor': {
|
|
bg: 'bg-orange-100 dark:bg-orange-900/30',
|
|
text: 'text-orange-700 dark:text-orange-300',
|
|
label: '⏳ Delete Request Pending Supervisor',
|
|
},
|
|
'Delete Request With CM': {
|
|
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
|
|
text: 'text-yellow-700 dark:text-yellow-300',
|
|
label: '⏳ Delete Request Pending CM',
|
|
},
|
|
Deleted: {
|
|
bg: 'bg-red-100 dark:bg-red-900/30',
|
|
text: 'text-red-700 dark:text-red-300',
|
|
label: '🗑 Marked for Deletion',
|
|
},
|
|
};
|
|
|
|
const c = config[status as string];
|
|
if (!c) return null;
|
|
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${c.bg} ${c.text}`}
|
|
>
|
|
{c.label}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
type PendingAction =
|
|
| 'raise'
|
|
| 'supervisor_approve'
|
|
| 'supervisor_reject'
|
|
| 'cm_approve'
|
|
| 'cm_reject'
|
|
| 'direct'
|
|
| null;
|
|
|
|
const DeleteRequestButton: React.FC<DeleteRequestButtonProps> = ({
|
|
doctype,
|
|
docname,
|
|
currentDeleteStatus,
|
|
userRoles,
|
|
isSystemManager,
|
|
onStatusChange,
|
|
className = '',
|
|
inline = false,
|
|
redirectOnDelete,
|
|
triggerMode = false,
|
|
}) => {
|
|
const navigate = useNavigate();
|
|
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
|
|
const [triggerOpen, setTriggerOpen] = useState(false); // ← ADD THIS
|
|
const triggerRef = useRef<HTMLDivElement>(null); // ← ADD THIS
|
|
|
|
const {
|
|
showRaiseRequest,
|
|
showApproveAsSupervisor,
|
|
showApproveAsCM,
|
|
showDirectDelete,
|
|
deleteStatus,
|
|
loading,
|
|
error,
|
|
raiseRequest,
|
|
approveAsSupervisor,
|
|
approveAsCM,
|
|
directDelete,
|
|
rejectRequest, // ← new: resets delete_status to ""
|
|
} = useDeleteRequest({
|
|
doctype,
|
|
docname,
|
|
currentDeleteStatus,
|
|
userRoles,
|
|
isSystemManager,
|
|
onSuccess: (newStatus) => {
|
|
onStatusChange?.(newStatus);
|
|
if (newStatus === 'Deleted' && redirectOnDelete) {
|
|
navigate(redirectOnDelete);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Close trigger dropdown on outside click
|
|
useEffect(() => {
|
|
if (!triggerOpen) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) {
|
|
setTriggerOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [triggerOpen]);
|
|
|
|
const handleConfirm = async () => {
|
|
if (!pendingAction) return;
|
|
const actionMap: Record<NonNullable<PendingAction>, () => Promise<void>> = {
|
|
raise: raiseRequest,
|
|
supervisor_approve: approveAsSupervisor,
|
|
supervisor_reject: rejectRequest,
|
|
cm_approve: approveAsCM,
|
|
cm_reject: rejectRequest,
|
|
direct: directDelete,
|
|
};
|
|
await actionMap[pendingAction]();
|
|
setPendingAction(null);
|
|
};
|
|
|
|
// ── Modal config per action ──────────────────────────────────────────────
|
|
const modalConfig: Record<
|
|
NonNullable<PendingAction>,
|
|
{ title: string; message: string; confirmLabel: string; confirmClass: string; icon: React.ReactNode }
|
|
> = {
|
|
raise: {
|
|
title: 'Request Deletion',
|
|
message: 'This will raise a deletion request to the Supervisor for review.',
|
|
confirmLabel: 'Raise Request',
|
|
confirmClass: 'bg-orange-600 hover:bg-orange-700',
|
|
icon: <FaExclamationTriangle className="text-orange-500" />,
|
|
},
|
|
supervisor_approve: {
|
|
title: 'Approve Deletion Request',
|
|
message: 'This will forward the deletion request to the Cluster Manager for final approval.',
|
|
confirmLabel: 'Approve & Forward',
|
|
confirmClass: 'bg-yellow-600 hover:bg-yellow-700',
|
|
icon: <FaCheckCircle className="text-yellow-500" />,
|
|
},
|
|
supervisor_reject: {
|
|
title: 'Reject Deletion Request',
|
|
message: 'This will reject the deletion request and clear the status. The record will remain active.',
|
|
confirmLabel: 'Reject Request',
|
|
confirmClass: 'bg-gray-600 hover:bg-gray-700',
|
|
icon: <FaBan className="text-gray-500" />,
|
|
},
|
|
cm_approve: {
|
|
title: 'Approve & Mark as Deleted',
|
|
message: 'This will mark the record as Deleted.',
|
|
confirmLabel: 'Approve & Delete',
|
|
confirmClass: 'bg-red-600 hover:bg-red-700',
|
|
icon: <FaTrash className="text-red-500" />,
|
|
},
|
|
cm_reject: {
|
|
title: 'Reject Deletion Request',
|
|
message: 'This will reject the deletion request and clear the status. The record will remain active.',
|
|
confirmLabel: 'Reject Request',
|
|
confirmClass: 'bg-gray-600 hover:bg-gray-700',
|
|
icon: <FaBan className="text-gray-500" />,
|
|
},
|
|
direct: {
|
|
title: 'Mark as Deleted',
|
|
message: 'This will immediately Delete The Record.',
|
|
confirmLabel: 'Delete',
|
|
confirmClass: 'bg-red-600 hover:bg-red-700',
|
|
icon: <FaTrash className="text-red-500" />,
|
|
},
|
|
};
|
|
|
|
const statusTooltip: Record<string, string> = {
|
|
'Delete Request With Supervisor': 'Delete Request Pending Supervisor',
|
|
'Delete Request With CM': 'Delete Request Pending Cluster Manager',
|
|
'Deleted': 'Marked for Deletion',
|
|
};
|
|
|
|
if (triggerMode) {
|
|
const hasRequest = !!deleteStatus;
|
|
const iconColor = hasRequest
|
|
? 'text-orange-500 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
|
: 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20';
|
|
|
|
const nothingToShow =
|
|
!showRaiseRequest && !showApproveAsSupervisor && !showApproveAsCM && !showDirectDelete && !deleteStatus;
|
|
|
|
if (nothingToShow) return null;
|
|
|
|
return (
|
|
<>
|
|
{pendingAction && (
|
|
<ConfirmModal
|
|
{...modalConfig[pendingAction]}
|
|
loading={loading}
|
|
onConfirm={handleConfirm}
|
|
onCancel={() => setPendingAction(null)}
|
|
/>
|
|
)}
|
|
<div className="relative" ref={triggerRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTriggerOpen((v) => !v)}
|
|
title={deleteStatus ? statusTooltip[deleteStatus as string] : 'Request Deletion'}
|
|
className={`p-2 rounded transition-colors ${iconColor}`}
|
|
disabled={loading || !docname}
|
|
>
|
|
{loading ? <FaSpinner className="animate-spin" size={14} /> : <FaTrash size={14} />}
|
|
</button>
|
|
|
|
{triggerOpen && (
|
|
<div className="absolute right-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 min-w-[230px]">
|
|
{deleteStatus && (
|
|
<div className="mb-3">
|
|
<StatusBadge status={deleteStatus} />
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<p className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
|
<FaExclamationTriangle size={10} /> {error}
|
|
</p>
|
|
)}
|
|
<div className="flex flex-col gap-2">
|
|
{showRaiseRequest && (
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => { setTriggerOpen(false); setPendingAction('raise'); }}
|
|
className="w-full px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
<FaTrash size={11} /> Request Deletion
|
|
</button>
|
|
)}
|
|
{showApproveAsSupervisor && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => { setTriggerOpen(false); setPendingAction('supervisor_approve'); }}
|
|
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
<FaCheckCircle size={11} /> Approve & Forward to CM
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => { setTriggerOpen(false); setPendingAction('supervisor_reject'); }}
|
|
className="w-full px-3 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
<FaBan size={11} /> Reject Request
|
|
</button>
|
|
</>
|
|
)}
|
|
{showApproveAsCM && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => { setTriggerOpen(false); setPendingAction('cm_approve'); }}
|
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
<FaCheckCircle size={11} /> Approve & Delete
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => { setTriggerOpen(false); setPendingAction('cm_reject'); }}
|
|
className="w-full px-3 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
<FaBan size={11} /> Reject Request
|
|
</button>
|
|
</>
|
|
)}
|
|
{showDirectDelete && (
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => { setTriggerOpen(false); setPendingAction('direct'); }}
|
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
|
>
|
|
<FaTrash size={11} /> Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const layoutClass = inline
|
|
? 'flex flex-row flex-wrap items-center gap-2'
|
|
: 'flex flex-col gap-2';
|
|
|
|
return (
|
|
<>
|
|
{/* Confirmation Modal */}
|
|
{pendingAction && (
|
|
<ConfirmModal
|
|
{...modalConfig[pendingAction]}
|
|
loading={loading}
|
|
onConfirm={handleConfirm}
|
|
onCancel={() => setPendingAction(null)}
|
|
/>
|
|
)}
|
|
|
|
<div className={`${layoutClass} ${className}`}>
|
|
{/* Status Badge — always shown when a status exists */}
|
|
{deleteStatus && <StatusBadge status={deleteStatus} />}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<p className="text-xs text-red-500 dark:text-red-400 flex items-center gap-1">
|
|
<FaExclamationTriangle size={11} /> {error}
|
|
</p>
|
|
)}
|
|
|
|
{/* ── Raise Request Button (no reject needed here) ─────────────────── */}
|
|
{showRaiseRequest && (
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => setPendingAction('raise')}
|
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors"
|
|
>
|
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : <FaTrash size={13} />}
|
|
Request Deletion
|
|
</button>
|
|
)}
|
|
|
|
{/* ── Supervisor: Approve (split) + Reject ────────────────────────── */}
|
|
{showApproveAsSupervisor && (
|
|
<SplitDropdownButton
|
|
disabled={loading || !docname}
|
|
loading={loading}
|
|
// Primary — Approve & Forward
|
|
primaryLabel="Approve Request"
|
|
primaryClass="bg-yellow-600 hover:bg-yellow-700"
|
|
primaryIcon={<FaCheckCircle size={13} />}
|
|
onPrimary={() => setPendingAction('supervisor_approve')}
|
|
// Secondary — Reject
|
|
secondaryLabel="Reject Request"
|
|
secondaryClass="text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
secondaryIcon={<FaBan size={13} className="text-gray-500" />}
|
|
onSecondary={() => setPendingAction('supervisor_reject')}
|
|
/>
|
|
)}
|
|
|
|
{/* ── CM: Approve & Delete (split) + Reject ───────────────────────── */}
|
|
{showApproveAsCM && (
|
|
<SplitDropdownButton
|
|
disabled={loading || !docname}
|
|
loading={loading}
|
|
// Primary — Approve & Delete
|
|
primaryLabel="Approve & Delete"
|
|
primaryClass="bg-red-600 hover:bg-red-700"
|
|
primaryIcon={<FaCheckCircle size={13} />}
|
|
onPrimary={() => setPendingAction('cm_approve')}
|
|
// Secondary — Reject
|
|
secondaryLabel="Reject Request"
|
|
secondaryClass="text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
secondaryIcon={<FaBan size={13} className="text-gray-500" />}
|
|
onSecondary={() => setPendingAction('cm_reject')}
|
|
/>
|
|
)}
|
|
|
|
{/* ── CM / SysManager: Direct Delete (no reject needed) ───────────── */}
|
|
{showDirectDelete && (
|
|
<button
|
|
type="button"
|
|
disabled={loading || !docname}
|
|
onClick={() => setPendingAction('direct')}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors"
|
|
>
|
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : <FaTrash size={13} />}
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DeleteRequestButton; |