feat(projects): add project create edit and membership flows
This commit is contained in:
@@ -5,6 +5,26 @@ export interface ProjectClient {
|
|||||||
name: string;
|
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 {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,10 +33,11 @@ export interface Project {
|
|||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
client: ProjectClient | null;
|
client: ProjectClient | null;
|
||||||
|
my_role?: string;
|
||||||
|
members?: ProjectMembership[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectPayload {
|
export interface ProjectPayload {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -44,7 +65,23 @@ export const getProjects = async (
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createProject = async (data: Partial<ProjectPayload> & { 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<ProjectPayload> & {
|
||||||
|
workspace: string;
|
||||||
|
name: string;
|
||||||
|
members?: ProjectMemberPayload[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
const response = await authFetch("/api/projects/", {
|
const response = await authFetch("/api/projects/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -57,7 +94,10 @@ export const createProject = async (data: Partial<ProjectPayload> & { workspace:
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (id: string, data: Partial<ProjectPayload>) => {
|
export const updateProject = async (
|
||||||
|
id: string,
|
||||||
|
data: Partial<ProjectPayload> & { members?: ProjectMemberPayload[] }
|
||||||
|
) => {
|
||||||
const response = await authFetch(`/api/projects/${id}/`, {
|
const response = await authFetch(`/api/projects/${id}/`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -95,3 +135,49 @@ export const toggleArchiveProject = async (id: string) => {
|
|||||||
}
|
}
|
||||||
return response.json();
|
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 }));
|
||||||
|
};
|
||||||
|
|||||||
633
src/pages/ProjectCreate.tsx
Normal file
633
src/pages/ProjectCreate.tsx
Normal file
@@ -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<any[]>([]);
|
||||||
|
|
||||||
|
// Workspace List & Pagination States
|
||||||
|
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]);
|
||||||
|
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<LocalMember[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [addAllMembers, setAddAllMembers] = useState(false);
|
||||||
|
const [isAddingAll, setIsAddingAll] = useState(false);
|
||||||
|
|
||||||
|
// External Search States
|
||||||
|
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState(false);
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(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 (
|
||||||
|
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
|
||||||
|
{t.projects?.createNew || "Create New Project"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||||
|
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
|
||||||
|
<form id="create-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
|
||||||
|
color === c
|
||||||
|
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
|
||||||
|
: "hover:scale-110 shadow-sm"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
aria-label={`Select color ${c}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{t.projects?.client || "Client"}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={client}
|
||||||
|
onChange={setClient}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||||
|
...clientsList.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
{t.projects?.descriptionLabel || "Description (Optional)"}
|
||||||
|
</label>
|
||||||
|
<TextAreaInput
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
|
||||||
|
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||||
|
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t.create || "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<Users size={18} />
|
||||||
|
{t.projects?.projectMembers || "Project Members"}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={addAllMembers ? "destructive" : "outline"}
|
||||||
|
disabled={isAddingAll || isLoadingData}
|
||||||
|
onClick={handleToggleAddAllMembers}
|
||||||
|
>
|
||||||
|
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{addAllMembers
|
||||||
|
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
|
||||||
|
: (t.projects?.addAllWorkspaceMembers || "Add All")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
|
||||||
|
className="ps-10"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
|
||||||
|
{t.projects?.userNotFound || "No user found with this mobile number."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResult && !searchError && (
|
||||||
|
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{searchResult.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={searchResult.profile_picture}
|
||||||
|
alt={searchResult.first_name}
|
||||||
|
className="w-10 h-10 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{searchResult.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{searchResult.first_name} {searchResult.last_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{searchResult.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={members.some((m) => m.user.id === searchResult.id)}
|
||||||
|
onClick={() => handleAddMember(searchResult)}
|
||||||
|
>
|
||||||
|
{members.some((m) => m.user.id === searchResult.id)
|
||||||
|
? (t.projects?.alreadyInProject || "Already Added")
|
||||||
|
: (t.projects?.addToProject || "Add to Project")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoadingData ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
{t.loading || "Loading..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InfiniteScroll
|
||||||
|
onLoadMore={loadMoreMembers}
|
||||||
|
hasMore={hasMore && searchQuery.trim().length === 0}
|
||||||
|
isLoading={isLoadingMore}
|
||||||
|
>
|
||||||
|
{displayList.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 text-center">
|
||||||
|
{t.projects?.noWorkspaceMembers || "No members found."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
|
{displayList.map((item) => {
|
||||||
|
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
|
||||||
|
const isAdded = !!addedMemberData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{item.user.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={item.user.profile_picture}
|
||||||
|
alt={item.user.first_name}
|
||||||
|
className="w-9 h-9 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{item.user.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
{item.user.first_name} {item.user.last_name}
|
||||||
|
{addedMemberData?.isCreator && (
|
||||||
|
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
|
||||||
|
{t.projects?.creator || "Creator"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{item.user.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isAdded ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!addedMemberData.isCreator && (
|
||||||
|
<Select
|
||||||
|
value={addedMemberData.role}
|
||||||
|
onChange={(val) => handleChangeRole(item.user.id, val)}
|
||||||
|
options={[
|
||||||
|
{ value: "member", label: t.projects?.roles?.member || "Member" },
|
||||||
|
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
|
||||||
|
]}
|
||||||
|
buttonClassName="text-xs h-8 w-28"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!addedMemberData.isCreator && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
||||||
|
onClick={() => openDeleteModal(item.user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleAddMember(item.user)}
|
||||||
|
>
|
||||||
|
{t.projects?.addToProject || "Add to Project"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteDialogOpen}
|
||||||
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
|
title={t.projects?.confirmDeleteTitle || "Remove Member"}
|
||||||
|
description={
|
||||||
|
t.projects?.confirmDeleteDesc || "Are you sure you want to remove this member from the project?"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteMember}>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
619
src/pages/ProjectEdit.tsx
Normal file
619
src/pages/ProjectEdit.tsx
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate, useParams, useBlocker } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { getProject, updateProject } 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 ProjectEdit() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user } = useAppContext();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const currentUserId = user?.id || "";
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [client, setClient] = useState("");
|
||||||
|
const [clientsList, setClientsList] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<LocalMember[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [addAllMembers, setAddAllMembers] = useState(false);
|
||||||
|
const [isAddingAll, setIsAddingAll] = useState(false);
|
||||||
|
|
||||||
|
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState(false);
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = name.trim() !== "";
|
||||||
|
|
||||||
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
|
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||||
|
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspace?.id && id) {
|
||||||
|
const loadInitialData = async () => {
|
||||||
|
try {
|
||||||
|
const clientsRes = await getClients(activeWorkspace.id);
|
||||||
|
setClientsList(clientsRes.results || []);
|
||||||
|
|
||||||
|
const projectRes = await getProject(id);
|
||||||
|
setName(projectRes.name || "");
|
||||||
|
setDescription(projectRes.description || "");
|
||||||
|
setColor(projectRes.color || COLORS[0]);
|
||||||
|
setClient(projectRes.client?.id || projectRes.client || "");
|
||||||
|
|
||||||
|
if (projectRes.members) {
|
||||||
|
const mappedMembers = projectRes.members.map((m: any) => ({
|
||||||
|
localId: m.id,
|
||||||
|
user: {
|
||||||
|
id: m.user_details?.id || m.user,
|
||||||
|
first_name: m.user_details?.first_name || "",
|
||||||
|
last_name: m.user_details?.last_name || "",
|
||||||
|
mobile: m.user_details?.phone_number || "",
|
||||||
|
profile_picture: m.user_details?.avatar || "",
|
||||||
|
},
|
||||||
|
role: m.role as ProjectRole,
|
||||||
|
isCreator: m.user === currentUserId && m.role === "manager",
|
||||||
|
}));
|
||||||
|
setMembers(mappedMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
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) {
|
||||||
|
toast.error("Failed to load project data.");
|
||||||
|
navigate("/projects");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
setIsProjectLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitialData();
|
||||||
|
}
|
||||||
|
}, [activeWorkspace?.id, id, currentUserId, navigate]);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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 && !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 || !id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const membersPayload = members.map((m) => ({ user_id: m.user.id, role: m.role }));
|
||||||
|
|
||||||
|
const projectPayload: any = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
members: membersPayload,
|
||||||
|
client: client || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedProject = await updateProject(id, projectPayload);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
|
||||||
|
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||||
|
navigate("/projects");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || t.projects?.updateError || "Failed to update project.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (isProjectLoading) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
|
||||||
|
{t.projects?.edit || "Edit Project"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||||
|
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
|
||||||
|
<form id="edit-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
|
||||||
|
color === c
|
||||||
|
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
|
||||||
|
: "hover:scale-110 shadow-sm"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
aria-label={`Select color ${c}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{t.projects?.client || "Client"}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={client}
|
||||||
|
onChange={setClient}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||||
|
...clientsList.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
{t.projects?.descriptionLabel || "Description (Optional)"}
|
||||||
|
</label>
|
||||||
|
<TextAreaInput
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
|
||||||
|
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||||
|
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t.save || "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<Users size={18} />
|
||||||
|
{t.projects?.projectMembers || "Project Members"}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={addAllMembers ? "destructive" : "outline"}
|
||||||
|
disabled={isAddingAll || isLoadingData}
|
||||||
|
onClick={handleToggleAddAllMembers}
|
||||||
|
>
|
||||||
|
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{addAllMembers
|
||||||
|
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
|
||||||
|
: (t.projects?.addAllWorkspaceMembers || "Add All")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
|
||||||
|
className="ps-10"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
|
||||||
|
{t.projects?.userNotFound || "No user found with this mobile number."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResult && !searchError && (
|
||||||
|
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{searchResult.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={searchResult.profile_picture}
|
||||||
|
alt={searchResult.first_name}
|
||||||
|
className="w-10 h-10 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{searchResult.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{searchResult.first_name} {searchResult.last_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{searchResult.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={members.some((m) => m.user.id === searchResult.id)}
|
||||||
|
onClick={() => handleAddMember(searchResult)}
|
||||||
|
>
|
||||||
|
{members.some((m) => m.user.id === searchResult.id)
|
||||||
|
? (t.projects?.alreadyInProject || "Already Added")
|
||||||
|
: (t.projects?.addToProject || "Add to Project")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoadingData ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
{t.loading || "Loading..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InfiniteScroll
|
||||||
|
onLoadMore={loadMoreMembers}
|
||||||
|
hasMore={hasMore && searchQuery.trim().length === 0}
|
||||||
|
isLoading={isLoadingMore}
|
||||||
|
>
|
||||||
|
{displayList.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 text-center">
|
||||||
|
{t.projects?.noWorkspaceMembers || "No members found."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
|
{displayList.map((item) => {
|
||||||
|
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
|
||||||
|
const isAdded = !!addedMemberData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{item.user.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={item.user.profile_picture}
|
||||||
|
alt={item.user.first_name}
|
||||||
|
className="w-9 h-9 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{item.user.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
{item.user.first_name} {item.user.last_name}
|
||||||
|
{addedMemberData?.isCreator && (
|
||||||
|
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
|
||||||
|
{t.projects?.creator || "Creator"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{item.user.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isAdded ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={addedMemberData.role}
|
||||||
|
onChange={(val) => handleChangeRole(item.user.id, val)}
|
||||||
|
options={[
|
||||||
|
{ value: "member", label: t.projects?.roles?.member || "Member" },
|
||||||
|
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
|
||||||
|
]}
|
||||||
|
buttonClassName="text-xs h-8 w-28"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
||||||
|
onClick={() => openDeleteModal(item.user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleAddMember(item.user)}
|
||||||
|
>
|
||||||
|
{t.projects?.addToProject || "Add to Project"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteDialogOpen}
|
||||||
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
|
title={t.projects?.confirmDeleteTitle || "Remove Member"}
|
||||||
|
description={
|
||||||
|
t.projects?.confirmDeleteDesc || "Are you sure you want to remove this member from the project?"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteMember}>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user