diff --git a/src/api/projects.ts b/src/api/projects.ts index 78ac957..e4b1e4f 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -5,6 +5,26 @@ export interface ProjectClient { name: string; } +export interface ProjectMemberPayload { + user_id: string; + role: "manager" | "member" | string; +} + +export interface ProjectMembership { + id: string; + project: string; + user: string; + user_details: { + id: string; + first_name: string; + last_name: string; + phone_number: string; + avatar?: string; + }; + role: "manager" | "member" | string; + is_active: boolean; +} + export interface Project { id: string; name: string; @@ -13,10 +33,11 @@ export interface Project { is_archived: boolean; workspace: string; client: ProjectClient | null; + my_role?: string; + members?: ProjectMembership[]; } export interface ProjectPayload { - id: string; name: string; description: string; color: string; @@ -44,7 +65,23 @@ export const getProjects = async ( return response.json(); }; -export const createProject = async (data: Partial & { workspace: string; name: string }) => { +export const getProject = async (id: string) => { + const response = await authFetch(`/api/projects/${id}/`); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to fetch project"); + } + return response.json(); +}; + +export const createProject = async ( + data: Partial & { + workspace: string; + name: string; + members?: ProjectMemberPayload[]; + } +) => { const response = await authFetch("/api/projects/", { method: "POST", body: JSON.stringify(data), @@ -57,7 +94,10 @@ export const createProject = async (data: Partial & { workspace: return response.json(); }; -export const updateProject = async (id: string, data: Partial) => { +export const updateProject = async ( + id: string, + data: Partial & { members?: ProjectMemberPayload[] } +) => { const response = await authFetch(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(data), @@ -95,3 +135,49 @@ export const toggleArchiveProject = async (id: string) => { } return response.json(); }; + + +export const getProjectMemberships = async (projectId: string) => { + const response = await authFetch(`/api/memberships/?project=${projectId}`); + if (!response.ok) throw new Error("Failed to fetch project memberships"); + return response.json(); +}; + +export const addProjectMembership = async (projectId: string, userId: string, role: string) => { + const response = await authFetch(`/api/memberships/`, { + method: "POST", + body: JSON.stringify({ project_id: projectId, user_id: userId, role }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to add project member"); + } + return response.json(); +}; + +export const updateProjectMembership = async (membershipId: string, role: string, isActive: boolean = true) => { + const response = await authFetch(`/api/memberships/${membershipId}/`, { + method: "PATCH", + body: JSON.stringify({ role, is_active: isActive }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to update project member"); + } + return response.json(); +}; + +export const removeProjectMembership = async (membershipId: string) => { + const response = await authFetch(`/api/memberships/${membershipId}/`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to remove member"); + } + if (response.status === 204) return { success: true }; + return response.json().catch(() => ({ success: true })); +}; diff --git a/src/pages/ProjectCreate.tsx b/src/pages/ProjectCreate.tsx new file mode 100644 index 0000000..2f7913e --- /dev/null +++ b/src/pages/ProjectCreate.tsx @@ -0,0 +1,633 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useNavigate, useBlocker } from "react-router-dom"; +import { + Users, + Briefcase, + Trash2, + Search, + Loader2, +} from "lucide-react"; +import { toast } from "sonner"; + +import { createProject } from "../api/projects"; +import { getClients } from "../api/clients"; +import { fetchWorkspaceMemberships } from "../api/workspaces"; +import { searchUserByExactMobile, type SearchedUser } from "../api/users"; +import { useAppContext } from "../context/AppContext"; +import { useWorkspace } from "../context/WorkspaceContext"; +import { useTranslation } from "../hooks/useTranslation"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Select } from "../components/ui/Select"; +import { TextAreaInput } from "../components/ui/TextAreaInput"; +import { InfiniteScroll } from "../components/InfiniteScroll"; +import { Modal } from "../components/Modal"; + +type ProjectRole = "manager" | "member"; + +interface LocalMember { + localId: string; + user: any; + role: ProjectRole; + isCreator?: boolean; +} + +const COLORS = [ + "#3B82F6", + "#10B981", + "#F59E0B", + "#EF4444", + "#8B5CF6", + "#EC4899", + "#14B8A6", + "#64748B", +]; + +const toEnglishDigits = (str: string) => { + if (!str) return ""; + return str + .replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString()) + .replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString()); +}; + +const LIMIT = 10; + +export default function ProjectCreate() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { user } = useAppContext(); + const { activeWorkspace } = useWorkspace(); + const currentUserId = user?.id || ""; + + // Project Detail States + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [color, setColor] = useState(COLORS[0]); + const [client, setClient] = useState(""); + const [clientsList, setClientsList] = useState([]); + + // Workspace List & Pagination States + const [workspaceMembers, setWorkspaceMembers] = useState([]); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); + + // Member Management States + const [members, setMembers] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [addAllMembers, setAddAllMembers] = useState(false); + const [isAddingAll, setIsAddingAll] = useState(false); + + // External Search States + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(false); + const searchTimeoutRef = useRef | null>(null); + + const [isSaving, setIsSaving] = useState(false); + const [memberIdToDelete, setMemberIdToDelete] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1; + + useBlocker(({ currentLocation, nextLocation }) => { + if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) { + return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?"); + } + return false; + }); + + // EXACT same pagination structure as EditWorkspace.tsx + useEffect(() => { + if (activeWorkspace?.id) { + const workspaceId = activeWorkspace.id; + + setName(""); + setDescription(""); + setColor(COLORS[0]); + setClient(""); + setClientsList([]); + setWorkspaceMembers([]); + setSearchQuery(""); + setSearchResult(null); + setSearchError(false); + setAddAllMembers(false); + + // Reset pagination state + setOffset(0); + setHasMore(true); + setIsLoadingData(true); + + if (user?.id) { + setMembers([{ localId: user.id, user: user, role: "manager", isCreator: true }]); + } else { + setMembers([]); + } + + const loadInitialData = async () => { + try { + const clientsRes = await getClients(workspaceId); + setClientsList(clientsRes.results || []); + + const res = await fetchWorkspaceMemberships({ + workspace: workspaceId, + limit: LIMIT, + offset: 0, + }); + const results = res.results || (Array.isArray(res) ? res : []); + + setWorkspaceMembers(results); + setOffset(LIMIT); + setHasMore(res.next ? true : results.length >= LIMIT); + } catch (err) { + console.error("Failed to fetch initial data", err); + toast.error("Failed to load initial data."); + } finally { + setIsLoadingData(false); + } + }; + + loadInitialData(); + } + }, [activeWorkspace?.id, user?.id]); + + // EXACT same LoadMore logic and deduplication as EditWorkspace.tsx + const loadMoreMembers = useCallback(async () => { + if (isLoadingMore || !hasMore || !activeWorkspace?.id) return; + try { + setIsLoadingMore(true); + + const res = await fetchWorkspaceMemberships({ + workspace: activeWorkspace.id, + limit: LIMIT, + offset: offset + }); + const results = res.results || (Array.isArray(res) ? res : []); + + setWorkspaceMembers((prev) => { + // Safe deduplication to avoid React key warnings breaking the DOM observer + const existingIds = new Set(prev.map(m => m.id)); + const newItems = results.filter((item: any) => !existingIds.has(item.id)); + return [...prev, ...newItems]; + }); + + setOffset(prev => prev + LIMIT); + setHasMore(res.next ? true : results.length >= LIMIT); + + } catch (error) { + console.error("Failed to load more members", error); + } finally { + setIsLoadingMore(false); + } + }, [activeWorkspace?.id, isLoadingMore, hasMore, offset]); + + // Unified Search Logic + useEffect(() => { + if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); + const cleanQuery = toEnglishDigits(searchQuery.trim()); + setSearchError(false); + + if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) { + searchTimeoutRef.current = setTimeout(async () => { + setIsSearching(true); + try { + const foundUser = await searchUserByExactMobile(cleanQuery); + if (foundUser && foundUser.id) { + if (foundUser.id === currentUserId) { + setSearchResult(null); + } else { + setSearchResult(foundUser); + setSearchError(false); + } + } else { + setSearchResult(null); + setSearchError(true); + } + } catch (error) { + setSearchResult(null); + setSearchError(true); + } finally { + setIsSearching(false); + } + }, 500); + } else { + setSearchResult(null); + } + return () => { + if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); + }; + }, [searchQuery, currentUserId]); + + const handleAddMember = (userToAdd: any) => { + if (members.some((m) => m.user.id === userToAdd.id)) return; + const newMember: LocalMember = { + localId: Math.random().toString(36).substr(2, 9), + user: userToAdd, + role: "member", + }; + setMembers((prev) => [newMember, ...prev]); + setSearchQuery(""); + setSearchResult(null); + }; + + const handleToggleAddAllMembers = async () => { + if (addAllMembers) { + setMembers((prev) => prev.filter(m => m.isCreator || m.role === "manager")); + setAddAllMembers(false); + } else { + if (!activeWorkspace?.id) return; + setIsAddingAll(true); + try { + let currentOffset = 0; + let continueFetching = true; + const allWsMembers: any[] = []; + + while (continueFetching) { + const res = await fetchWorkspaceMemberships({ + workspace: activeWorkspace.id, + limit: 50, + offset: currentOffset, + }); + const fetchedResults = res.results || (Array.isArray(res) ? res : []); + allWsMembers.push(...fetchedResults); + if (res.next) { + currentOffset += 50; + } else { + continueFetching = false; + } + } + + const newMembersToAdd = allWsMembers + .map((wm) => wm.user) + .filter((u) => u && u.id !== currentUserId && !members.some((m) => m.user.id === u.id)); + + const localMembers: LocalMember[] = newMembersToAdd.map((u) => ({ + localId: Math.random().toString(36).substr(2, 9), + user: u, + role: "member", + })); + + setMembers((prev) => [...prev, ...localMembers]); + setAddAllMembers(true); + } catch (error) { + toast.error("Could not add all workspace members."); + } finally { + setIsAddingAll(false); + } + } + }; + + const openDeleteModal = (userId: string) => { + setMemberIdToDelete(userId); + setIsDeleteDialogOpen(true); + }; + + const handleDeleteMember = () => { + if (!memberIdToDelete) return; + setMembers(members.filter((m) => m.user.id !== memberIdToDelete)); + setIsDeleteDialogOpen(false); + setMemberIdToDelete(null); + }; + + const handleChangeRole = (userId: string, newRole: string) => { + setMembers( + members.map((m) => (m.user.id === userId ? { ...m, role: newRole as ProjectRole } : m)) + ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !activeWorkspace) return; + + try { + setIsSaving(true); + const membersPayload = members + .filter((m) => !m.isCreator) + .map((m) => ({ user_id: m.user.id, role: m.role })); + + const projectPayload: any = { + name, + description, + color, + workspace: activeWorkspace.id, + members: membersPayload, + }; + if (client) projectPayload.client = client; + + const newProject = await createProject(projectPayload); + + window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); + toast.success(t.projects?.createSuccess || "Project created successfully."); + navigate("/projects"); + } catch (error: any) { + toast.error(error.message || t.projects?.createError || "Failed to create project."); + } finally { + setIsSaving(false); + } + }; + + // Prepare unified display list + const filteredWorkspaceMembers = workspaceMembers.filter((m) => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return true; + const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase(); + const phone = toEnglishDigits(m.user.mobile || ""); + return fullName.includes(q) || phone.includes(toEnglishDigits(q)); + }); + + const workspaceMemberUserIds = new Set(filteredWorkspaceMembers.map((m) => m.user.id)); + + const externalAddedMembers = members.filter((m) => { + if (workspaceMemberUserIds.has(m.user.id)) return false; + const q = searchQuery.trim().toLowerCase(); + if (!q) return true; + const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase(); + const phone = toEnglishDigits(m.user.mobile || ""); + return fullName.includes(q) || phone.includes(toEnglishDigits(q)); + }); + + const displayList = [ + ...externalAddedMembers.map((m) => ({ listId: m.localId, user: m.user })), + ...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user })) + ]; + + if (!activeWorkspace) { + return null; + } + + return ( +
+

+ {t.projects?.createNew || "Create New Project"} +

+ +
+
+
+
+
+
+
+ setName(e.target.value)} + placeholder={t.projects?.namePlaceholder || "Project name..."} + required + /> +
+
+ {COLORS.map((c) => ( +
+
+ +
+ + setSearchQuery(e.target.value)} + placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."} + className="ps-10" + /> + {isSearching && ( + + )} +
+ + {searchError && ( +

+ {t.projects?.userNotFound || "No user found with this mobile number."} +

+ )} + + {searchResult && !searchError && ( +
+
+ {searchResult.profile_picture ? ( + {searchResult.first_name} + ) : ( +
+ {searchResult.first_name?.[0] || "U"} +
+ )} +
+ + {searchResult.first_name} {searchResult.last_name} + + + {searchResult.mobile} + +
+
+ +
+ )} +
+ +
+ {isLoadingData ? ( +
+ + {t.loading || "Loading..."} +
+ ) : ( + + {displayList.length === 0 ? ( +
+ {t.projects?.noWorkspaceMembers || "No members found."} +
+ ) : ( +
    + {displayList.map((item) => { + const addedMemberData = members.find((mm) => mm.user.id === item.user.id); + const isAdded = !!addedMemberData; + + return ( +
  • +
    + {item.user.profile_picture ? ( + {item.user.first_name} + ) : ( +
    + {item.user.first_name?.[0] || "U"} +
    + )} +
    + + {item.user.first_name} {item.user.last_name} + {addedMemberData?.isCreator && ( + + {t.projects?.creator || "Creator"} + + )} + + + {item.user.mobile} + +
    +
    + +
    + {isAdded ? ( +
    + {!addedMemberData.isCreator && ( + setName(e.target.value)} + placeholder={t.projects?.namePlaceholder || "Project name..."} + required + /> +
    +
    + {COLORS.map((c) => ( +
    +
    + +
    + + setSearchQuery(e.target.value)} + placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."} + className="ps-10" + /> + {isSearching && ( + + )} +
    + + {searchError && ( +

    + {t.projects?.userNotFound || "No user found with this mobile number."} +

    + )} + + {searchResult && !searchError && ( +
    +
    + {searchResult.profile_picture ? ( + {searchResult.first_name} + ) : ( +
    + {searchResult.first_name?.[0] || "U"} +
    + )} +
    + + {searchResult.first_name} {searchResult.last_name} + + + {searchResult.mobile} + +
    +
    + +
    + )} +
+ +
+ {isLoadingData ? ( +
+ + {t.loading || "Loading..."} +
+ ) : ( + + {displayList.length === 0 ? ( +
+ {t.projects?.noWorkspaceMembers || "No members found."} +
+ ) : ( +
    + {displayList.map((item) => { + const addedMemberData = members.find((mm) => mm.user.id === item.user.id); + const isAdded = !!addedMemberData; + + return ( +
  • +
    + {item.user.profile_picture ? ( + {item.user.first_name} + ) : ( +
    + {item.user.first_name?.[0] || "U"} +
    + )} +
    + + {item.user.first_name} {item.user.last_name} + {addedMemberData?.isCreator && ( + + {t.projects?.creator || "Creator"} + + )} + + + {item.user.mobile} + +
    +
    + +
    + {isAdded ? ( +
    +