238 lines
6.0 KiB
JavaScript
238 lines
6.0 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import Badge from "@/components/ui/Badge";
|
|
import { useTranslation } from "@/lib/i18n";
|
|
|
|
export default function TaskStatusDropdown({
|
|
task,
|
|
size = "sm",
|
|
showDropdown = true,
|
|
onStatusChange,
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [status, setStatus] = useState(task.status);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0, position: 'bottom' });
|
|
const [mounted, setMounted] = useState(false);
|
|
const buttonRef = useRef(null);
|
|
const dropdownRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const statusConfig = {
|
|
not_started: {
|
|
label: t("taskStatus.not_started"),
|
|
variant: "secondary",
|
|
},
|
|
pending: {
|
|
label: t("taskStatus.pending"),
|
|
variant: "warning",
|
|
},
|
|
in_progress: {
|
|
label: t("taskStatus.in_progress"),
|
|
variant: "primary",
|
|
},
|
|
completed: {
|
|
label: t("taskStatus.completed"),
|
|
variant: "success",
|
|
},
|
|
cancelled: {
|
|
label: t("taskStatus.cancelled"),
|
|
variant: "danger",
|
|
},
|
|
};
|
|
|
|
const handleChange = async (newStatus) => {
|
|
if (newStatus === status) {
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
setStatus(newStatus);
|
|
setLoading(true);
|
|
setIsOpen(false);
|
|
|
|
try {
|
|
const res = await fetch(`/api/project-tasks/${task.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: newStatus }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
// Call the callback if provided (for parent component to refresh)
|
|
if (onStatusChange) {
|
|
onStatusChange(task.id, newStatus);
|
|
}
|
|
} else {
|
|
// Revert on error
|
|
setStatus(task.status);
|
|
alert("Failed to update task status");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to update status:", error);
|
|
setStatus(task.status); // Revert on error
|
|
alert("Error updating task status");
|
|
} finally {
|
|
setLoading(false);
|
|
} };
|
|
// Calculate dropdown position to avoid clipping
|
|
const calculateDropdownPosition = () => {
|
|
if (!buttonRef.current) return;
|
|
|
|
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
const viewportHeight = window.innerHeight;
|
|
const viewportWidth = window.innerWidth;
|
|
const dropdownHeight = 180; // Estimated dropdown height
|
|
const dropdownWidth = 140; // Estimated dropdown width
|
|
|
|
// Check if there's enough space below
|
|
const spaceBelow = viewportHeight - buttonRect.bottom;
|
|
const spaceAbove = buttonRect.top;
|
|
|
|
// Determine vertical position
|
|
let y, position;
|
|
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
|
|
// Show above
|
|
y = buttonRect.top - dropdownHeight - 4;
|
|
position = 'top';
|
|
} else {
|
|
// Show below
|
|
y = buttonRect.bottom + 4;
|
|
position = 'bottom';
|
|
}
|
|
|
|
// Determine horizontal position (align right edge of dropdown with right edge of button)
|
|
let x = buttonRect.right - dropdownWidth;
|
|
|
|
// Ensure dropdown doesn't go off screen
|
|
if (x < 8) {
|
|
x = 8; // Minimum margin from left edge
|
|
} else if (x + dropdownWidth > viewportWidth - 8) {
|
|
x = viewportWidth - dropdownWidth - 8; // Ensure it fits within viewport
|
|
}
|
|
|
|
setDropdownPosition({ x, y, position });
|
|
}; const handleToggle = () => {
|
|
if (!isOpen) {
|
|
calculateDropdownPosition();
|
|
}
|
|
setIsOpen(!isOpen);
|
|
};
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleClickOutside = (event) => {
|
|
if (
|
|
buttonRef.current &&
|
|
!buttonRef.current.contains(event.target) &&
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(event.target)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (isOpen) {
|
|
calculateDropdownPosition();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
window.addEventListener('scroll', handleScroll);
|
|
window.addEventListener('resize', handleScroll);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
window.removeEventListener('scroll', handleScroll);
|
|
window.removeEventListener('resize', handleScroll);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
const currentConfig = statusConfig[status] || {
|
|
label: "Unknown",
|
|
variant: "default",
|
|
};
|
|
if (!showDropdown) {
|
|
return (
|
|
<Badge variant={currentConfig.variant} size={size}>
|
|
{currentConfig.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// Portal dropdown component
|
|
const DropdownPortal = () => {
|
|
if (!mounted || !isOpen) return null;
|
|
return createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
className="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-[9999] min-w-[140px]"
|
|
style={{
|
|
left: `${dropdownPosition.x}px`,
|
|
top: `${dropdownPosition.y}px`,
|
|
}}
|
|
> {Object.entries(statusConfig).map(([statusKey, config]) => (
|
|
<button
|
|
key={statusKey}
|
|
onClick={() => {
|
|
handleChange(statusKey);
|
|
}}
|
|
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last:rounded-b-md"
|
|
>
|
|
<Badge variant={config.variant} size="sm">
|
|
{config.label}
|
|
</Badge>
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}; return (
|
|
<>
|
|
<button
|
|
ref={buttonRef}
|
|
onClick={handleToggle}
|
|
disabled={loading}
|
|
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded-md"
|
|
>
|
|
<Badge
|
|
variant={currentConfig.variant}
|
|
size={size}
|
|
className={`cursor-pointer hover:opacity-80 transition-opacity ${
|
|
loading ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
{loading ? "Updating..." : currentConfig.label}
|
|
<svg
|
|
className={`w-3 h-3 ml-1 transition-transform ${
|
|
isOpen ? "rotate-180" : ""
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</Badge>
|
|
</button>
|
|
|
|
{/* Portal-based dropdown */}
|
|
<DropdownPortal />
|
|
</>
|
|
);
|
|
}
|