refactor(projects): remove project member management ui

This commit is contained in:
2026-04-28 19:35:23 +03:30
parent 8bd0e908a1
commit 3efa04094d
9 changed files with 361 additions and 1361 deletions

View File

@@ -5,7 +5,6 @@ export type WorkspaceLogSection =
| "workspace_members" | "workspace_members"
| "clients" | "clients"
| "projects" | "projects"
| "project_members"
| "tags" | "tags"
| "time_entries" | "time_entries"
| "rates" | "rates"

View File

@@ -12,26 +12,6 @@ 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;
@@ -42,8 +22,6 @@ export interface Project {
workspace: string; workspace: string;
created_by?: AuditUser | null; created_by?: AuditUser | null;
client: ProjectClient | null; client: ProjectClient | null;
my_role?: string;
members?: ProjectMembership[];
} }
export interface ProjectPayload { export interface ProjectPayload {
@@ -84,13 +62,9 @@ export const getProject = async (id: string) => {
return response.json(); return response.json();
}; };
export const createProject = async ( export const createProject = async (
data: Partial<ProjectPayload> & { data: Partial<ProjectPayload> & { workspace: string; name: string }
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),
@@ -103,10 +77,10 @@ export const createProject = async (
return response.json(); return response.json();
}; };
export const updateProject = async ( export const updateProject = async (
id: string, id: string,
data: Partial<ProjectPayload> & { members?: ProjectMemberPayload[] } data: Partial<ProjectPayload>
) => { ) => {
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),
@@ -143,50 +117,4 @@ export const toggleArchiveProject = async (id: string) => {
throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`); throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`);
} }
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 }));
};

View File

@@ -21,7 +21,6 @@ const sectionBadgeStyles: Record<string, string> = {
workspace_members: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300", workspace_members: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
clients: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300", clients: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
projects: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", projects: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
project_members: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
tags: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", tags: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
time_entries: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", time_entries: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
rates: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", rates: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",

View File

@@ -78,7 +78,6 @@ export function LogsFilterBar({
{ value: "workspace_members", label: t.logs?.sections?.workspace_members || "Workspace members" }, { value: "workspace_members", label: t.logs?.sections?.workspace_members || "Workspace members" },
{ value: "clients", label: t.logs?.sections?.clients || "Clients" }, { value: "clients", label: t.logs?.sections?.clients || "Clients" },
{ value: "projects", label: t.logs?.sections?.projects || "Projects" }, { value: "projects", label: t.logs?.sections?.projects || "Projects" },
{ value: "project_members", label: t.logs?.sections?.project_members || "Project members" },
{ value: "tags", label: t.logs?.sections?.tags || "Tags" }, { value: "tags", label: t.logs?.sections?.tags || "Tags" },
{ value: "time_entries", label: t.logs?.sections?.time_entries || "Time entries" }, { value: "time_entries", label: t.logs?.sections?.time_entries || "Time entries" },
{ value: "rates", label: t.logs?.sections?.rates || "Rates" }, { value: "rates", label: t.logs?.sections?.rates || "Rates" },

View File

@@ -1,5 +1,4 @@
export type WorkspaceRole = "owner" | "admin" | "member" | "guest"; export type WorkspaceRole = "owner" | "admin" | "member" | "guest";
export type ProjectRole = "manager" | "member" | string;
export const WORKSPACE_VIEW = "workspace.view"; export const WORKSPACE_VIEW = "workspace.view";
export const WORKSPACE_EDIT = "workspace.edit"; export const WORKSPACE_EDIT = "workspace.edit";
@@ -22,10 +21,6 @@ export const PROJECTS_CREATE = "projects.create";
export const PROJECTS_EDIT = "projects.edit"; export const PROJECTS_EDIT = "projects.edit";
export const PROJECTS_DELETE = "projects.delete"; export const PROJECTS_DELETE = "projects.delete";
export const PROJECTS_ARCHIVE = "projects.archive"; export const PROJECTS_ARCHIVE = "projects.archive";
export const PROJECT_MEMBERS_VIEW = "project_members.view";
export const PROJECT_MEMBERS_ADD = "project_members.add";
export const PROJECT_MEMBERS_REMOVE = "project_members.remove";
export const PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role";
export const TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"; export const TIME_ENTRIES_VIEW_OWN = "time_entries.view_own";
export const TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"; export const TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own";
@@ -51,10 +46,6 @@ export type WorkspaceCapability =
| typeof PROJECTS_EDIT | typeof PROJECTS_EDIT
| typeof PROJECTS_DELETE | typeof PROJECTS_DELETE
| typeof PROJECTS_ARCHIVE | typeof PROJECTS_ARCHIVE
| typeof PROJECT_MEMBERS_VIEW
| typeof PROJECT_MEMBERS_ADD
| typeof PROJECT_MEMBERS_REMOVE
| typeof PROJECT_MEMBERS_CHANGE_ROLE
| typeof TIME_ENTRIES_VIEW_OWN | typeof TIME_ENTRIES_VIEW_OWN
| typeof TIME_ENTRIES_MANAGE_OWN; | typeof TIME_ENTRIES_MANAGE_OWN;
@@ -81,10 +72,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN, TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN, TIME_ENTRIES_MANAGE_OWN,
]), ]),
@@ -109,10 +96,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN, TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN, TIME_ENTRIES_MANAGE_OWN,
]), ]),
@@ -134,15 +117,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
]), ]),
}; };
const PROJECT_MANAGER_CAPABILITIES = new Set<WorkspaceCapability>([
PROJECTS_EDIT,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
]);
export const canWorkspace = ( export const canWorkspace = (
role: WorkspaceRole | null | undefined, role: WorkspaceRole | null | undefined,
capability: WorkspaceCapability, capability: WorkspaceCapability,
@@ -151,22 +125,6 @@ export const canWorkspace = (
return CAPABILITIES_BY_ROLE[role]?.has(capability) ?? false; return CAPABILITIES_BY_ROLE[role]?.has(capability) ?? false;
}; };
export const canProject = ({
workspaceRole,
projectRole,
capability,
}: {
workspaceRole: WorkspaceRole | null | undefined;
projectRole?: ProjectRole | null;
capability: WorkspaceCapability;
}) => {
if (canWorkspace(workspaceRole, capability)) return true;
if (workspaceRole === "member" || workspaceRole === "guest" || !workspaceRole) {
return false;
}
return projectRole === "manager" && PROJECT_MANAGER_CAPABILITIES.has(capability);
};
export const canDeleteWorkspaceResource = ({ export const canDeleteWorkspaceResource = ({
workspaceRole, workspaceRole,
currentUserId, currentUserId,

View File

@@ -543,7 +543,6 @@ export const en = {
workspace_members: "Workspace members", workspace_members: "Workspace members",
clients: "Clients", clients: "Clients",
projects: "Projects", projects: "Projects",
project_members: "Project members",
tags: "Tags", tags: "Tags",
time_entries: "Time entries", time_entries: "Time entries",
rates: "Rates", rates: "Rates",

View File

@@ -538,7 +538,6 @@ export const fa = {
workspace_members: "اعضای ورک‌اسپیس", workspace_members: "اعضای ورک‌اسپیس",
clients: "مشتری‌ها", clients: "مشتری‌ها",
projects: "پروژه‌ها", projects: "پروژه‌ها",
project_members: "اعضای پروژه",
tags: "تگ‌ها", tags: "تگ‌ها",
time_entries: "ورودی‌های زمان", time_entries: "ورودی‌های زمان",
rates: "نرخ‌ها", rates: "نرخ‌ها",

View File

@@ -1,97 +1,44 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useBlocker } from "react-router-dom"; import { useBlocker, useNavigate } from "react-router-dom";
import { import { Briefcase, Loader2 } from "lucide-react";
Users, import { toast } from "sonner";
Briefcase,
Trash2,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { createProject } from "../api/projects";
import { getClients } from "../api/clients"; import { getClients } from "../api/clients";
import { fetchWorkspaceMemberships } from "../api/workspaces"; import { createProject } from "../api/projects";
import { searchUserByExactMobile, type SearchedUser } from "../api/users"; import { Button } from "../components/ui/button";
import { useAppContext } from "../context/AppContext"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions"; import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; const COLORS = [
import { Select } from "../components/ui/Select"; "#3B82F6",
import { TextAreaInput } from "../components/ui/TextAreaInput"; "#10B981",
import { InfiniteScroll } from "../components/InfiniteScroll"; "#F59E0B",
import { Modal } from "../components/Modal"; "#EF4444",
"#8B5CF6",
type ProjectRole = "manager" | "member"; "#EC4899",
"#14B8A6",
interface LocalMember { "#64748B",
localId: string; ];
user: any;
role: ProjectRole; export default function ProjectCreate() {
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 navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || "";
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE); const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
// Project Detail States const [name, setName] = useState("");
const [name, setName] = useState(""); const [description, setDescription] = useState("");
const [description, setDescription] = useState(""); const [color, setColor] = useState(COLORS[0]);
const [color, setColor] = useState(COLORS[0]); const [client, setClient] = useState("");
const [client, setClient] = useState(""); const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [clientsList, setClientsList] = useState<any[]>([]); const [isLoadingData, setIsLoadingData] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// Workspace List & Pagination States
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]); const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || client !== "" || color !== COLORS[0];
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;
useEffect(() => { useEffect(() => {
if (activeWorkspace && !canCreateProject) { if (activeWorkspace && !canCreateProject) {
@@ -99,544 +46,144 @@ export default function ProjectCreate() {
navigate("/projects"); navigate("/projects");
} }
}, [activeWorkspace, canCreateProject, navigate]); }, [activeWorkspace, canCreateProject, navigate]);
useBlocker(({ currentLocation, nextLocation }) => { useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) { if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?"); return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
} }
return false; return false;
}); });
// EXACT same pagination structure as EditWorkspace.tsx useEffect(() => {
useEffect(() => { if (!activeWorkspace?.id) return;
if (activeWorkspace?.id) {
const workspaceId = activeWorkspace.id; setName("");
setDescription("");
setName(""); setColor(COLORS[0]);
setDescription(""); setClient("");
setColor(COLORS[0]); setClientsList([]);
setClient(""); setIsLoadingData(true);
setClientsList([]);
setWorkspaceMembers([]); const loadInitialData = async () => {
setSearchQuery(""); try {
setSearchResult(null); const clientsRes = await getClients(activeWorkspace.id);
setSearchError(false); setClientsList(clientsRes.results || []);
setAddAllMembers(false); } catch {
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
// Reset pagination state } finally {
setOffset(0); setIsLoadingData(false);
setHasMore(true); }
setIsLoadingData(true); };
if (user?.id) { void loadInitialData();
setMembers([{ localId: user.id, user: user, role: "manager", isCreator: true }]); }, [activeWorkspace?.id, t.projects?.clientFetchError]);
} else {
setMembers([]); const handleSubmit = async (e: React.FormEvent) => {
} e.preventDefault();
if (!name.trim() || !activeWorkspace) return;
const loadInitialData = async () => {
try { try {
const clientsRes = await getClients(workspaceId); setIsSaving(true);
setClientsList(clientsRes.results || []); const newProject = await createProject({
workspace: activeWorkspace.id,
const res = await fetchWorkspaceMemberships({ name,
workspace: workspaceId, description,
limit: LIMIT, color,
offset: 0, client: client || null,
}); is_archived: false,
const results = res.results || (Array.isArray(res) ? res : []); });
setWorkspaceMembers(results); window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
setOffset(LIMIT); toast.success(t.projects?.createSuccess || "Project created successfully.");
setHasMore(res.next ? true : results.length >= LIMIT); navigate("/projects");
} catch (err) { } catch (error: any) {
console.error("Failed to fetch initial data", err); toast.error(error.message || t.projects?.createError || "Failed to create project.");
toast.error("Failed to load initial data."); } finally {
} finally { setIsSaving(false);
setIsLoadingData(false); }
} };
};
if (!activeWorkspace) return null;
loadInitialData();
} return (
}, [activeWorkspace?.id, user?.id]); <div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<div className="mx-auto max-w-3xl">
// EXACT same LoadMore logic and deduplication as EditWorkspace.tsx <h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
const loadMoreMembers = useCallback(async () => { {t.projects?.createNew || "Create New Project"}
if (isLoadingMore || !hasMore || !activeWorkspace?.id) return; </h1>
try {
setIsLoadingMore(true); <div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<form onSubmit={handleSubmit} className="space-y-6 p-6">
const res = await fetchWorkspaceMemberships({ <div className="flex flex-col gap-3">
workspace: activeWorkspace.id, <div className="flex items-center gap-4">
limit: LIMIT, <div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
offset: offset <Input
}); value={name}
const results = res.results || (Array.isArray(res) ? res : []); onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
setWorkspaceMembers((prev) => { required
// Safe deduplication to avoid React key warnings breaking the DOM observer />
const existingIds = new Set(prev.map(m => m.id)); </div>
const newItems = results.filter((item: any) => !existingIds.has(item.id)); <div className="mt-2 flex flex-wrap gap-2">
return [...prev, ...newItems]; {COLORS.map((paletteColor) => (
}); <button
key={paletteColor}
setOffset(prev => prev + LIMIT); type="button"
setHasMore(res.next ? true : results.length >= LIMIT); onClick={() => setColor(paletteColor)}
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
} catch (error) { color === paletteColor
console.error("Failed to load more members", error); ? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
} finally { : "shadow-sm hover:scale-110"
setIsLoadingMore(false); }`}
} style={{ backgroundColor: paletteColor }}
}, [activeWorkspace?.id, isLoadingMore, hasMore, offset]); aria-label={`Select color ${paletteColor}`}
/>
// Unified Search Logic ))}
useEffect(() => { </div>
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); </div>
const cleanQuery = toEnglishDigits(searchQuery.trim());
setSearchError(false); <div>
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) { <Briefcase size={16} />
searchTimeoutRef.current = setTimeout(async () => { {t.projects?.client || "Client"}
setIsSearching(true); </label>
try { <Select
const foundUser = await searchUserByExactMobile(cleanQuery); value={client}
if (foundUser && foundUser.id) { onChange={setClient}
if (foundUser.id === currentUserId) { options={[
setSearchResult(null); { value: "", label: t.projects?.noClient || "No Client" },
} else { ...clientsList.map((item) => ({ value: item.id, label: item.name })),
setSearchResult(foundUser); ]}
setSearchError(false); isLoading={isLoadingData}
} className="w-full"
} else { buttonClassName="w-full"
setSearchResult(null); />
setSearchError(true); </div>
}
} catch (error) { <div>
setSearchResult(null); <label className="mb-2 block text-slate-700 dark:text-slate-300">
setSearchError(true); {t.projects?.descriptionLabel || "Description"}
} finally { </label>
setIsSearching(false); <TextAreaInput
} value={description}
}, 500); onChange={(e) => setDescription(e.target.value)}
} else { placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
setSearchResult(null); rows={5}
} />
return () => { </div>
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
}; <div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
}, [searchQuery, currentUserId]); <Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"}
const handleAddMember = (userToAdd: any) => { </Button>
if (members.some((m) => m.user.id === userToAdd.id)) return; <Button type="submit" disabled={isSaving || !name.trim()}>
const newMember: LocalMember = { {isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
localId: Math.random().toString(36).substr(2, 9), {t.create || "Create"}
user: userToAdd, </Button>
role: "member", </div>
}; </form>
setMembers((prev) => [newMember, ...prev]); </div>
setSearchQuery(""); </div>
setSearchResult(null); </div>
}; );
}
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>
);
}

View File

@@ -1,94 +1,45 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams, useBlocker } from "react-router-dom"; import { useBlocker, useNavigate, useParams } from "react-router-dom";
import { import { Briefcase, Loader2 } from "lucide-react";
Users, import { toast } from "sonner";
Briefcase,
Trash2,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { getProject, updateProject } from "../api/projects";
import { getClients } from "../api/clients"; import { getClients } from "../api/clients";
import { fetchWorkspaceMemberships } from "../api/workspaces"; import { getProject, updateProject } from "../api/projects";
import { searchUserByExactMobile, type SearchedUser } from "../api/users"; import { Button } from "../components/ui/button";
import { useAppContext } from "../context/AppContext"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions"; import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; const COLORS = [
import { Select } from "../components/ui/Select"; "#3B82F6",
import { TextAreaInput } from "../components/ui/TextAreaInput"; "#10B981",
import { InfiniteScroll } from "../components/InfiniteScroll"; "#F59E0B",
import { Modal } from "../components/Modal"; "#EF4444",
"#8B5CF6",
type ProjectRole = "manager" | "member"; "#EC4899",
"#14B8A6",
interface LocalMember { "#64748B",
localId: string; ];
user: any;
role: ProjectRole; export default function ProjectEdit() {
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 navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || "";
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT); const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]); const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState(""); const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<any[]>([]); const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]); const [isProjectLoading, setIsProjectLoading] = useState(true);
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 [isSaving, setIsSaving] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const hasUnsavedChanges = name.trim() !== ""; const hasUnsavedChanges = name.trim() !== "";
useEffect(() => { useEffect(() => {
@@ -97,532 +48,153 @@ export default function ProjectEdit() {
navigate("/projects"); navigate("/projects");
} }
}, [activeWorkspace, canEditProject, navigate]); }, [activeWorkspace, canEditProject, navigate]);
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>
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) return;
const loadInitialData = async () => {
try {
const [clientsRes, projectRes] = await Promise.all([
getClients(activeWorkspace.id),
getProject(id),
]);
setClientsList(clientsRes.results || []);
setName(projectRes.name || "");
setDescription(projectRes.description || "");
setColor(projectRes.color || COLORS[0]);
setClient(projectRes.client?.id || projectRes.client || "");
} catch {
toast.error("Failed to load project data.");
navigate("/projects");
} finally {
setIsLoadingData(false);
setIsProjectLoading(false);
}
};
void loadInitialData();
}, [activeWorkspace?.id, id, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace || !id) return;
try {
setIsSaving(true);
const updatedProject = await updateProject(id, {
name,
description,
color,
client: client || null,
});
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);
}
};
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 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
{t.projects?.edit || "Edit Project"}
</h1>
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<form onSubmit={handleSubmit} className="space-y-6 p-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{COLORS.map((paletteColor) => (
<button
key={paletteColor}
type="button"
onClick={() => setColor(paletteColor)}
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
color === paletteColor
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
: "shadow-sm hover:scale-110"
}`}
style={{ backgroundColor: paletteColor }}
aria-label={`Select color ${paletteColor}`}
/>
))}
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((item) => ({ value: item.id, label: item.name })),
]}
isLoading={isLoadingData}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="mb-2 block text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || "Description"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={5}
/>
</div>
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
<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" /> : null}
{t.save || "Save"}
</Button>
</div>
</form>
</div> </div>
</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>
);
}