feat: Add ProjectAssigneeDropdown component and integrate it into ProjectViewPage

This commit is contained in:
2025-09-29 20:09:11 +02:00
parent 6ab87c7396
commit 769fc73898
3 changed files with 234 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ import { formatDate, formatCoordinates } from "@/lib/utils";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
import ProjectAssigneeDropdown from "@/components/ProjectAssigneeDropdown";
import ClientProjectMap from "@/components/ui/ClientProjectMap";
import FileUploadBox from "@/components/FileUploadBox";
import FileItem from "@/components/FileItem";
@@ -479,6 +480,12 @@ export default function ProjectViewPage() {
</span>
<ProjectStatusDropdown project={project} size="md" />
</div>
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2">
Przypisany do
</span>
<ProjectAssigneeDropdown project={project} size="md" />
</div>
{daysRemaining !== null && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2">

View File

@@ -0,0 +1,219 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Badge from "@/components/ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectAssigneeDropdown({
project,
size = "md",
showDropdown = true,
}) {
const { t } = useTranslation();
const [assignee, setAssignee] = useState({
id: project.assigned_to,
name: project.assigned_to_name,
username: project.assigned_to_username,
initial: project.assigned_to_initial,
});
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const buttonRef = useRef(null);
// Update assignee state when project prop changes
useEffect(() => {
setAssignee({
id: project.assigned_to,
name: project.assigned_to_name,
username: project.assigned_to_username,
initial: project.assigned_to_initial,
});
}, [project.assigned_to, project.assigned_to_name, project.assigned_to_username, project.assigned_to_initial]);
// Fetch users for assignment
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch("/api/projects/users");
if (response.ok) {
const userData = await response.json();
setUsers(userData);
}
} catch (error) {
console.error("Failed to fetch users:", error);
}
};
if (isOpen && users.length === 0) {
fetchUsers();
}
}, [isOpen, users.length]);
const handleChange = async (newAssigneeId) => {
if (newAssigneeId === assignee.id) {
setIsOpen(false);
return;
}
const newAssignee = users.find(u => u.id === newAssigneeId) || null;
setAssignee(newAssignee);
setLoading(true);
setIsOpen(false);
try {
const updateData = { ...project, assigned_to: newAssigneeId };
const response = await fetch(`/api/projects/${project.project_id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updateData),
});
if (!response.ok) {
const errorData = await response.json();
console.error('Update failed:', errorData);
}
window.location.reload();
} catch (error) {
console.error("Failed to update assignee:", error);
setAssignee({
id: project.assigned_to,
name: project.assigned_to_name,
username: project.assigned_to_username,
initial: project.assigned_to_initial,
}); // Revert on error
} finally {
setLoading(false);
}
};
const updateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
const handleOpen = () => {
setIsOpen(true);
};
useEffect(() => {
if (isOpen) {
const handleResize = () => updateDropdownPosition();
const handleScroll = () => updateDropdownPosition();
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleScroll, true);
updateDropdownPosition();
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleScroll, true);
};
}
}, [isOpen]);
const displayText = loading
? "Updating..."
: assignee?.name
? assignee.name
: isOpen && users.length === 0
? "Loading..."
: t("projects.unassigned");
if (!showDropdown) {
return (
<Badge variant="default" size={size}>
{displayText}
</Badge>
);
}
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => {
setIsOpen(!isOpen);
}}
disabled={loading || (isOpen && users.length === 0)}
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded-md"
>
<Badge
variant="default"
size={size}
className={`cursor-pointer hover:opacity-80 transition-opacity ${
loading || (isOpen && users.length === 0) ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{loading ? "Updating..." : displayText}
<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>{" "}
{/* Assignee Options Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-[9999] min-w-[200px] max-h-60 overflow-y-auto">
{/* Unassigned option */}
<button
onClick={() => {
handleChange(null);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700"
>
<span className="text-gray-500 italic">{t("projects.unassigned")}</span>
</button>
{/* User options */}
{users.map((user) => (
<button
key={user.id}
onClick={() => {
handleChange(user.id);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<span>{user.name}</span>
</button>
))}
</div>
)}{" "}
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[9998]"
onClick={() => {
setIsOpen(false);
}}
/>
)}
</div>
);
}

View File

@@ -219,9 +219,16 @@ export function getProjectWithContract(id) {
c.contract_number,
c.contract_name,
c.customer,
c.investor
c.investor,
creator.name as created_by_name,
creator.username as created_by_username,
assignee.name as assigned_to_name,
assignee.username as assigned_to_username,
assignee.initial as assigned_to_initial
FROM projects p
LEFT JOIN contracts c ON p.contract_id = c.contract_id
LEFT JOIN users creator ON p.created_by = creator.id
LEFT JOIN users assignee ON p.assigned_to = assignee.id
WHERE p.project_id = ?
`
)