feat(improvement): add pagination to endpoints and pages + sync navbar when data changes
This commit is contained in:
@@ -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 = {}) => {
|
export const authFetch = async (endpoint: string, options: RequestInit = {}) => {
|
||||||
const token = localStorage.getItem("accessToken");
|
const token = localStorage.getItem("accessToken")
|
||||||
const isFormData = options.body instanceof FormData;
|
const isFormData = options.body instanceof FormData
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
...(!isFormData && { "Content-Type": "application/json" }),
|
...(!isFormData && { "Content-Type": "application/json" }),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...options.headers,
|
...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 cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "");
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
const url = `${cleanBaseUrl}${cleanEndpoint}`
|
||||||
const url = `${cleanBaseUrl}${cleanEndpoint}`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken")
|
||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refreshToken")
|
||||||
window.location.href = "/auth";
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { authFetch } from "./client";
|
import { authFetch } from "./client";
|
||||||
import { type PaginatedClientList } from "../types/client";
|
|
||||||
|
|
||||||
|
export const getClients = async (
|
||||||
export const getClients = async (workspaceId: string, search: string = "", ordering: string = "") => {
|
workspaceId: string,
|
||||||
const queryParams = new URLSearchParams({ workspace: workspaceId });
|
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 (search) queryParams.append("search", search);
|
||||||
if (ordering) queryParams.append("ordering", ordering);
|
if (ordering) queryParams.append("ordering", ordering);
|
||||||
@@ -54,7 +62,6 @@ export const deleteClient = async (id: string) => {
|
|||||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete client");
|
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) {
|
if (response.status === 204) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/api/workspaces.ts
|
|
||||||
import { authFetch } from "./client";
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
export interface Workspace {
|
export interface Workspace {
|
||||||
@@ -10,7 +9,31 @@ export interface Workspace {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchWorkspaces = async (params?: Record<string, string>): Promise<Workspace[]> => {
|
export interface PaginatedResponse<T> {
|
||||||
|
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<string, string>): Promise<PaginatedResponse<Workspace>> => {
|
||||||
const query = params ? new URLSearchParams(params).toString() : '';
|
const query = params ? new URLSearchParams(params).toString() : '';
|
||||||
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
|
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
|
||||||
const response = await authFetch(url);
|
const response = await authFetch(url);
|
||||||
@@ -20,7 +43,17 @@ export const fetchWorkspaces = async (params?: Record<string, string>): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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<Workspace> => {
|
export const getWorkspace = async (id: string): Promise<Workspace> => {
|
||||||
@@ -65,12 +98,24 @@ export const deleteWorkspace = async (id: string): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchWorkspaceMemberships = async (workspaceId: string) => {
|
export const fetchWorkspaceMemberships = async (params?: Record<string, string>): Promise<PaginatedResponse<WorkspaceMembership>> => {
|
||||||
const response = await authFetch(`/api/workspace-memberships/?workspace=${workspaceId}`);
|
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");
|
if (!response.ok) throw new Error("Failed to fetch workspace memberships");
|
||||||
|
|
||||||
const data = await response.json();
|
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 }) => {
|
export const addWorkspaceMembership = async (data: { workspace: string; user: string; role: string }) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { WorkspaceSelector } from "./WorkspaceSelector"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { t, lang, setLang } = useTranslation()
|
const { t, lang, setLanguage } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||||
@@ -23,6 +23,17 @@ export function Navbar() {
|
|||||||
return document.documentElement.classList.contains('dark');
|
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(() => {
|
useEffect(() => {
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
@@ -83,8 +94,8 @@ export function Navbar() {
|
|||||||
|
|
||||||
const toggleLanguage = () => {
|
const toggleLanguage = () => {
|
||||||
const newLang = isFa ? 'en' : 'fa'
|
const newLang = isFa ? 'en' : 'fa'
|
||||||
if (setLang) {
|
if (setLanguage) {
|
||||||
setLang(newLang)
|
setLanguage(newLang)
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('language', newLang)
|
localStorage.setItem('language', newLang)
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
@@ -126,7 +137,14 @@ export function Navbar() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen && (
|
||||||
<div className={`absolute ${isFa ? 'left-0' : 'right-0'} mt-2 w-56 rounded-lg bg-white dark:bg-slate-900 shadow-lg ring-1 ring-black ring-opacity-5 border border-slate-200 dark:border-slate-800 z-50 py-2 overflow-hidden`}>
|
<div dir='rtl' className={`absolute ${isFa ? 'left-0' : 'right-0'} mt-2 w-56 rounded-lg bg-white dark:bg-slate-900 shadow-lg ring-1 ring-black ring-opacity-5 border border-slate-200 dark:border-slate-800 z-50 py-2 overflow-hidden`}>
|
||||||
|
<div className="px-4 py-2 mb-2 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<p className="text-sm font-semibold text-slate-800 dark:text-slate-400 truncate">
|
||||||
|
{user.first_name || user.last_name
|
||||||
|
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
|
||||||
|
: user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { navigate("/profile"); setIsDropdownOpen(false); }}
|
onClick={() => { navigate("/profile"); setIsDropdownOpen(false); }}
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
|||||||
@@ -1,17 +1,84 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
import { Check, ChevronDown, Plus, Briefcase, Settings } from "lucide-react";
|
import { Check, ChevronDown, Plus, Briefcase, Settings } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { fetchWorkspaces, type Workspace } from "../api/workspaces";
|
||||||
|
import { InfiniteScroll } from "./InfiniteScroll";
|
||||||
|
|
||||||
export const WorkspaceSelector: React.FC = () => {
|
export const WorkspaceSelector: React.FC = () => {
|
||||||
const { workspaces, activeWorkspace, setActiveWorkspace } = useWorkspace();
|
const { workspaces, activeWorkspace, setActiveWorkspace } = useWorkspace();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const isFa = lang === "fa";
|
const isFa = lang === "fa";
|
||||||
|
|
||||||
|
const [localWorkspaces, setLocalWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
|
const refreshWorkspacesList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchWorkspaces({ offset: 0, limit: LIMIT });
|
||||||
|
const items = Array.isArray(res) ? res : (res?.results || []);
|
||||||
|
setLocalWorkspaces(items);
|
||||||
|
setOffset(items.length);
|
||||||
|
setHasMore(!Array.isArray(res) ? !!res?.next : items.length >= LIMIT);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWorkspaceDeleted = ((e: CustomEvent) => {
|
||||||
|
if (activeWorkspace?.id === e.detail?.id) {
|
||||||
|
setActiveWorkspace(null);
|
||||||
|
}
|
||||||
|
refreshWorkspacesList();
|
||||||
|
}) as EventListener;
|
||||||
|
|
||||||
|
const handleWorkspaceCreated = ((e: CustomEvent) => {
|
||||||
|
if (e.detail) {
|
||||||
|
setActiveWorkspace(e.detail);
|
||||||
|
}
|
||||||
|
refreshWorkspacesList();
|
||||||
|
}) as EventListener;
|
||||||
|
|
||||||
|
const handleWorkspaceEdited = ((e: CustomEvent) => {
|
||||||
|
// آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد
|
||||||
|
if (activeWorkspace?.id === e.detail?.id) {
|
||||||
|
setActiveWorkspace({
|
||||||
|
...activeWorkspace,
|
||||||
|
name: e.detail.name,
|
||||||
|
description: e.detail.description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refreshWorkspacesList();
|
||||||
|
}) as EventListener;
|
||||||
|
|
||||||
|
window.addEventListener("workspace_deleted", handleWorkspaceDeleted);
|
||||||
|
window.addEventListener("workspace_created", handleWorkspaceCreated);
|
||||||
|
window.addEventListener("workspace_edited", handleWorkspaceEdited);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("workspace_deleted", handleWorkspaceDeleted);
|
||||||
|
window.removeEventListener("workspace_created", handleWorkspaceCreated);
|
||||||
|
window.removeEventListener("workspace_edited", handleWorkspaceEdited);
|
||||||
|
};
|
||||||
|
}, [activeWorkspace, setActiveWorkspace, refreshWorkspacesList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctxList = Array.isArray(workspaces) ? workspaces : (workspaces as any)?.results || [];
|
||||||
|
setLocalWorkspaces(ctxList);
|
||||||
|
setOffset(ctxList.length);
|
||||||
|
if (ctxList.length > 0 && ctxList.length < LIMIT) {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
}, [workspaces]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
@@ -22,9 +89,35 @@ export const WorkspaceSelector: React.FC = () => {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMoreWorkspaces = useCallback(async () => {
|
||||||
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchWorkspaces({ offset, limit: LIMIT });
|
||||||
|
const newItems = Array.isArray(res) ? res : (res?.results || []);
|
||||||
|
const nextUrl = !Array.isArray(res) ? res?.next : null;
|
||||||
|
|
||||||
|
setLocalWorkspaces((prev) => {
|
||||||
|
const existingIds = new Set(prev.map(w => w.id));
|
||||||
|
const uniqueNewItems = newItems.filter((w: Workspace) => !existingIds.has(w.id));
|
||||||
|
return [...prev, ...uniqueNewItems];
|
||||||
|
});
|
||||||
|
|
||||||
|
setOffset((prev) => prev + LIMIT);
|
||||||
|
|
||||||
|
if (!nextUrl && newItems.length < LIMIT) {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [offset, hasMore, isLoadingMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
{/* Selector Button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
@@ -39,7 +132,6 @@ export const WorkspaceSelector: React.FC = () => {
|
|||||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={`absolute top-full mt-2 w-64 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-800 py-2 z-40 ${
|
className={`absolute top-full mt-2 w-64 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-800 py-2 z-40 ${
|
||||||
@@ -50,8 +142,13 @@ export const WorkspaceSelector: React.FC = () => {
|
|||||||
{t.workspace?.title || "Workspaces"}
|
{t.workspace?.title || "Workspaces"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto" id="workspace-scroll-container">
|
||||||
{workspaces.map((ws) => (
|
<InfiniteScroll
|
||||||
|
onLoadMore={loadMoreWorkspaces}
|
||||||
|
hasMore={hasMore}
|
||||||
|
isLoading={isLoadingMore}
|
||||||
|
>
|
||||||
|
{localWorkspaces.map((ws: Workspace) => (
|
||||||
<button
|
<button
|
||||||
key={ws.id}
|
key={ws.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -72,6 +169,7 @@ export const WorkspaceSelector: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</InfiniteScroll>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-200 dark:bg-slate-800 my-2" />
|
<div className="h-px bg-slate-200 dark:bg-slate-800 my-2" />
|
||||||
@@ -100,7 +198,6 @@ export const WorkspaceSelector: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
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 {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,23 +31,26 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const fetchInitialData = async () => {
|
const fetchInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
const [userRes, wsRes] = await Promise.all([
|
const [userData, wsData] = await Promise.all([
|
||||||
api.get('/api/users/me/'),
|
getUserProfile(),
|
||||||
api.get('/api/workspaces/')
|
fetchWorkspaces() // fetchWorkspaces({ limit: 50 })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setUser(userRes.data);
|
setUser(userData);
|
||||||
setWorkspaces(wsRes.data.results || wsRes.data);
|
|
||||||
|
const workspacesList = Array.isArray(wsData.data) ? wsData.data : (wsData?.data?.results || []);
|
||||||
|
|
||||||
|
setWorkspaces(workspacesList);
|
||||||
|
|
||||||
const savedWsId = localStorage.getItem('active_workspace');
|
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) {
|
if (targetWs) {
|
||||||
setActiveWorkspace(targetWs);
|
setActiveWorkspace(targetWs);
|
||||||
localStorage.setItem('active_workspace', targetWs.id);
|
localStorage.setItem('active_workspace', targetWs.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error("Failed to fetch initial context data:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const loadWorkspaces = async () => {
|
const loadWorkspaces = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchWorkspaces()
|
const response = await fetchWorkspaces()
|
||||||
|
|
||||||
|
const data = Array.isArray(response) ? response : (response?.results || [])
|
||||||
setWorkspaces(data)
|
setWorkspaces(data)
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
const storedId = localStorage.getItem("activeWorkspaceId")
|
const storedId = localStorage.getItem("activeWorkspaceId")
|
||||||
const stored = data.find((w) => w.id === storedId)
|
const stored = data.find((w: Workspace) => w.id === storedId)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setActiveWorkspaceState(stored)
|
setActiveWorkspaceState(stored)
|
||||||
} else {
|
} else {
|
||||||
@@ -69,7 +71,7 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const addWorkspace = async (name: string) => {
|
const addWorkspace = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
setIsCreatingFirst(true)
|
setIsCreatingFirst(true)
|
||||||
const newWs = await createWorkspace(name)
|
const newWs = await createWorkspace({ name, description: "" })
|
||||||
setWorkspaces((prev) => [...prev, newWs])
|
setWorkspaces((prev) => [...prev, newWs])
|
||||||
setActiveWorkspace(newWs)
|
setActiveWorkspace(newWs)
|
||||||
toast.success(t.workspace?.createSuccess || "Workspace created!")
|
toast.success(t.workspace?.createSuccess || "Workspace created!")
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export const en = {
|
|||||||
successCreate: "Workspace created successfully.",
|
successCreate: "Workspace created successfully.",
|
||||||
errorCreate: "Failed to create workspace.",
|
errorCreate: "Failed to create workspace.",
|
||||||
toast: {
|
toast: {
|
||||||
|
successCreate: "Workspace created successfully.",
|
||||||
successUpdate: "Workspace updated successfully.",
|
successUpdate: "Workspace updated successfully.",
|
||||||
errorUpdate: "Failed to update workspace.",
|
errorUpdate: "Failed to update workspace.",
|
||||||
successAdd: "Member added successfully.",
|
successAdd: "Member added successfully.",
|
||||||
@@ -208,7 +209,17 @@ export const en = {
|
|||||||
createFailed: "Failed to create client",
|
createFailed: "Failed to create client",
|
||||||
fetchFailed: "Failed to fetch clients",
|
fetchFailed: "Failed to fetch clients",
|
||||||
updateFailed: "Failed to update client",
|
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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const fa = {
|
|||||||
otpPlaceholder: "کد ۶ رقمی",
|
otpPlaceholder: "کد ۶ رقمی",
|
||||||
verifyAndContinue: "تایید و ادامه",
|
verifyAndContinue: "تایید و ادامه",
|
||||||
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
||||||
brandingQuote: "زمان و فضاهای کاری خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||||
toasts: {
|
toasts: {
|
||||||
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
||||||
verifySent: "کد تایید ارسال شد!",
|
verifySent: "کد تایید ارسال شد!",
|
||||||
@@ -105,9 +105,9 @@ export const fa = {
|
|||||||
darkMode: "حالت تاریک",
|
darkMode: "حالت تاریک",
|
||||||
|
|
||||||
workspace: {
|
workspace: {
|
||||||
title: "مدیریت فضاهای کاری",
|
title: "مدیریت ورکاسپیسها",
|
||||||
createNew: "ایجاد فضای کاری جدید",
|
createNew: "ایجاد فضای کاری جدید",
|
||||||
manage: "مدیریت فضاهای کاری",
|
manage: "مدیریت ورکاسپیسها",
|
||||||
nameLabel: "نام فضای کاری",
|
nameLabel: "نام فضای کاری",
|
||||||
namePlaceholder: "نام فضای کاری را وارد کنید",
|
namePlaceholder: "نام فضای کاری را وارد کنید",
|
||||||
descriptionLabel: "توضیحات",
|
descriptionLabel: "توضیحات",
|
||||||
@@ -126,7 +126,7 @@ export const fa = {
|
|||||||
loading: "در حال بارگذاری...",
|
loading: "در حال بارگذاری...",
|
||||||
confirmDelete: "آیا از حذف این فضای کاری اطمینان دارید؟",
|
confirmDelete: "آیا از حذف این فضای کاری اطمینان دارید؟",
|
||||||
deleteError: "خطا در حذف فضای کاری",
|
deleteError: "خطا در حذف فضای کاری",
|
||||||
subtitle: "فضاهای کاری خود را مدیریت کنید",
|
subtitle: "ورکاسپیسهای خود را مدیریت کنید",
|
||||||
noDescription: "بدون توضیحات",
|
noDescription: "بدون توضیحات",
|
||||||
view: "مشاهده",
|
view: "مشاهده",
|
||||||
edit: "ویرایش",
|
edit: "ویرایش",
|
||||||
@@ -137,7 +137,7 @@ export const fa = {
|
|||||||
detailTitle: "جزئیات فضای کاری",
|
detailTitle: "جزئیات فضای کاری",
|
||||||
save: "ذخیره",
|
save: "ذخیره",
|
||||||
create: "ایجاد",
|
create: "ایجاد",
|
||||||
back: "بازگشت به فضاهای کاری",
|
back: "بازگشت به ورکاسپیسها",
|
||||||
roleLabel: "نقش شما",
|
roleLabel: "نقش شما",
|
||||||
roles: {
|
roles: {
|
||||||
owner: "مالک",
|
owner: "مالک",
|
||||||
@@ -152,7 +152,7 @@ export const fa = {
|
|||||||
noUsersFound: "کاربری یافت نشد",
|
noUsersFound: "کاربری یافت نشد",
|
||||||
selectRole: "انتخاب نقش",
|
selectRole: "انتخاب نقش",
|
||||||
add: "افزودن",
|
add: "افزودن",
|
||||||
searchPlaceholder: "جستوجوی فضاهای کاری...",
|
searchPlaceholder: "جستوجوی ورکاسپیسها...",
|
||||||
orderByUpdatedDesc: "آخرین ویرایش",
|
orderByUpdatedDesc: "آخرین ویرایش",
|
||||||
orderByCreatedDesc: "جدیدترین",
|
orderByCreatedDesc: "جدیدترین",
|
||||||
orderByCreatedAsc: "قدیمیترین",
|
orderByCreatedAsc: "قدیمیترین",
|
||||||
@@ -168,6 +168,7 @@ export const fa = {
|
|||||||
confirmDeleteTitle: "حذف عضو",
|
confirmDeleteTitle: "حذف عضو",
|
||||||
confirmDeleteMessage: "آیا مطمئن هستید که میخواهید این عضو را از فضای کاری حذف کنید؟",
|
confirmDeleteMessage: "آیا مطمئن هستید که میخواهید این عضو را از فضای کاری حذف کنید؟",
|
||||||
toast: {
|
toast: {
|
||||||
|
successCreate: "فضای کاری با موفقیت ساخته شد.",
|
||||||
successUpdate: "فضای کاری با موفقیت بهروزرسانی شد.",
|
successUpdate: "فضای کاری با موفقیت بهروزرسانی شد.",
|
||||||
errorUpdate: "بهروزرسانی فضای کاری با خطا مواجه شد.",
|
errorUpdate: "بهروزرسانی فضای کاری با خطا مواجه شد.",
|
||||||
successAdd: "کاربر جدید با موفقیت به فضای کاری افزوده شد.",
|
successAdd: "کاربر جدید با موفقیت به فضای کاری افزوده شد.",
|
||||||
@@ -209,7 +210,17 @@ export const fa = {
|
|||||||
createFailed: "خطا در ایجاد مشتری",
|
createFailed: "خطا در ایجاد مشتری",
|
||||||
fetchFailed: "خطا در دریافت لیست مشتریان",
|
fetchFailed: "خطا در دریافت لیست مشتریان",
|
||||||
updateFailed: "خطا در ویرایش مشتری",
|
updateFailed: "خطا در ویرایش مشتری",
|
||||||
deleteFailed: "خطا در حذف مشتری"
|
deleteFailed: "خطا در حذف مشتری",
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pagination: {
|
||||||
|
perPage: "در هر صفحه",
|
||||||
|
showing: "نمایش",
|
||||||
|
to: "تا",
|
||||||
|
of: "از",
|
||||||
|
previous: "قبلی",
|
||||||
|
page: "صفحه",
|
||||||
|
next: "بعدی",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import DeleteClientModal from "../components/DeleteClientModal"
|
|||||||
import FilterBar from "../components/FilterBar"
|
import FilterBar from "../components/FilterBar"
|
||||||
import { Button } from "../components/ui/button"
|
import { Button } from "../components/ui/button"
|
||||||
import { Card } from "../components/ui/card"
|
import { Card } from "../components/ui/card"
|
||||||
|
import { Pagination } from "../components/Pagination"
|
||||||
|
|
||||||
export default function Clients() {
|
export default function Clients() {
|
||||||
const { activeWorkspace } = useWorkspace()
|
const { activeWorkspace } = useWorkspace()
|
||||||
const [clients, setClients] = useState<Client[]>([])
|
const [clients, setClients] = useState<Client[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
// Pagination States
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [totalItems, setTotalItems] = useState(0)
|
||||||
|
const [limit, setLimit] = useState(10)
|
||||||
|
|
||||||
// Filter States
|
// Filter States
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
@@ -37,6 +43,11 @@ export default function Clients() {
|
|||||||
{ value: "-updated_at", label: isFa ? "اخیراً بروزرسانی شده" : "Recently Updated" },
|
{ value: "-updated_at", label: isFa ? "اخیراً بروزرسانی شده" : "Recently Updated" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// بازگشت به صفحه اول در صورت تغییر فیلتر یا جستجو
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [debouncedSearch, ordering])
|
||||||
|
|
||||||
// Debounce search input to avoid spamming the API
|
// Debounce search input to avoid spamming the API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
@@ -53,8 +64,14 @@ export default function Clients() {
|
|||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering)
|
const offset = (currentPage - 1) * limit
|
||||||
setClients(data?.results || (Array.isArray(data) ? data : []))
|
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) {
|
} catch (error) {
|
||||||
console.error(t.clients.errors.fetchFailed, error)
|
console.error(t.clients.errors.fetchFailed, error)
|
||||||
setClients([])
|
setClients([])
|
||||||
@@ -76,10 +93,9 @@ export default function Clients() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refetch when workspace, debounced search, or ordering changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClientsList()
|
fetchClientsList()
|
||||||
}, [activeWorkspace?.id, debouncedSearch, ordering])
|
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
|
||||||
|
|
||||||
if (!activeWorkspace) {
|
if (!activeWorkspace) {
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +106,7 @@ export default function Clients() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto min-h-[calc(100vh-73px)]">
|
<div className="flex flex-col p-6 max-w-6xl mx-auto min-h-[calc(100vh-73px)]">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
||||||
@@ -114,7 +130,7 @@ export default function Clients() {
|
|||||||
searchPlaceholder={t.clients.searchPlaceholder}
|
searchPlaceholder={t.clients.searchPlaceholder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="overflow-hidden dark:bg-slate-900 dark:border-slate-800">
|
<Card className="overflow-hidden dark:bg-slate-900 dark:border-slate-800 mb-6">
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center items-center p-12 text-slate-500">
|
<div className="flex justify-center items-center p-12 text-slate-500">
|
||||||
@@ -169,6 +185,16 @@ export default function Clients() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{!isLoading && clients.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CreateClientModal
|
<CreateClientModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export default function Profile() {
|
|||||||
const updatedUser = await updateUserProfile(payload)
|
const updatedUser = await updateUserProfile(payload)
|
||||||
setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser)
|
setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('profile_updated', { detail: updatedUser }));
|
||||||
toast.success(t.profile.toasts.successEdit)
|
toast.success(t.profile.toasts.successEdit)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.profile.toasts.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)
|
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture } : response)
|
||||||
setIsPicModalOpen(false)
|
setIsPicModalOpen(false)
|
||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('profile_updated', { detail: { profile_picture: response.profile_picture || null } }));
|
||||||
toast.success(t.profile.toasts.successImage)
|
toast.success(t.profile.toasts.successImage)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.profile.toasts.error)
|
toast.error(t.profile.toasts.error)
|
||||||
@@ -146,6 +150,7 @@ export default function Profile() {
|
|||||||
const response = await removeProfilePicture()
|
const response = await removeProfilePicture()
|
||||||
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture || null } : response)
|
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture || null } : response)
|
||||||
setIsPicModalOpen(false)
|
setIsPicModalOpen(false)
|
||||||
|
window.dispatchEvent(new CustomEvent('profile_updated', { detail: { profile_picture: response.profile_picture || null } }));
|
||||||
toast.success(t.profile.toasts.successRemoveImage)
|
toast.success(t.profile.toasts.successRemoveImage)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.profile.toasts.error)
|
toast.error(t.profile.toasts.error)
|
||||||
|
|||||||
@@ -101,9 +101,13 @@ export default function WorkspaceCreate() {
|
|||||||
description,
|
description,
|
||||||
members: members.map(m => ({ user_id: m.user.id, role: m.role }))
|
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.");
|
toast.success(t.workspace?.toast?.successCreate || "Workspace created successfully.");
|
||||||
|
|
||||||
navigate('/workspaces');
|
navigate('/workspaces');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace.");
|
toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace.");
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ import {
|
|||||||
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
|
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
|
||||||
import { useAppContext } from '../context/AppContext';
|
import { useAppContext } from '../context/AppContext';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
|
import { InfiniteScroll } from '../components/infiniteScroll';
|
||||||
|
|
||||||
const toEnglishDigits = (str: string) => {
|
const toEnglishDigits = (str: string) => {
|
||||||
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
||||||
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d).toString());
|
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d).toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
export default function EditWorkspace() {
|
export default function EditWorkspace() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -52,6 +55,11 @@ export default function EditWorkspace() {
|
|||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [newMemberRole, setNewMemberRole] = useState<'owner' | 'admin' | 'member' | 'guest'>('member');
|
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
|
// Modal State
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
@@ -71,8 +79,12 @@ export default function EditWorkspace() {
|
|||||||
setMyRole(workspaceData.my_role || 'member');
|
setMyRole(workspaceData.my_role || 'member');
|
||||||
setWorkspaceOwnerId(workspaceData.owner || '');
|
setWorkspaceOwnerId(workspaceData.owner || '');
|
||||||
|
|
||||||
const membersData = await fetchWorkspaceMemberships(id!);
|
const membersData = await fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 });
|
||||||
setMembers(membersData);
|
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
|
||||||
|
|
||||||
|
setMembers(results);
|
||||||
|
setOffset(LIMIT);
|
||||||
|
setHasMore(!!membersData.next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.workspace?.toast?.errorLoad || "Failed to load workspace data.");
|
toast.error(t.workspace?.toast?.errorLoad || "Failed to load workspace data.");
|
||||||
navigate('/workspaces');
|
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(() => {
|
useEffect(() => {
|
||||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
const cleanQuery = toEnglishDigits(searchQuery.trim());
|
const cleanQuery = toEnglishDigits(searchQuery.trim());
|
||||||
@@ -116,8 +145,14 @@ export default function EditWorkspace() {
|
|||||||
if (!name.trim() || !id) return;
|
if (!name.trim() || !id) return;
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
await updateWorkspace(id, { name, description });
|
await updateWorkspace(id, { name, description });
|
||||||
|
|
||||||
toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully.");
|
toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully.");
|
||||||
|
window.dispatchEvent(new CustomEvent('workspace_edited', {
|
||||||
|
detail: { id, name, description }
|
||||||
|
}));
|
||||||
|
|
||||||
navigate('/workspaces');
|
navigate('/workspaces');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.workspace?.toast?.errorUpdate || "Failed to update workspace.");
|
toast.error(t.workspace?.toast?.errorUpdate || "Failed to update workspace.");
|
||||||
@@ -134,7 +169,7 @@ export default function EditWorkspace() {
|
|||||||
user: searchResult.id,
|
user: searchResult.id,
|
||||||
role: newMemberRole
|
role: newMemberRole
|
||||||
});
|
});
|
||||||
setMembers([...members, newMembership]);
|
setMembers([newMembership, ...members]);
|
||||||
toast.success(t.workspace?.toast?.successAdd || "Member added successfully.");
|
toast.success(t.workspace?.toast?.successAdd || "Member added successfully.");
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchResult(null);
|
setSearchResult(null);
|
||||||
@@ -297,6 +332,13 @@ export default function EditWorkspace() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<InfiniteScroll
|
||||||
|
onLoadMore={loadMoreMembers}
|
||||||
|
hasMore={hasMore}
|
||||||
|
isLoading={isLoadingMembers}
|
||||||
|
className="space-y-3"
|
||||||
|
loader={<div className="py-4 text-center text-sm text-slate-500 dark:text-slate-400">{t.workspace?.loading || "Loading more members..."}</div>}
|
||||||
|
>
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
|
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
|
||||||
const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner');
|
const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner');
|
||||||
@@ -354,7 +396,8 @@ export default function EditWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{members.length === 0 && (
|
</InfiniteScroll>
|
||||||
|
{members.length === 0 && !isLoadingMembers && (
|
||||||
<p className="text-sm text-center text-slate-500 py-4">
|
<p className="text-sm text-center text-slate-500 py-4">
|
||||||
{t.workspace?.noMembers || "No members found."}
|
{t.workspace?.noMembers || "No members found."}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppContext } from '../context/AppContext';
|
|||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
import FilterBar from '../components/FilterBar';
|
import FilterBar from '../components/FilterBar';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Pagination } from '../components/Pagination';
|
||||||
|
|
||||||
type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ export default function Workspaces() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [ordering, setOrdering] = useState('-updated_at');
|
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 [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
||||||
const [deleteInput, setDeleteInput] = useState('');
|
const [deleteInput, setDeleteInput] = useState('');
|
||||||
|
|
||||||
@@ -48,22 +54,37 @@ export default function Workspaces() {
|
|||||||
{ value: 'name', label: t.workspace?.orderByName || 'Name (A-Z)' },
|
{ value: 'name', label: t.workspace?.orderByName || 'Name (A-Z)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// وقتی جستجو یا ترتیب تغییر کرد، به صفحه اول برگرد
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, ordering]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
loadWorkspaces();
|
loadWorkspaces();
|
||||||
}, 400);
|
}, 400);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchQuery, ordering]);
|
}, [searchQuery, ordering, currentPage, limit]);
|
||||||
|
|
||||||
const loadWorkspaces = async () => {
|
const loadWorkspaces = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string | number> = {
|
||||||
|
limit: limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
};
|
||||||
|
|
||||||
if (searchQuery) params.search = searchQuery;
|
if (searchQuery) params.search = searchQuery;
|
||||||
if (ordering) params.ordering = ordering;
|
if (ordering) params.ordering = ordering;
|
||||||
|
|
||||||
const data = await fetchWorkspaces(params);
|
const data = await fetchWorkspaces(params as any);
|
||||||
setWorkspaces(data);
|
|
||||||
|
// استخراج هوشمند نتایج و تعداد کل
|
||||||
|
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) {
|
} catch (error) {
|
||||||
toast.error(t.workspace?.fetchError || 'Error fetching workspaces');
|
toast.error(t.workspace?.fetchError || 'Error fetching workspaces');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,8 +95,16 @@ export default function Workspaces() {
|
|||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!deleteModal.workspace) return;
|
if (!deleteModal.workspace) return;
|
||||||
try {
|
try {
|
||||||
await deleteWorkspace(deleteModal.workspace.id);
|
const deletedId = deleteModal.workspace.id;
|
||||||
setWorkspaces(workspaces.filter((w) => w.id !== deleteModal.workspace!.id));
|
await deleteWorkspace(deletedId);
|
||||||
|
|
||||||
|
loadWorkspaces();
|
||||||
|
|
||||||
|
// ارسال سیگنال به کل اپلیکیشن برای آپدیت نوار ناوبری
|
||||||
|
window.dispatchEvent(new CustomEvent('workspace_deleted', {
|
||||||
|
detail: { id: deletedId }
|
||||||
|
}));
|
||||||
|
|
||||||
toast.success(t.workspace?.deleteSuccess || 'Workspace deleted successfully');
|
toast.success(t.workspace?.deleteSuccess || 'Workspace deleted successfully');
|
||||||
setDeleteModal({ isOpen: false, workspace: null });
|
setDeleteModal({ isOpen: false, workspace: null });
|
||||||
setDeleteInput('');
|
setDeleteInput('');
|
||||||
@@ -85,18 +114,18 @@ export default function Workspaces() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto p-6">
|
<div className="flex flex-col p-6 max-w-6xl mx-auto min-h-[calc(100vh-73px)]">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title}</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle}</p>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate('/workspaces/create')}
|
onClick={() => navigate('/workspaces/create')}
|
||||||
className="gap-2 rounded-xl shadow-sm"
|
className="gap-2 rounded-xl shadow-sm"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{t.workspace?.createNew}
|
{t.workspace?.createNew || 'Create New'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,7 +135,7 @@ export default function Workspaces() {
|
|||||||
ordering={ordering}
|
ordering={ordering}
|
||||||
setOrdering={setOrdering}
|
setOrdering={setOrdering}
|
||||||
orderingOptions={orderingOptions}
|
orderingOptions={orderingOptions}
|
||||||
searchPlaceholder={t.workspace?.searchPlaceholder}
|
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -114,6 +143,7 @@ export default function Workspaces() {
|
|||||||
<div className="animate-pulse">{t.workspace?.loading || 'Loading...'}</div>
|
<div className="animate-pulse">{t.workspace?.loading || 'Loading...'}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
{workspaces.map((workspace) => {
|
{workspaces.map((workspace) => {
|
||||||
const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner';
|
const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner';
|
||||||
@@ -132,7 +162,7 @@ export default function Workspaces() {
|
|||||||
<RoleBadge role={workspace.my_role} />
|
<RoleBadge role={workspace.my_role} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||||
{workspace.description || t.workspace?.noDescription}
|
{workspace.description || t.workspace?.noDescription || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,7 +172,7 @@ export default function Workspaces() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(`/workspaces/${workspace.id}`)}
|
onClick={() => navigate(`/workspaces/${workspace.id}`)}
|
||||||
className="rounded-xl hover:text-blue-600 dark:hover:text-blue-400"
|
className="rounded-xl hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
title={t.workspace?.view}
|
title={t.workspace?.view || 'View'}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -153,7 +183,7 @@ export default function Workspaces() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
|
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
|
||||||
className="rounded-xl hover:text-emerald-600 dark:hover:text-emerald-400"
|
className="rounded-xl hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||||
title={t.workspace?.edit}
|
title={t.workspace?.edit || 'Edit'}
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -165,7 +195,7 @@ export default function Workspaces() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setDeleteModal({ isOpen: true, workspace })}
|
onClick={() => setDeleteModal({ isOpen: true, workspace })}
|
||||||
className="rounded-xl hover:text-red-600 dark:hover:text-red-400"
|
className="rounded-xl hover:text-red-600 dark:hover:text-red-400"
|
||||||
title={t.workspace?.delete}
|
title={t.workspace?.delete || 'Delete'}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -181,12 +211,23 @@ export default function Workspaces() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
pageSizeOptions={[9, 12, 24]} // Custom options for Workspaces
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
{deleteModal.isOpen && deleteModal.workspace && (
|
{deleteModal.isOpen && deleteModal.workspace && (
|
||||||
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-2xl p-6 max-w-md w-full shadow-2xl border border-slate-200 dark:border-slate-800">
|
<div className="bg-white dark:bg-slate-900 rounded-2xl p-6 max-w-md w-full shadow-2xl border border-slate-200 dark:border-slate-800">
|
||||||
<h3 className="text-xl font-bold text-red-600 mb-2">{t.workspace?.deleteTitle || 'Delete Workspace'}</h3>
|
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">{t.workspace?.deleteTitle || 'Delete Workspace'}</h3>
|
||||||
<p className="text-slate-600 dark:text-slate-400 mb-5 text-sm leading-relaxed">
|
<p className="text-slate-600 dark:text-slate-400 mb-5 text-sm leading-relaxed">
|
||||||
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
|
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user