From 56404792c6ead5fe0407c785042f767023b216e4 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 13 Mar 2026 10:30:27 +0800 Subject: [PATCH] feat(improvement): add pagination to endpoints and pages + sync navbar when data changes --- src/api/client.ts | 47 ++++--- src/api/clients.ts | 19 ++- src/api/workspaces.ts | 57 ++++++++- src/components/Navbar.tsx | 26 +++- src/components/WorkspaceSelector.tsx | 151 +++++++++++++++++++---- src/context/AppContext.tsx | 20 +-- src/context/WorkspaceContext.tsx | 8 +- src/locales/en.ts | 15 ++- src/locales/fa.ts | 27 ++-- src/pages/Clients.tsx | 38 +++++- src/pages/Profile.tsx | 5 + src/pages/WorkspaceCreate.tsx | 6 +- src/pages/WorkspaceEdit.tsx | 157 +++++++++++++++--------- src/pages/Workspaces.tsx | 177 +++++++++++++++++---------- 14 files changed, 543 insertions(+), 210 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index a22a2c0..7f21a66 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,30 +1,49 @@ -import { API_BASE_URL } from "../config/constants"; +import { API_BASE_URL } from "../config/constants" export const authFetch = async (endpoint: string, options: RequestInit = {}) => { - const token = localStorage.getItem("accessToken"); - const isFormData = options.body instanceof FormData; + const token = localStorage.getItem("accessToken") + const isFormData = options.body instanceof FormData const headers: HeadersInit = { ...(!isFormData && { "Content-Type": "application/json" }), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, - }; + } - // Safely join URLs preventing double slashes (e.g., "http://api.com//api/..." -> "http://api.com/api/...") - const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, ""); - const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; - const url = `${cleanBaseUrl}${cleanEndpoint}`; + const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") + const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}` + const url = `${cleanBaseUrl}${cleanEndpoint}` const response = await fetch(url, { ...options, headers, - }); + }) if (response.status === 401) { - localStorage.removeItem("accessToken"); - localStorage.removeItem("refreshToken"); - window.location.href = "/auth"; + localStorage.removeItem("accessToken") + localStorage.removeItem("refreshToken") + window.location.href = "/auth" + return response } - return response; -}; + const originalJson = response.json.bind(response) + response.json = async () => { + const data = await originalJson() + + if (data && typeof data === "object" && "items" in data && "pages_count" in data) { + return { + count: data.total_items || 0, + results: data.items || [], + _meta: { + pages_count: data.pages_count, + items_per_page: data.items_per_page, + current_page: data.current_page + } + } + } + + return data + } + + return response +} diff --git a/src/api/clients.ts b/src/api/clients.ts index fff6d68..480b2c5 100644 --- a/src/api/clients.ts +++ b/src/api/clients.ts @@ -1,9 +1,17 @@ import { authFetch } from "./client"; -import { type PaginatedClientList } from "../types/client"; - -export const getClients = async (workspaceId: string, search: string = "", ordering: string = "") => { - const queryParams = new URLSearchParams({ workspace: workspaceId }); +export const getClients = async ( + workspaceId: string, + search: string = "", + ordering: string = "", + limit: number = 10, + offset: number = 0 +) => { + const queryParams = new URLSearchParams({ + workspace: workspaceId, + limit: limit.toString(), + offset: offset.toString() + }); if (search) queryParams.append("search", search); if (ordering) queryParams.append("ordering", ordering); @@ -12,7 +20,7 @@ export const getClients = async (workspaceId: string, search: string = "", order if (!response.ok) { throw new Error("Failed to fetch clients"); } - return response.json(); + return response.json(); }; export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => { @@ -54,7 +62,6 @@ export const deleteClient = async (id: string) => { throw new Error(errorData?.detail || errorData?.message || "Failed to delete client"); } - // DELETE requests often return 204 No Content, which throws an error on .json() if (response.status === 204) { return { success: true }; } diff --git a/src/api/workspaces.ts b/src/api/workspaces.ts index 86b6a3d..0b3128e 100644 --- a/src/api/workspaces.ts +++ b/src/api/workspaces.ts @@ -1,4 +1,3 @@ -// src/api/workspaces.ts import { authFetch } from "./client"; export interface Workspace { @@ -10,7 +9,31 @@ export interface Workspace { [key: string]: any; } -export const fetchWorkspaces = async (params?: Record): Promise => { +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export interface WorkspaceMembership { + id: string; + workspace: string; + user: { + id: string; + email: string; + first_name?: string; + last_name?: string; + [key: string]: any; + }; + role: 'owner' | 'admin' | 'member' | 'guest'; + is_active: boolean; + joined_at?: string; + [key: string]: any; +} + + +export const fetchWorkspaces = async (params?: Record): Promise> => { const query = params ? new URLSearchParams(params).toString() : ''; const url = `/api/workspaces/${query ? `?${query}` : ''}`; const response = await authFetch(url); @@ -20,7 +43,17 @@ export const fetchWorkspaces = async (params?: Record): Promise< } const data = await response.json(); - return data.results || data; + + if (Array.isArray(data)) { + return { count: data.length, next: null, previous: null, results: data }; + } + + return { + count: data.count || data.results?.length || 0, + next: data.next || null, + previous: data.previous || null, + results: data.results || data + }; }; export const getWorkspace = async (id: string): Promise => { @@ -65,12 +98,24 @@ export const deleteWorkspace = async (id: string): Promise => { } }; -export const fetchWorkspaceMemberships = async (workspaceId: string) => { - const response = await authFetch(`/api/workspace-memberships/?workspace=${workspaceId}`); +export const fetchWorkspaceMemberships = async (params?: Record): Promise> => { + const queryParams = new URLSearchParams((params || {})); + const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`); + if (!response.ok) throw new Error("Failed to fetch workspace memberships"); const data = await response.json(); - return data.results || data; + + if (Array.isArray(data)) { + return { count: data.length, next: null, previous: null, results: data }; + } + + return { + count: data.count || data.results?.length || 0, + next: data.next || null, + previous: data.previous || null, + results: data.results || data + }; }; export const addWorkspaceMembership = async (data: { workspace: string; user: string; role: string }) => { diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8298725..39efda8 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -9,7 +9,7 @@ import { WorkspaceSelector } from "./WorkspaceSelector" import { toast } from "sonner" export function Navbar() { - const { t, lang, setLang } = useTranslation() + const { t, lang, setLanguage } = useTranslation() const navigate = useNavigate() const [showLogoutModal, setShowLogoutModal] = useState(false) const [isDropdownOpen, setIsDropdownOpen] = useState(false) @@ -23,6 +23,17 @@ export function Navbar() { return document.documentElement.classList.contains('dark'); }); + useEffect(() => { + const handleProfileUpdated = ((e: CustomEvent) => { + if (e.detail) { + setUser((prev: any) => prev ? { ...prev, ...e.detail } : e.detail); + } + }) as EventListener; + + window.addEventListener('profile_updated', handleProfileUpdated); + return () => window.removeEventListener('profile_updated', handleProfileUpdated); + }, []); + useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); @@ -83,8 +94,8 @@ export function Navbar() { const toggleLanguage = () => { const newLang = isFa ? 'en' : 'fa' - if (setLang) { - setLang(newLang) + if (setLanguage) { + setLanguage(newLang) } else { localStorage.setItem('language', newLang) window.location.reload() @@ -126,7 +137,14 @@ export function Navbar() { {isDropdownOpen && ( -
+
+
+

+ {user.first_name || user.last_name + ? `${user.first_name || ''} ${user.last_name || ''}`.trim() + : user.email} +

+
- {/* Dropdown Menu */} {isOpen && (
{ {t.workspace?.title || "Workspaces"}
-
- {workspaces.map((ws) => ( -
- {activeWorkspace?.id === ws.id && ( - - )} - - ))} + {activeWorkspace?.id === ws.id && ( + + )} + + ))} +
@@ -96,11 +194,10 @@ export const WorkspaceSelector: React.FC = () => { className="flex w-full items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" > - { t.workspace?.manage || "Manage Workspaces" } + {t.workspace?.manage || "Manage Workspaces"}
)} -
); }; diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 88f5a50..ab0b0a8 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; -import { api } from '../api'; +import { getUserProfile } from '../api/users'; +import { fetchWorkspaces } from '../api/workspaces'; interface User { id: string; @@ -30,23 +31,26 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { const fetchInitialData = async () => { try { - const [userRes, wsRes] = await Promise.all([ - api.get('/api/users/me/'), - api.get('/api/workspaces/') + const [userData, wsData] = await Promise.all([ + getUserProfile(), + fetchWorkspaces() // fetchWorkspaces({ limit: 50 }) ]); - setUser(userRes.data); - setWorkspaces(wsRes.data.results || wsRes.data); + setUser(userData); + + const workspacesList = Array.isArray(wsData.data) ? wsData.data : (wsData?.data?.results || []); + + setWorkspaces(workspacesList); const savedWsId = localStorage.getItem('active_workspace'); - const targetWs = wsRes.data.find((w: Workspace) => w.id === savedWsId) || wsRes.data[0]; + const targetWs = workspacesList.find((w: Workspace) => w.id === savedWsId) || workspacesList[0]; if (targetWs) { setActiveWorkspace(targetWs); localStorage.setItem('active_workspace', targetWs.id); } } catch (error) { - console.error(error); + console.error("Failed to fetch initial context data:", error); } }; diff --git a/src/context/WorkspaceContext.tsx b/src/context/WorkspaceContext.tsx index 1fc590f..165b79c 100644 --- a/src/context/WorkspaceContext.tsx +++ b/src/context/WorkspaceContext.tsx @@ -38,12 +38,14 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { const loadWorkspaces = async () => { try { - const data = await fetchWorkspaces() + const response = await fetchWorkspaces() + + const data = Array.isArray(response) ? response : (response?.results || []) setWorkspaces(data) if (data.length > 0) { const storedId = localStorage.getItem("activeWorkspaceId") - const stored = data.find((w) => w.id === storedId) + const stored = data.find((w: Workspace) => w.id === storedId) if (stored) { setActiveWorkspaceState(stored) } else { @@ -69,7 +71,7 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { const addWorkspace = async (name: string) => { try { setIsCreatingFirst(true) - const newWs = await createWorkspace(name) + const newWs = await createWorkspace({ name, description: "" }) setWorkspaces((prev) => [...prev, newWs]) setActiveWorkspace(newWs) toast.success(t.workspace?.createSuccess || "Workspace created!") diff --git a/src/locales/en.ts b/src/locales/en.ts index 9280cfa..f5b549a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -169,6 +169,7 @@ export const en = { successCreate: "Workspace created successfully.", errorCreate: "Failed to create workspace.", toast: { + successCreate: "Workspace created successfully.", successUpdate: "Workspace updated successfully.", errorUpdate: "Failed to update workspace.", successAdd: "Member added successfully.", @@ -208,7 +209,17 @@ export const en = { createFailed: "Failed to create client", fetchFailed: "Failed to fetch clients", updateFailed: "Failed to update client", - deleteFailed: "Failed to delete client" - } + deleteFailed: "Failed to delete client", + }, + }, + + pagination: { + perPage: "per page", + showing: "Showing", + to: "to", + of: "of", + previous: "Previous", + page: "Page", + next: "Next", }, } diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 3ca7850..e1734c2 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -17,7 +17,7 @@ export const fa = { otpPlaceholder: "کد ۶ رقمی", verifyAndContinue: "تایید و ادامه", terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.", - brandingQuote: "زمان و فضاهای کاری خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.", + brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.", toasts: { enterMobile: "لطفا شماره موبایل خود را وارد کنید", verifySent: "کد تایید ارسال شد!", @@ -105,9 +105,9 @@ export const fa = { darkMode: "حالت تاریک", workspace: { - title: "مدیریت فضاهای کاری", + title: "مدیریت ورک‌اسپیس‌ها", createNew: "ایجاد فضای کاری جدید", - manage: "مدیریت فضاهای کاری", + manage: "مدیریت ورک‌اسپیس‌ها", nameLabel: "نام فضای کاری", namePlaceholder: "نام فضای کاری را وارد کنید", descriptionLabel: "توضیحات", @@ -126,7 +126,7 @@ export const fa = { loading: "در حال بارگذاری...", confirmDelete: "آیا از حذف این فضای کاری اطمینان دارید؟", deleteError: "خطا در حذف فضای کاری", - subtitle: "فضاهای کاری خود را مدیریت کنید", + subtitle: "ورک‌اسپیس‌های خود را مدیریت کنید", noDescription: "بدون توضیحات", view: "مشاهده", edit: "ویرایش", @@ -137,7 +137,7 @@ export const fa = { detailTitle: "جزئیات فضای کاری", save: "ذخیره", create: "ایجاد", - back: "بازگشت به فضاهای کاری", + back: "بازگشت به ورک‌اسپیس‌ها", roleLabel: "نقش شما", roles: { owner: "مالک", @@ -152,7 +152,7 @@ export const fa = { noUsersFound: "کاربری یافت نشد", selectRole: "انتخاب نقش", add: "افزودن", - searchPlaceholder: "جستوجوی فضاهای کاری...", + searchPlaceholder: "جستوجوی ورک‌اسپیس‌ها...", orderByUpdatedDesc: "آخرین ویرایش", orderByCreatedDesc: "جدیدترین", orderByCreatedAsc: "قدیمی‌ترین", @@ -168,6 +168,7 @@ export const fa = { confirmDeleteTitle: "حذف عضو", confirmDeleteMessage: "آیا مطمئن هستید که می‌خواهید این عضو را از فضای کاری حذف کنید؟", toast: { + successCreate: "فضای کاری با موفقیت ساخته شد.", successUpdate: "فضای کاری با موفقیت به‌روزرسانی شد.", errorUpdate: "به‌روزرسانی فضای کاری با خطا مواجه شد.", successAdd: "کاربر جدید با موفقیت به فضای کاری افزوده شد.", @@ -209,7 +210,17 @@ export const fa = { createFailed: "خطا در ایجاد مشتری", fetchFailed: "خطا در دریافت لیست مشتریان", updateFailed: "خطا در ویرایش مشتری", - deleteFailed: "خطا در حذف مشتری" - } + deleteFailed: "خطا در حذف مشتری", + }, + }, + + pagination: { + perPage: "در هر صفحه", + showing: "نمایش", + to: "تا", + of: "از", + previous: "قبلی", + page: "صفحه", + next: "بعدی", }, } diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index bfd171b..e8175e3 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -10,12 +10,18 @@ import DeleteClientModal from "../components/DeleteClientModal" import FilterBar from "../components/FilterBar" import { Button } from "../components/ui/button" import { Card } from "../components/ui/card" +import { Pagination } from "../components/Pagination" export default function Clients() { const { activeWorkspace } = useWorkspace() const [clients, setClients] = useState([]) const [isLoading, setIsLoading] = useState(true) + // Pagination States + const [currentPage, setCurrentPage] = useState(1) + const [totalItems, setTotalItems] = useState(0) + const [limit, setLimit] = useState(10) + // Filter States const [searchQuery, setSearchQuery] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") @@ -37,6 +43,11 @@ export default function Clients() { { value: "-updated_at", label: isFa ? "اخیراً بروزرسانی شده" : "Recently Updated" }, ] + // بازگشت به صفحه اول در صورت تغییر فیلتر یا جستجو + useEffect(() => { + setCurrentPage(1) + }, [debouncedSearch, ordering]) + // Debounce search input to avoid spamming the API useEffect(() => { const handler = setTimeout(() => { @@ -53,8 +64,14 @@ export default function Clients() { setIsLoading(true) try { - const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering) - setClients(data?.results || (Array.isArray(data) ? data : [])) + const offset = (currentPage - 1) * limit + const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset) + + const items = data?.results || (Array.isArray(data) ? data : []) + const count = data?.count !== undefined ? data.count : items.length + + setClients(items) + setTotalItems(count) } catch (error) { console.error(t.clients.errors.fetchFailed, error) setClients([]) @@ -76,10 +93,9 @@ export default function Clients() { } } - // Refetch when workspace, debounced search, or ordering changes useEffect(() => { fetchClientsList() - }, [activeWorkspace?.id, debouncedSearch, ordering]) + }, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit]) if (!activeWorkspace) { return ( @@ -90,7 +106,7 @@ export default function Clients() { } return ( -
+

{t.clients.title}

@@ -114,7 +130,7 @@ export default function Clients() { searchPlaceholder={t.clients.searchPlaceholder} /> - +
{isLoading ? (
@@ -169,6 +185,16 @@ export default function Clients() {
+ {!isLoading && clients.length > 0 && ( + + )} + setIsCreateModalOpen(false)} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 83dd250..e9831bd 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -114,6 +114,8 @@ export default function Profile() { const updatedUser = await updateUserProfile(payload) setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser) setIsEditing(false) + + window.dispatchEvent(new CustomEvent('profile_updated', { detail: updatedUser })); toast.success(t.profile.toasts.successEdit) } catch (error) { toast.error(t.profile.toasts.error) @@ -131,6 +133,8 @@ export default function Profile() { setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture } : response) setIsPicModalOpen(false) setSelectedFile(null) + + window.dispatchEvent(new CustomEvent('profile_updated', { detail: { profile_picture: response.profile_picture || null } })); toast.success(t.profile.toasts.successImage) } catch (error) { toast.error(t.profile.toasts.error) @@ -146,6 +150,7 @@ export default function Profile() { const response = await removeProfilePicture() setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture || null } : response) setIsPicModalOpen(false) + window.dispatchEvent(new CustomEvent('profile_updated', { detail: { profile_picture: response.profile_picture || null } })); toast.success(t.profile.toasts.successRemoveImage) } catch (error) { toast.error(t.profile.toasts.error) diff --git a/src/pages/WorkspaceCreate.tsx b/src/pages/WorkspaceCreate.tsx index c18a544..914b94f 100644 --- a/src/pages/WorkspaceCreate.tsx +++ b/src/pages/WorkspaceCreate.tsx @@ -101,9 +101,13 @@ export default function WorkspaceCreate() { description, members: members.map(m => ({ user_id: m.user.id, role: m.role })) }; + const newWorkspace = await createWorkspace({ name, description }); - await createWorkspace(payload); + window.dispatchEvent(new CustomEvent('workspace_created', { + detail: newWorkspace + })); toast.success(t.workspace?.toast?.successCreate || "Workspace created successfully."); + navigate('/workspaces'); } catch (error) { toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace."); diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index 67c57a1..75d69ae 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -15,12 +15,15 @@ import { import { searchUserByExactMobile, type SearchedUser } from '../api/users'; import { useAppContext } from '../context/AppContext'; import { Button } from '../components/ui/button'; +import { InfiniteScroll } from '../components/infiniteScroll'; const toEnglishDigits = (str: string) => { return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) .replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d).toString()); }; +const LIMIT = 10; + export default function EditWorkspace() { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); @@ -52,6 +55,11 @@ export default function EditWorkspace() { const [isSearching, setIsSearching] = useState(false); const [newMemberRole, setNewMemberRole] = useState<'owner' | 'admin' | 'member' | 'guest'>('member'); + // Pagination States + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [isLoadingMembers, setIsLoadingMembers] = useState(false); + // Modal State const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [memberIdToDelete, setMemberIdToDelete] = useState(null); @@ -71,8 +79,12 @@ export default function EditWorkspace() { setMyRole(workspaceData.my_role || 'member'); setWorkspaceOwnerId(workspaceData.owner || ''); - const membersData = await fetchWorkspaceMemberships(id!); - setMembers(membersData); + const membersData = await fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }); + const results = membersData.results || (Array.isArray(membersData) ? membersData : []); + + setMembers(results); + setOffset(LIMIT); + setHasMore(!!membersData.next); } catch (error) { toast.error(t.workspace?.toast?.errorLoad || "Failed to load workspace data."); navigate('/workspaces'); @@ -81,6 +93,23 @@ export default function EditWorkspace() { } }; + const loadMoreMembers = async () => { + if (isLoadingMembers || !hasMore || !id) return; + try { + setIsLoadingMembers(true); + const membersData = await fetchWorkspaceMemberships({ workspace: id, limit: LIMIT, offset }); + const results = membersData.results || []; + + setMembers((prev) => [...prev, ...results]); + setOffset((prev) => prev + LIMIT); + setHasMore(!!membersData.next); + } catch (error) { + console.error("Failed to load more members", error); + } finally { + setIsLoadingMembers(false); + } + }; + useEffect(() => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); const cleanQuery = toEnglishDigits(searchQuery.trim()); @@ -116,8 +145,14 @@ export default function EditWorkspace() { if (!name.trim() || !id) return; try { setIsSaving(true); + await updateWorkspace(id, { name, description }); + toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully."); + window.dispatchEvent(new CustomEvent('workspace_edited', { + detail: { id, name, description } + })); + navigate('/workspaces'); } catch (error) { toast.error(t.workspace?.toast?.errorUpdate || "Failed to update workspace."); @@ -134,7 +169,7 @@ export default function EditWorkspace() { user: searchResult.id, role: newMemberRole }); - setMembers([...members, newMembership]); + setMembers([newMembership, ...members]); toast.success(t.workspace?.toast?.successAdd || "Member added successfully."); setSearchQuery(''); setSearchResult(null); @@ -297,64 +332,72 @@ export default function EditWorkspace() { )}
- {members.map((m) => { - const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId; - const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner'); + {t.workspace?.loading || "Loading more members..."}
} + > + {members.map((m) => { + const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId; + const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner'); - return ( -
-
- {m.user?.profile_picture ? ( - {m.user?.first_name} - ) : ( -
- {m.user?.name?.[0] || m.user?.first_name?.[0] || "U"} + return ( +
+
+ {m.user?.profile_picture ? ( + {m.user?.first_name} + ) : ( +
+ {m.user?.name?.[0] || m.user?.first_name?.[0] || "U"} +
+ )} +
+

+ {m.user?.name || `${m.user?.first_name || ''} ${m.user?.last_name || ''}`.trim() || 'Unknown'} +

+

{toPersianNum(m.user?.mobile)}

- )} -
-

- {m.user?.name || `${m.user?.first_name || ''} ${m.user?.last_name || ''}`.trim() || 'Unknown'} -

-

{toPersianNum(m.user?.mobile)}

+
+ +
+ {canChangeThisUserRole ? ( + + ) : ( + + {m.role === 'owner' && } + {m.role ? t.workspace?.roles?.[m.role] || m.role : "-"} + + )} + + {canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && ( + + )}
- -
- {canChangeThisUserRole ? ( - - ) : ( - - {m.role === 'owner' && } - {m.role ? t.workspace?.roles?.[m.role] || m.role : "-"} - - )} - - {canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && ( - - )} -
-
- ); - })} - {members.length === 0 && ( + ); + })} + + {members.length === 0 && !isLoadingMembers && (

{t.workspace?.noMembers || "No members found."}

diff --git a/src/pages/Workspaces.tsx b/src/pages/Workspaces.tsx index bdb65d6..cf3c01f 100644 --- a/src/pages/Workspaces.tsx +++ b/src/pages/Workspaces.tsx @@ -7,6 +7,7 @@ import { useAppContext } from '../context/AppContext'; import { useTranslation } from '../hooks/useTranslation'; import FilterBar from '../components/FilterBar'; import { Button } from '../components/ui/button'; +import { Pagination } from '../components/Pagination'; type WorkspaceRole = "owner" | "admin" | "member" | "guest"; @@ -33,6 +34,11 @@ export default function Workspaces() { const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [ordering, setOrdering] = useState('-updated_at'); + + // تنظیمات پاژینیشن + const [currentPage, setCurrentPage] = useState(1); + const [totalItems, setTotalItems] = useState(0); + const [limit, setLimit] = useState(9); const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null}); const [deleteInput, setDeleteInput] = useState(''); @@ -48,22 +54,37 @@ export default function Workspaces() { { value: 'name', label: t.workspace?.orderByName || 'Name (A-Z)' }, ]; + // وقتی جستجو یا ترتیب تغییر کرد، به صفحه اول برگرد + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, ordering]); + useEffect(() => { const timer = setTimeout(() => { loadWorkspaces(); }, 400); return () => clearTimeout(timer); - }, [searchQuery, ordering]); + }, [searchQuery, ordering, currentPage, limit]); const loadWorkspaces = async () => { try { setIsLoading(true); - const params: Record = {}; + const params: Record = { + limit: limit, + offset: (currentPage - 1) * limit, + }; + if (searchQuery) params.search = searchQuery; if (ordering) params.ordering = ordering; - const data = await fetchWorkspaces(params); - setWorkspaces(data); + const data = await fetchWorkspaces(params as any); + + // استخراج هوشمند نتایج و تعداد کل + const items = Array.isArray(data) ? data : (data?.results || []); + const count = !Array.isArray(data) && data?.count !== undefined ? data.count : items.length; + + setWorkspaces(items); + setTotalItems(count); } catch (error) { toast.error(t.workspace?.fetchError || 'Error fetching workspaces'); } finally { @@ -71,11 +92,19 @@ export default function Workspaces() { } }; - const confirmDelete = async () => { + const confirmDelete = async () => { if (!deleteModal.workspace) return; try { - await deleteWorkspace(deleteModal.workspace.id); - setWorkspaces(workspaces.filter((w) => w.id !== deleteModal.workspace!.id)); + const deletedId = deleteModal.workspace.id; + await deleteWorkspace(deletedId); + + loadWorkspaces(); + + // ارسال سیگنال به کل اپلیکیشن برای آپدیت نوار ناوبری + window.dispatchEvent(new CustomEvent('workspace_deleted', { + detail: { id: deletedId } + })); + toast.success(t.workspace?.deleteSuccess || 'Workspace deleted successfully'); setDeleteModal({ isOpen: false, workspace: null }); setDeleteInput(''); @@ -85,18 +114,18 @@ export default function Workspaces() { }; return ( -
+
-

{t.workspace?.title}

-

{t.workspace?.subtitle}

+

{t.workspace?.title || 'Workspaces'}

+

{t.workspace?.subtitle || 'Manage your workspaces'}

@@ -106,7 +135,7 @@ export default function Workspaces() { ordering={ordering} setOrdering={setOrdering} orderingOptions={orderingOptions} - searchPlaceholder={t.workspace?.searchPlaceholder} + searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'} /> {isLoading ? ( @@ -114,79 +143,91 @@ export default function Workspaces() {
{t.workspace?.loading || 'Loading...'}
) : ( -
- {workspaces.map((workspace) => { - const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner'; - const isAdmin = workspace.my_role === 'admin' || isOwner; + <> +
+ {workspaces.map((workspace) => { + const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner'; + const isAdmin = workspace.my_role === 'admin' || isOwner; - return ( -
-
-
-

- {workspace.name} -

- + return ( +
+
+
+

+ {workspace.name} +

+ +
+

+ {workspace.description || t.workspace?.noDescription || 'No description'} +

-

- {workspace.description || t.workspace?.noDescription} -

-
-
- - - {isAdmin && ( +
- )} - {isOwner && ( - - )} + {isAdmin && ( + + )} + + {isOwner && ( + + )} +
+ ); + })} + + {workspaces.length === 0 && ( +
+

{t.workspace?.emptyState || 'No workspaces found'}

- ); - })} + )} +
- {workspaces.length === 0 && ( -
-

{t.workspace?.emptyState || 'No workspaces found'}

-
- )} -
+ + )} + {/* Delete Modal */} {deleteModal.isOpen && deleteModal.workspace && (
-

{t.workspace?.deleteTitle || 'Delete Workspace'}

+

{t.workspace?.deleteTitle || 'Delete Workspace'}

{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} {deleteModal.workspace.name}