Compare commits
10 Commits
22f8f71976
...
441cc0c008
| Author | SHA1 | Date | |
|---|---|---|---|
| 441cc0c008 | |||
| 71103b9d8e | |||
| 987d2e2b59 | |||
| c4d8379924 | |||
| 790e5f1dba | |||
| dfe280d9a1 | |||
| 57e727da19 | |||
| 99257ef70f | |||
| 501e6c7ed2 | |||
| 0dddaa8185 |
@@ -19,5 +19,12 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-empty-object-type": "off",
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
63
src/App.tsx
63
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from "react-router-dom"
|
import { createBrowserRouter, RouterProvider, Navigate, Outlet } from "react-router-dom"
|
||||||
import { ThemeProvider } from "./components/ThemeProvider"
|
import { ThemeProvider } from "./components/ThemeProvider"
|
||||||
import { LanguageProvider } from "./components/LanguageProvider"
|
import { LanguageProvider } from "./components/LanguageProvider"
|
||||||
import { Toaster } from "./components/ui/toaster"
|
import { Toaster } from "./components/ui/toaster"
|
||||||
@@ -13,16 +13,21 @@ import CreateWorkspace from "./pages/WorkspaceCreate"
|
|||||||
import WorkspaceDetail from "./pages/WorkspaceDetail"
|
import WorkspaceDetail from "./pages/WorkspaceDetail"
|
||||||
import EditWorkspace from "./pages/WorkspaceEdit"
|
import EditWorkspace from "./pages/WorkspaceEdit"
|
||||||
import Clients from "./pages/Clients"
|
import Clients from "./pages/Clients"
|
||||||
|
import { Projects } from "./pages/Projects"
|
||||||
|
import ProjectCreate from "./pages/ProjectCreate"
|
||||||
|
import ProjectEdit from "./pages/ProjectEdit"
|
||||||
|
import Tags from "./pages/Tags"
|
||||||
|
import Timesheet from "./pages/Timesheet"
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-slate-50 dark:bg-slate-950 overflow-hidden text-slate-900 dark:text-slate-100">
|
<div className="flex h-screen bg-slate-50 dark:bg-slate-950 overflow-hidden text-slate-900 dark:text-slate-100">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
<div className="flex-1 flex flex-col h-screen overflow-y-auto relative">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto relative">
|
<main className="flex-1 relative">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,31 +37,45 @@ const MainLayout = () => {
|
|||||||
|
|
||||||
const RootRedirect = () => {
|
const RootRedirect = () => {
|
||||||
const isAuthenticated = !!localStorage.getItem("accessToken")
|
const isAuthenticated = !!localStorage.getItem("accessToken")
|
||||||
return isAuthenticated ? <Navigate to="/workspaces" replace /> : <Navigate to="/auth" replace />
|
return isAuthenticated ? <Navigate to="/timesheet" replace /> : <Navigate to="/auth" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
element: (
|
||||||
|
<WorkspaceProvider>
|
||||||
|
<Outlet />
|
||||||
|
</WorkspaceProvider>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ path: "/", element: <RootRedirect /> },
|
||||||
|
{ path: "/auth", element: <Auth /> },
|
||||||
|
{ path: "/terms", element: <Terms /> },
|
||||||
|
{
|
||||||
|
element: <MainLayout />,
|
||||||
|
children: [
|
||||||
|
{ path: "/profile", element: <Profile /> },
|
||||||
|
{ path: "/timesheet", element: <Timesheet /> },
|
||||||
|
{ path: "/tags", element: <Tags /> },
|
||||||
|
{ path: "/workspaces", element: <Workspaces /> },
|
||||||
|
{ path: "/workspaces/create", element: <CreateWorkspace /> },
|
||||||
|
{ path: "/workspaces/:id", element: <WorkspaceDetail /> },
|
||||||
|
{ path: "/workspaces/:id/edit", element: <EditWorkspace /> },
|
||||||
|
{ path: "/clients", element: <Clients /> },
|
||||||
|
{ path: "/projects", element: <Projects /> },
|
||||||
|
{ path: "/projects/create", element: <ProjectCreate /> },
|
||||||
|
{ path: "/projects/:id/edit", element: <ProjectEdit /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<Router>
|
<RouterProvider router={router} />
|
||||||
<WorkspaceProvider>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<RootRedirect />} />
|
|
||||||
<Route path="/auth" element={<Auth />} />
|
|
||||||
<Route path="/terms" element={<Terms />} />
|
|
||||||
|
|
||||||
<Route element={<MainLayout />}>
|
|
||||||
<Route path="/profile" element={<Profile />} />
|
|
||||||
<Route path="/workspaces" element={<Workspaces />} />
|
|
||||||
<Route path="/workspaces/create" element={<CreateWorkspace />} />
|
|
||||||
<Route path="/workspaces/:id" element={<WorkspaceDetail />} />
|
|
||||||
<Route path="/workspaces/:id/edit" element={<EditWorkspace />} />
|
|
||||||
<Route path="/clients" element={<Clients />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</WorkspaceProvider>
|
|
||||||
</Router>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,49 +1,142 @@
|
|||||||
import { API_BASE_URL } from "../config/constants"
|
import { API_BASE_URL } from "../config/constants"
|
||||||
|
|
||||||
export const authFetch = async (endpoint: string, options: RequestInit = {}) => {
|
let refreshRequest: Promise<string | null> | null = null
|
||||||
const token = localStorage.getItem("accessToken")
|
|
||||||
const isFormData = options.body instanceof FormData
|
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const buildUrl = (endpoint: string) => {
|
||||||
...(!isFormData && { "Content-Type": "application/json" }),
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
return `${cleanBaseUrl}${cleanEndpoint}`
|
||||||
...options.headers,
|
}
|
||||||
}
|
|
||||||
|
const normalizeJsonResponse = (response: Response) => {
|
||||||
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
const originalJson = response.json.bind(response)
|
||||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
|
||||||
const url = `${cleanBaseUrl}${cleanEndpoint}`
|
response.json = async () => {
|
||||||
|
if (response.status === 204 || response.status === 205) {
|
||||||
const response = await fetch(url, {
|
return {}
|
||||||
...options,
|
}
|
||||||
headers,
|
|
||||||
})
|
const data = await originalJson()
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (data && typeof data === "object" && "items" in data && "pages_count" in data) {
|
||||||
localStorage.removeItem("accessToken")
|
return {
|
||||||
localStorage.removeItem("refreshToken")
|
count: data.total_items || 0,
|
||||||
window.location.href = "/auth"
|
next: null,
|
||||||
return response
|
previous: null,
|
||||||
}
|
results: data.items || [],
|
||||||
|
_meta: {
|
||||||
const originalJson = response.json.bind(response)
|
pages_count: data.pages_count,
|
||||||
response.json = async () => {
|
items_per_page: data.items_per_page,
|
||||||
const data = await originalJson()
|
current_page: data.current_page,
|
||||||
|
},
|
||||||
if (data && typeof data === "object" && "items" in data && "pages_count" in data) {
|
}
|
||||||
return {
|
}
|
||||||
count: data.total_items || 0,
|
|
||||||
results: data.items || [],
|
return data
|
||||||
_meta: {
|
}
|
||||||
pages_count: data.pages_count,
|
|
||||||
items_per_page: data.items_per_page,
|
return response
|
||||||
current_page: data.current_page
|
}
|
||||||
}
|
|
||||||
}
|
const clearSessionAndRedirect = () => {
|
||||||
}
|
localStorage.removeItem("accessToken")
|
||||||
|
localStorage.removeItem("refreshToken")
|
||||||
return data
|
if (window.location.pathname !== "/auth") {
|
||||||
}
|
window.location.href = "/auth"
|
||||||
|
}
|
||||||
return response
|
}
|
||||||
}
|
|
||||||
|
const shouldAttemptRefresh = (endpoint: string) => {
|
||||||
|
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||||
|
return ![
|
||||||
|
"/api/users/login/",
|
||||||
|
"/api/users/otp/send/",
|
||||||
|
"/api/users/otp/login/",
|
||||||
|
"/api/users/token/refresh/",
|
||||||
|
].includes(normalizedEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
const refreshToken = localStorage.getItem("refreshToken")
|
||||||
|
if (!refreshToken) return null
|
||||||
|
|
||||||
|
if (!refreshRequest) {
|
||||||
|
refreshRequest = (async () => {
|
||||||
|
const response = await fetch(buildUrl("/api/users/token/refresh/"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh: refreshToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const nextAccessToken = typeof data?.access === "string" ? data.access : null
|
||||||
|
const nextRefreshToken = typeof data?.refresh === "string" ? data.refresh : null
|
||||||
|
|
||||||
|
if (!nextAccessToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("accessToken", nextAccessToken)
|
||||||
|
if (nextRefreshToken) {
|
||||||
|
localStorage.setItem("refreshToken", nextRefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextAccessToken
|
||||||
|
})().finally(() => {
|
||||||
|
refreshRequest = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise<Response> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildUrl(endpoint), {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && allowRetry && shouldAttemptRefresh(endpoint)) {
|
||||||
|
const nextAccessToken = await refreshAccessToken()
|
||||||
|
|
||||||
|
if (nextAccessToken) {
|
||||||
|
return authFetch(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${nextAccessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionAndRedirect()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 && shouldAttemptRefresh(endpoint)) {
|
||||||
|
clearSessionAndRedirect()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeJsonResponse(response)
|
||||||
|
}
|
||||||
|
|||||||
183
src/api/projects.ts
Normal file
183
src/api/projects.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
|
export interface ProjectClient {
|
||||||
|
id: 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 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
is_archived: boolean;
|
||||||
|
workspace: string;
|
||||||
|
client: ProjectClient | null;
|
||||||
|
my_role?: string;
|
||||||
|
members?: ProjectMembership[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPayload {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
is_archived: boolean;
|
||||||
|
workspace: string;
|
||||||
|
client: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProjects = async (
|
||||||
|
workspaceId: string,
|
||||||
|
params: { limit?: number; offset?: number; search?: string; client?: string; is_archived?: boolean, ordering?: string } = {}
|
||||||
|
) => {
|
||||||
|
const queryParams = new URLSearchParams({ workspace: workspaceId });
|
||||||
|
|
||||||
|
if (params.limit !== undefined) queryParams.append("limit", params.limit.toString());
|
||||||
|
if (params.offset !== undefined) queryParams.append("offset", params.offset.toString());
|
||||||
|
if (params.search) queryParams.append("search", params.search);
|
||||||
|
if (params.client) queryParams.append("client", params.client);
|
||||||
|
if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString());
|
||||||
|
if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString());
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/projects/?${queryParams.toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch projects");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProject = async (id: string) => {
|
||||||
|
const response = await authFetch(`/api/projects/${id}/`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to fetch project");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProject = async (
|
||||||
|
data: Partial<ProjectPayload> & {
|
||||||
|
workspace: string;
|
||||||
|
name: string;
|
||||||
|
members?: ProjectMemberPayload[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const response = await authFetch("/api/projects/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to create project");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProject = async (
|
||||||
|
id: string,
|
||||||
|
data: Partial<ProjectPayload> & { members?: ProjectMemberPayload[] }
|
||||||
|
) => {
|
||||||
|
const response = await authFetch(`/api/projects/${id}/`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to update project");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProject = async (id: string) => {
|
||||||
|
const response = await authFetch(`/api/projects/${id}/`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to delete project");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return { success: true };
|
||||||
|
return response.json().catch(() => ({ success: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleArchiveProject = async (id: string) => {
|
||||||
|
const response = await authFetch(`/api/projects/${id}/archive/`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`);
|
||||||
|
}
|
||||||
|
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 }));
|
||||||
|
};
|
||||||
61
src/api/tags.ts
Normal file
61
src/api/tags.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
workspace: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTags = async (
|
||||||
|
workspaceId: string,
|
||||||
|
params: { limit?: number; offset?: number; search?: string; ordering?: string } = {},
|
||||||
|
): Promise<PaginatedResponse<Tag>> => {
|
||||||
|
const query = new URLSearchParams({ workspace: workspaceId });
|
||||||
|
|
||||||
|
if (params.limit !== undefined) query.append("limit", String(params.limit));
|
||||||
|
if (params.offset !== undefined) query.append("offset", String(params.offset));
|
||||||
|
if (params.search) query.append("search", params.search);
|
||||||
|
if (params.ordering) query.append("ordering", params.ordering);
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/tags/?${query.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch tags");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTag = async (workspaceId: string, data: { name: string; color: string }) => {
|
||||||
|
const response = await authFetch("/api/tags/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
...data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to create tag");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTag = async (id: string, data: Partial<Pick<Tag, "name" | "color">>) => {
|
||||||
|
const response = await authFetch(`/api/tags/${id}/`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to update tag");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTag = async (id: string) => {
|
||||||
|
const response = await authFetch(`/api/tags/${id}/`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to delete tag");
|
||||||
|
};
|
||||||
122
src/api/timeEntries.ts
Normal file
122
src/api/timeEntries.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
|
export interface TimeEntry {
|
||||||
|
id: string;
|
||||||
|
workspace: string;
|
||||||
|
user: string;
|
||||||
|
project: string | null;
|
||||||
|
description: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
tags: string[];
|
||||||
|
is_billable: boolean;
|
||||||
|
hourly_rate: string | null;
|
||||||
|
currency: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryGroupDay {
|
||||||
|
key: string;
|
||||||
|
date: string;
|
||||||
|
total_ms: number;
|
||||||
|
entries: TimeEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryGroupWeek {
|
||||||
|
key: string;
|
||||||
|
week_start: string;
|
||||||
|
week_end: string;
|
||||||
|
total_ms: number;
|
||||||
|
days: TimeEntryGroupDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedTimeEntryResponse {
|
||||||
|
items_per_page: number;
|
||||||
|
current_page_items_count: number;
|
||||||
|
total_items: number;
|
||||||
|
offset: number;
|
||||||
|
next_offset: number | null;
|
||||||
|
has_more: boolean;
|
||||||
|
groups: TimeEntryGroupWeek[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryPayload {
|
||||||
|
workspace_id?: string;
|
||||||
|
project_id?: string | null;
|
||||||
|
description?: string;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
is_billable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryListParams {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
search?: string;
|
||||||
|
status?: "running" | "ended" | "all";
|
||||||
|
project?: string;
|
||||||
|
client?: string;
|
||||||
|
tags?: string[];
|
||||||
|
started_after?: string;
|
||||||
|
started_before?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTimeEntries = async (
|
||||||
|
workspaceId: string,
|
||||||
|
params: TimeEntryListParams = {},
|
||||||
|
): Promise<GroupedTimeEntryResponse> => {
|
||||||
|
const query = new URLSearchParams({ workspace: workspaceId });
|
||||||
|
|
||||||
|
if (params.limit !== undefined) query.append("limit", String(params.limit));
|
||||||
|
if (params.offset !== undefined) query.append("offset", String(params.offset));
|
||||||
|
if (params.search) query.append("search", params.search);
|
||||||
|
if (params.status) query.append("status", params.status);
|
||||||
|
if (params.project) query.append("project", params.project);
|
||||||
|
if (params.client) query.append("client", params.client);
|
||||||
|
if (params.started_after) query.append("started_after", params.started_after);
|
||||||
|
if (params.started_before) query.append("started_before", params.started_before);
|
||||||
|
if (params.tags?.length) {
|
||||||
|
params.tags.forEach((tagId) => query.append("tags", tagId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/time-entries/?${query.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch time entries");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTimeEntry = async (payload: TimeEntryPayload) => {
|
||||||
|
const response = await authFetch("/api/time-entries/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to create time entry");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => {
|
||||||
|
const response = await authFetch(`/api/time-entries/${id}/`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to update time entry");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopTimeEntry = async (id: string, endTime?: string) => {
|
||||||
|
const response = await authFetch(`/api/time-entries/${id}/stop/`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(endTime ? { end_time: endTime } : {}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to stop time entry");
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTimeEntry = async (id: string) => {
|
||||||
|
const response = await authFetch(`/api/time-entries/${id}/`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to delete time entry");
|
||||||
|
};
|
||||||
@@ -20,14 +20,14 @@ export const sendOtp = async (mobile: string, mode: string) => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loginWithOtp = async (mobile: string, otp: string) => {
|
export const loginWithOtp = async (mobile: string, otp: string) => {
|
||||||
const response = await authFetch('/api/users/otp/login/', {
|
const response = await authFetch('/api/users/otp/login/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mobile, otp })
|
body: JSON.stringify({ mobile, code: otp })
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to login with OTP');
|
if (!response.ok) throw new Error('Failed to login with OTP');
|
||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logoutUser = async (refreshToken: string) => {
|
export const logoutUser = async (refreshToken: string) => {
|
||||||
const response = await authFetch('/api/users/logout/', {
|
const response = await authFetch('/api/users/logout/', {
|
||||||
@@ -69,15 +69,13 @@ export const updateProfilePicture = async (file: File) => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeProfilePicture = async () => {
|
export const removeProfilePicture = async () => {
|
||||||
const formData = new FormData();
|
const response = await authFetch(`/api/users/profile/picture/`, {
|
||||||
formData.append('profile_picture', '');
|
method: 'DELETE',
|
||||||
|
});
|
||||||
return authFetch(`/api/users/profile/picture/`, {
|
if (!response.ok) throw new Error('Failed to remove profile picture');
|
||||||
method: 'POST',
|
return response.json();
|
||||||
body: formData,
|
};
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export interface SearchedUser {
|
export interface SearchedUser {
|
||||||
@@ -96,4 +94,4 @@ export const searchUserByExactMobile = async (mobile: string): Promise<SearchedU
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { authFetch } from "./client";
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
export interface Workspace {
|
export interface Workspace {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
@@ -16,9 +16,9 @@ export interface PaginatedResponse<T> {
|
|||||||
results: T[];
|
results: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceMembership {
|
export interface WorkspaceMembership {
|
||||||
id: string;
|
id: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -29,14 +29,27 @@ export interface WorkspaceMembership {
|
|||||||
role: 'owner' | 'admin' | 'member' | 'guest';
|
role: 'owner' | 'admin' | 'member' | 'guest';
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
joined_at?: string;
|
joined_at?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchWorkspaces = async (params?: Record<string, string>): Promise<PaginatedResponse<Workspace>> => {
|
type QueryValue = string | number | boolean | undefined | null;
|
||||||
const query = params ? new URLSearchParams(params).toString() : '';
|
|
||||||
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
|
const toQueryString = (params?: Record<string, QueryValue>) => {
|
||||||
const response = await authFetch(url);
|
if (!params) return "";
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
query.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return query.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchWorkspaces = async (params?: Record<string, QueryValue>): Promise<PaginatedResponse<Workspace>> => {
|
||||||
|
const query = toQueryString(params);
|
||||||
|
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
|
||||||
|
const response = await authFetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch workspaces");
|
throw new Error("Failed to fetch workspaces");
|
||||||
@@ -98,9 +111,9 @@ export const deleteWorkspace = async (id: string): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchWorkspaceMemberships = async (params?: Record<string, string>): Promise<PaginatedResponse<WorkspaceMembership>> => {
|
export const fetchWorkspaceMemberships = async (params?: Record<string, QueryValue>): Promise<PaginatedResponse<WorkspaceMembership>> => {
|
||||||
const queryParams = new URLSearchParams((params || {}));
|
const queryParams = toQueryString(params);
|
||||||
const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`);
|
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");
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,28 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
|||||||
loader,
|
loader,
|
||||||
}) => {
|
}) => {
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
const onLoadMoreRef = useRef(onLoadMore);
|
||||||
|
const hasMoreRef = useRef(hasMore);
|
||||||
|
const isLoadingRef = useRef(isLoading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLoadMoreRef.current = onLoadMore;
|
||||||
|
hasMoreRef.current = hasMore;
|
||||||
|
isLoadingRef.current = isLoading;
|
||||||
|
}, [onLoadMore, hasMore, isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
if (entries[0].isIntersecting && hasMoreRef.current && !isLoadingRef.current) {
|
||||||
onLoadMore();
|
onLoadMoreRef.current();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 0.1 }
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: "200px",
|
||||||
|
threshold: 0
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (observerTarget.current) {
|
if (observerTarget.current) {
|
||||||
@@ -34,12 +47,13 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMore, isLoading, onLoadMore]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{children}
|
{children}
|
||||||
{hasMore && <div ref={observerTarget} className="h-2 w-full" />}
|
<div ref={observerTarget} className={`h-4 w-full ${!hasMore ? 'hidden' : ''}`} />
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
loader || (
|
loader || (
|
||||||
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ import { X } from "lucide-react";
|
|||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
footer?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
maxWidth?: string;
|
footer?: React.ReactNode;
|
||||||
isFa?: boolean;
|
maxWidth?: string;
|
||||||
}
|
isFa?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<ModalProps> = ({
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
footer,
|
description,
|
||||||
maxWidth = "max-w-lg",
|
footer,
|
||||||
}) => {
|
maxWidth = "max-w-lg",
|
||||||
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -34,16 +36,16 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`w-full ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -58,14 +60,21 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
<div className="flex-1 overflow-y-auto overscroll-contain p-4 md:p-5">
|
||||||
|
{description && (
|
||||||
{footer && (
|
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||||
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/50 shrink-0 flex justify-end gap-3">
|
)}
|
||||||
{footer}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Card>
|
{footer && (
|
||||||
</div>
|
<div className="shrink-0 border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/50">
|
||||||
);
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
} from 'lucide-react';
|
Clock3,
|
||||||
|
Tags,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
@@ -21,10 +23,20 @@ export const Sidebar = () => {
|
|||||||
? (isCollapsed ? PanelRightOpen : PanelRightClose)
|
? (isCollapsed ? PanelRightOpen : PanelRightClose)
|
||||||
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
path: '/workspaces',
|
path: '/timesheet',
|
||||||
icon: LayoutDashboard,
|
icon: Clock3,
|
||||||
|
label: t.sidebar?.timesheet || 'Timesheet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tags',
|
||||||
|
icon: Tags,
|
||||||
|
label: t.sidebar?.tags || 'Tags'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/workspaces',
|
||||||
|
icon: LayoutDashboard,
|
||||||
label: t.sidebar?.workspaces || 'Workspaces'
|
label: t.sidebar?.workspaces || 'Workspaces'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,22 +40,22 @@ export const WorkspaceSelector: React.FC = () => {
|
|||||||
refreshWorkspacesList();
|
refreshWorkspacesList();
|
||||||
}) as EventListener;
|
}) as EventListener;
|
||||||
|
|
||||||
const handleWorkspaceCreated = ((e: CustomEvent) => {
|
const handleWorkspaceCreated = ((e: CustomEvent) => {
|
||||||
if (e.detail) {
|
if (e.detail?.id) {
|
||||||
setActiveWorkspace(e.detail);
|
setActiveWorkspace(e.detail);
|
||||||
}
|
}
|
||||||
refreshWorkspacesList();
|
refreshWorkspacesList();
|
||||||
}) as EventListener;
|
}) as EventListener;
|
||||||
|
|
||||||
const handleWorkspaceEdited = ((e: CustomEvent) => {
|
const handleWorkspaceEdited = ((e: CustomEvent) => {
|
||||||
// آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد
|
// آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد
|
||||||
if (activeWorkspace?.id === e.detail?.id) {
|
if (activeWorkspace?.id === e.detail?.id) {
|
||||||
setActiveWorkspace({
|
setActiveWorkspace({
|
||||||
...activeWorkspace,
|
...activeWorkspace,
|
||||||
name: e.detail.name,
|
name: e.detail.name,
|
||||||
description: e.detail.description
|
description: e.detail.description
|
||||||
});
|
} as Workspace);
|
||||||
}
|
}
|
||||||
refreshWorkspacesList();
|
refreshWorkspacesList();
|
||||||
}) as EventListener;
|
}) as EventListener;
|
||||||
|
|
||||||
|
|||||||
142
src/components/projects/ProjectCreateModal.tsx
Normal file
142
src/components/projects/ProjectCreateModal.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
|
import { Modal } from "../Modal";
|
||||||
|
import { createProject } from "../../api/projects";
|
||||||
|
import { getClients } from "../../api/clients";
|
||||||
|
import { useWorkspace } from "../../context/WorkspaceContext";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { TextAreaInput } from "../ui/TextAreaInput";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface ProjectCreateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: "#3B82F6",
|
||||||
|
client: "",
|
||||||
|
});
|
||||||
|
const [loadingClients, setLoadingClients] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && activeWorkspace) {
|
||||||
|
setLoadingClients(true);
|
||||||
|
getClients(activeWorkspace.id)
|
||||||
|
.then((res: any) => setClients(res.results || res))
|
||||||
|
.catch((err) => toast.error(t.projects?.clientFetchError || err.message || "Failed to load clients"))
|
||||||
|
.finally(() => setLoadingClients(false));
|
||||||
|
}
|
||||||
|
}, [isOpen, activeWorkspace]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!activeWorkspace || !formData.name) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const newProject = await createProject({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
color: formData.color,
|
||||||
|
client: formData.client || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
||||||
|
onClose();
|
||||||
|
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700">
|
||||||
|
{t.actions?.cancel || "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{loading ? "..." : t.projects?.create}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* ردیف اول: عنوان و انتخاب رنگ */}
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.projects.titleLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
placeholder={t.projects?.titlePlaceholder}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center shrink-0">
|
||||||
|
{/* یک لیبل مخفی برای همتراز شدن دقیق دایره با اینپوت */}
|
||||||
|
<div className="mb-1 text-sm font-medium invisible">C</div>
|
||||||
|
<div
|
||||||
|
className="relative w-10 h-10 rounded-full overflow-hidden border border-slate-300 dark:border-slate-600 shadow-sm cursor-pointer shrink-0"
|
||||||
|
title={t.projects.colorLabel}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||||
|
className="absolute -top-2 -left-2 w-16 h-16 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.projects?.descriptionLabel || 'Description'}
|
||||||
|
</label>
|
||||||
|
<TextAreaInput
|
||||||
|
value={formData.description}
|
||||||
|
placeholder={t.projects?.titlePlaceholder}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500 min-h-[80px] resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.projects.clientLabel}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={formData.client}
|
||||||
|
onChange={(val) => setFormData({ ...formData, client: val })}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.projects.noClient },
|
||||||
|
...clients.map(c => ({ value: c.id, label: c.name }))
|
||||||
|
]}
|
||||||
|
isLoading={loadingClients}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
181
src/components/projects/ProjectEditModal.tsx
Normal file
181
src/components/projects/ProjectEditModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
|
import { Modal } from "../Modal";
|
||||||
|
import { updateProject, toggleArchiveProject } from "../../api/projects";
|
||||||
|
import { getClients } from "../../api/clients";
|
||||||
|
import { useWorkspace } from "../../context/WorkspaceContext";
|
||||||
|
import { Archive, RefreshCcw } from "lucide-react";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { TextAreaInput } from "../ui/TextAreaInput";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface ProjectEditModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
project: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onClose, project }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: "#3B82F6",
|
||||||
|
client: "",
|
||||||
|
});
|
||||||
|
const [loadingClients, setLoadingClients] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && activeWorkspace) {
|
||||||
|
setLoadingClients(true);
|
||||||
|
getClients(activeWorkspace.id)
|
||||||
|
.then((res: any) => setClients(res.results || res))
|
||||||
|
.catch((err) => toast.error(t.projects?.clientFetchError || err.message || "Failed to load clients"))
|
||||||
|
.finally(() => setLoadingClients(false));
|
||||||
|
}
|
||||||
|
}, [isOpen, activeWorkspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (project) {
|
||||||
|
setFormData({
|
||||||
|
name: project.name || "",
|
||||||
|
description: project.description || "",
|
||||||
|
color: project.color || "#3B82F6",
|
||||||
|
client: project.client ? project.client.id : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!project || !formData.name) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateProject(project.id, {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
color: formData.color,
|
||||||
|
client: formData.client || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveToggle = async () => {
|
||||||
|
if (!project) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await toggleArchiveProject(project.id);
|
||||||
|
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<button
|
||||||
|
onClick={handleArchiveToggle}
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border ${
|
||||||
|
project?.is_archived
|
||||||
|
? "text-green-600 border-green-600 hover:bg-green-50"
|
||||||
|
: "text-amber-600 border-amber-600 hover:bg-amber-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
||||||
|
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
|
||||||
|
{t.actions?.cancel || "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{loading ? "..." : t.save || "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.projects.titleLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
placeholder={t.projects?.titlePlaceholder}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center shrink-0">
|
||||||
|
<div className="mb-1 text-sm font-medium invisible">C</div>
|
||||||
|
<div
|
||||||
|
className="relative w-10 h-10 rounded-full overflow-hidden border border-slate-300 dark:border-slate-600 shadow-sm cursor-pointer shrink-0"
|
||||||
|
title={t.projects.colorLabel}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||||
|
className="absolute -top-2 -left-2 w-16 h-16 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.projects?.descriptionLabel || 'Description'}
|
||||||
|
</label>
|
||||||
|
<TextAreaInput
|
||||||
|
value={formData.description}
|
||||||
|
placeholder={t.projects?.titlePlaceholder}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 outline-none focus:ring-2 focus:ring-blue-500 min-h-[80px] resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.projects.clientLabel}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={formData.client}
|
||||||
|
onChange={(val) => setFormData({ ...formData, client: val })}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.projects.noClient },
|
||||||
|
...clients.map(c => ({ value: c.id, label: c.name }))
|
||||||
|
]}
|
||||||
|
isLoading={loadingClients}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
385
src/components/timesheet/TimesheetFilterBar.tsx
Normal file
385
src/components/timesheet/TimesheetFilterBar.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { BriefcaseBusiness, CalendarRange, Check, ChevronDown, FolderKanban, Search, SlidersHorizontal, Tag as TagIcon, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { Project } from "../../api/projects";
|
||||||
|
import type { Tag } from "../../api/tags";
|
||||||
|
import JalaliDatePicker from "../ui/JalaliDatePicker";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
|
||||||
|
export interface TimeEntryFilters {
|
||||||
|
projectId: string;
|
||||||
|
clientId: string;
|
||||||
|
tagIds: string[];
|
||||||
|
startedAfter: string;
|
||||||
|
startedBefore: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimesheetFilterBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
filters: TimeEntryFilters;
|
||||||
|
onApply: (searchQuery: string, filters: TimeEntryFilters) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
projects: Project[];
|
||||||
|
tags: Tag[];
|
||||||
|
searchPlaceholder: string;
|
||||||
|
labels?: {
|
||||||
|
project?: string;
|
||||||
|
client?: string;
|
||||||
|
tags?: string;
|
||||||
|
clear?: string;
|
||||||
|
customFrom?: string;
|
||||||
|
customTo?: string;
|
||||||
|
allClients?: string;
|
||||||
|
allProjects?: string;
|
||||||
|
allTags?: string;
|
||||||
|
showFilters?: string;
|
||||||
|
hideFilters?: string;
|
||||||
|
apply?: string;
|
||||||
|
clientPrefix?: string;
|
||||||
|
projectPrefix?: string;
|
||||||
|
tagPrefix?: string;
|
||||||
|
fromPrefix?: string;
|
||||||
|
toPrefix?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterTagMultiSelect({
|
||||||
|
tags,
|
||||||
|
selectedTagIds,
|
||||||
|
onChange,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
tags: Tag[];
|
||||||
|
selectedTagIds: string[];
|
||||||
|
onChange: (tagIds: string[]) => void;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (!wrapperRef.current?.contains(target) && !dropdownRef.current?.contains(target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !buttonRef.current) return;
|
||||||
|
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const dropdownWidth = Math.max(rect.width, 260);
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const openUpward = spaceBelow < 280 && rect.top > spaceBelow;
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: "fixed",
|
||||||
|
top: openUpward ? `${rect.top - 6}px` : `${rect.bottom + 6}px`,
|
||||||
|
left: `${Math.max(12, rect.right - dropdownWidth)}px`,
|
||||||
|
width: `${dropdownWidth}px`,
|
||||||
|
transform: openUpward ? "translateY(-100%)" : "none",
|
||||||
|
zIndex: 100000,
|
||||||
|
});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeOnViewportChange = () => setIsOpen(false);
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener("resize", closeOnViewportChange);
|
||||||
|
window.addEventListener("scroll", closeOnViewportChange, true);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", closeOnViewportChange);
|
||||||
|
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
||||||
|
const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
className="flex h-8 w-full items-center gap-2 rounded-md border border-slate-200 bg-white px-2.5 text-sm text-slate-700 transition-colors hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:border-slate-600"
|
||||||
|
>
|
||||||
|
<TagIcon className="h-3.5 w-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() =>
|
||||||
|
onChange(
|
||||||
|
selected
|
||||||
|
? selectedTagIds.filter((tagId) => tagId !== tag.id)
|
||||||
|
: [...selectedTagIds, tag.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
|
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2 truncate">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
</span>
|
||||||
|
{selected && <Check className="h-4 w-4 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniFilterBlock({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
<div className="mb-1 inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimesheetFilterBar({
|
||||||
|
searchQuery,
|
||||||
|
filters,
|
||||||
|
onApply,
|
||||||
|
onClearFilters,
|
||||||
|
projects,
|
||||||
|
tags,
|
||||||
|
searchPlaceholder,
|
||||||
|
labels,
|
||||||
|
}: TimesheetFilterBarProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [draftSearchQuery, setDraftSearchQuery] = useState(searchQuery);
|
||||||
|
const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftSearchQuery(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftFilters(filters);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const clients = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Map(
|
||||||
|
projects
|
||||||
|
.filter((project) => project.client)
|
||||||
|
.map((project) => [project.client!.id, { value: project.client!.id, label: project.client!.name }]),
|
||||||
|
).values(),
|
||||||
|
),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedClient = clients.find((client) => client.value === filters.clientId) || null;
|
||||||
|
const selectedProject = projects.find((project) => project.id === filters.projectId) || null;
|
||||||
|
const selectedTags = tags.filter((tag) => filters.tagIds.includes(tag.id));
|
||||||
|
|
||||||
|
const activeChips = [
|
||||||
|
filters.startedAfter ? `${labels?.fromPrefix || labels?.customFrom || "From"}: ${filters.startedAfter}` : null,
|
||||||
|
filters.startedBefore ? `${labels?.toPrefix || labels?.customTo || "To"}: ${filters.startedBefore}` : null,
|
||||||
|
selectedClient ? `${labels?.clientPrefix || labels?.client || "Client"}: ${selectedClient.label}` : null,
|
||||||
|
selectedProject ? `${labels?.projectPrefix || labels?.project || "Project"}: ${selectedProject.name}` : null,
|
||||||
|
...selectedTags.map((tag) => `${labels?.tagPrefix || labels?.tags || "Tag"}: ${tag.name}`),
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(
|
||||||
|
searchQuery.trim() ||
|
||||||
|
filters.clientId ||
|
||||||
|
filters.projectId ||
|
||||||
|
filters.tagIds.length ||
|
||||||
|
filters.startedAfter ||
|
||||||
|
filters.startedBefore,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draftSearchQuery}
|
||||||
|
onChange={(event) => setDraftSearchQuery(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-9 w-full rounded-md border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800 rtl:pl-3 rtl:pr-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
|
aria-label={isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}
|
||||||
|
className={`relative inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition-colors sm:w-auto sm:gap-2 sm:px-3 ${
|
||||||
|
isExpanded || hasActiveFilters
|
||||||
|
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="absolute -right-1 -top-1 z-10 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-600 px-1 text-[10px] font-semibold leading-none text-white dark:bg-sky-500 sm:static sm:z-auto sm:h-auto sm:min-w-5 sm:px-1.5 sm:text-[11px] sm:leading-normal">
|
||||||
|
{activeChips.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className={`hidden h-4 w-4 transition-transform sm:inline ${isExpanded ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftSearchQuery("");
|
||||||
|
setDraftFilters({
|
||||||
|
projectId: "",
|
||||||
|
clientId: "",
|
||||||
|
tagIds: [],
|
||||||
|
startedAfter: "",
|
||||||
|
startedBefore: "",
|
||||||
|
});
|
||||||
|
onClearFilters();
|
||||||
|
}}
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
aria-label={labels?.clear || "Clear"}
|
||||||
|
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition sm:w-auto sm:gap-2 sm:px-3 ${
|
||||||
|
hasActiveFilters
|
||||||
|
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
|
||||||
|
: "border-slate-200 bg-white text-slate-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{labels?.clear || "Clear"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-slate-200 pt-2 dark:border-slate-800">
|
||||||
|
<div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||||
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From"}>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={draftFilters.startedAfter}
|
||||||
|
onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))}
|
||||||
|
placeholder="YYYY/MM/DD"
|
||||||
|
inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customTo || "To"}>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={draftFilters.startedBefore}
|
||||||
|
onChange={(value) => setDraftFilters((current) => ({ ...current, startedBefore: value }))}
|
||||||
|
placeholder="YYYY/MM/DD"
|
||||||
|
inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<BriefcaseBusiness className="h-3.5 w-3.5" />} label={labels?.client || "Client"}>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.clientId}
|
||||||
|
onChange={(clientId) =>
|
||||||
|
setDraftFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
clientId,
|
||||||
|
projectId:
|
||||||
|
current.projectId &&
|
||||||
|
!projects.some((project) => project.id === current.projectId && project.client?.id === clientId)
|
||||||
|
? ""
|
||||||
|
: current.projectId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
options={[{ value: "", label: labels?.allClients || "All clients" }, ...clients]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<FolderKanban className="h-3.5 w-3.5" />} label={labels?.project || "Project"}>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.projectId}
|
||||||
|
onChange={(projectId) => setDraftFilters((current) => ({ ...current, projectId }))}
|
||||||
|
options={[{ value: "", label: labels?.allProjects || "All projects" }, ...(
|
||||||
|
draftFilters.clientId
|
||||||
|
? projects.filter((project) => project.client?.id === draftFilters.clientId)
|
||||||
|
: projects
|
||||||
|
).map((project) => ({ value: project.id, label: project.name }))]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<TagIcon className="h-3.5 w-3.5" />} label={labels?.tags || "Tags"}>
|
||||||
|
<FilterTagMultiSelect
|
||||||
|
tags={tags}
|
||||||
|
selectedTagIds={draftFilters.tagIds}
|
||||||
|
onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))}
|
||||||
|
title={labels?.allTags || "All tags"}
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onApply(draftSearchQuery, draftFilters)}
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border bg-sky-50 border-sky-200 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300 px-3 text-sm font-medium transition hover:border-sky-700 hover:bg-sky-700 hover:text-sky-100 dark:hover:border-sky-400 dark:hover:text-sky-900 dark:hover:bg-sky-400"
|
||||||
|
>
|
||||||
|
{labels?.apply || "Apply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,14 +6,16 @@ import gregorian from "react-date-object/calendars/gregorian"
|
|||||||
import gregorian_en from "react-date-object/locales/gregorian_en"
|
import gregorian_en from "react-date-object/locales/gregorian_en"
|
||||||
import "react-multi-date-picker/styles/backgrounds/bg-dark.css"
|
import "react-multi-date-picker/styles/backgrounds/bg-dark.css"
|
||||||
|
|
||||||
interface JalaliDatePickerProps {
|
interface JalaliDatePickerProps {
|
||||||
value: string | null | undefined;
|
value: string | null | undefined;
|
||||||
onChange: (date: string) => void;
|
onChange: (date: string) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
inputClassName?: string;
|
||||||
|
placeholder?: string;
|
||||||
export default function JalaliDatePicker({ value, onChange, label, disabled }: JalaliDatePickerProps) {
|
}
|
||||||
|
|
||||||
|
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
|
||||||
const isFa = document.documentElement.dir === 'rtl'
|
const isFa = document.documentElement.dir === 'rtl'
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
||||||
|
|
||||||
@@ -42,14 +44,17 @@ export default function JalaliDatePicker({ value, onChange, label, disabled }: J
|
|||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={value ? new Date(value) : null}
|
value={value ? new Date(value) : null}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
calendar={isFa ? persian : gregorian}
|
calendar={isFa ? persian : gregorian}
|
||||||
locale={isFa ? persian_fa : gregorian_en}
|
locale={isFa ? persian_fa : gregorian_en}
|
||||||
inputClass="w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
format="YYYY/MM/DD"
|
||||||
containerClassName="w-full"
|
placeholder={placeholder || "YYYY/MM/DD"}
|
||||||
className={isDark ? "bg-dark" : ""}
|
onOpenPickNewDate={false}
|
||||||
|
inputClass={`w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${inputClassName}`}
|
||||||
|
containerClassName="w-full"
|
||||||
|
className={isDark ? "bg-dark" : ""}
|
||||||
calendarPosition="bottom-right"
|
calendarPosition="bottom-right"
|
||||||
fixMainPosition
|
fixMainPosition
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectProps {
|
interface SelectProps {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
className?: string;
|
className?: string;
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
}
|
isLoading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
showChevron?: boolean;
|
||||||
|
portalOwnerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const Select: React.FC<SelectProps> = ({
|
export const Select: React.FC<SelectProps> = ({
|
||||||
value,
|
value,
|
||||||
@@ -20,12 +26,19 @@ export const Select: React.FC<SelectProps> = ({
|
|||||||
options,
|
options,
|
||||||
className = "",
|
className = "",
|
||||||
buttonClassName = "",
|
buttonClassName = "",
|
||||||
}) => {
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
|
loadingText = "",
|
||||||
|
showChevron = true,
|
||||||
|
portalOwnerId,
|
||||||
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
loadingText = loadingText || t.loadingText
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -85,40 +98,43 @@ export const Select: React.FC<SelectProps> = ({
|
|||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedOption = options.find((o) => o.value === value) || options[0];
|
const selectedOption = options.find((o) => o.value === value) || options[0];
|
||||||
|
const isDisabled = disabled || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative inline-block ${className}`}>
|
<div className={`relative inline-block ${className}`}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
disabled={isDisabled}
|
||||||
className={`flex items-center justify-between bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md px-3 py-2 text-sm text-slate-700 dark:text-slate-300 outline-none focus:ring-2 focus:ring-blue-500 ${buttonClassName}`}
|
onClick={() => !isDisabled && setIsOpen(!isOpen)}
|
||||||
|
className={`flex items-center justify-between bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md px-3 py-2 text-sm text-slate-700 dark:text-slate-300 outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${buttonClassName}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{selectedOption?.label}</span>
|
<span className="truncate">{isLoading ? loadingText : selectedOption?.label}</span>
|
||||||
<svg
|
{isLoading ? (
|
||||||
className={`w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 transition-transform ${
|
<svg className="w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 animate-spin text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
isOpen ? "rotate-180" : ""
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
}`}
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
fill="none"
|
</svg>
|
||||||
stroke="currentColor"
|
) : showChevron ? (
|
||||||
viewBox="0 0 24 24"
|
<svg
|
||||||
>
|
className={`w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||||
<path
|
fill="none"
|
||||||
strokeLinecap="round"
|
stroke="currentColor"
|
||||||
strokeLinejoin="round"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth="2"
|
>
|
||||||
d="M19 9l-7 7-7-7"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||||
></path>
|
</svg>
|
||||||
</svg>
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen &&
|
{isOpen && !isDisabled &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-lg py-1 overflow-y-auto max-h-60"
|
data-entry-editor-owner={portalOwnerId}
|
||||||
>
|
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-lg py-1 overflow-y-auto max-h-60"
|
||||||
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
|||||||
@@ -1,71 +1,47 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
import { getUserProfile } from '../api/users';
|
import { getUserProfile } from '../api/users';
|
||||||
import { fetchWorkspaces } from '../api/workspaces';
|
|
||||||
|
interface User {
|
||||||
interface User {
|
id: string;
|
||||||
id: string;
|
mobile: string;
|
||||||
phone_number: string;
|
first_name: string;
|
||||||
first_name: string;
|
last_name: string;
|
||||||
last_name: string;
|
email?: string;
|
||||||
}
|
profile_picture?: string | null;
|
||||||
|
}
|
||||||
interface Workspace {
|
|
||||||
id: string;
|
interface AppContextType {
|
||||||
name: string;
|
user: User | null;
|
||||||
}
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
interface AppContextType {
|
|
||||||
user: User | null;
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
workspaces: Workspace[];
|
|
||||||
activeWorkspace: Workspace | null;
|
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||||
setActiveWorkspace: (ws: Workspace) => void;
|
const [user, setUser] = useState<User | null>(null);
|
||||||
fetchInitialData: () => Promise<void>;
|
|
||||||
}
|
const refreshUser = async () => {
|
||||||
|
try {
|
||||||
const AppContext = createContext<AppContextType | null>(null);
|
const userData = await getUserProfile();
|
||||||
|
setUser(userData);
|
||||||
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
} catch (error) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
console.error("Failed to fetch user context data:", error);
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
setUser(null);
|
||||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
|
}
|
||||||
|
};
|
||||||
const fetchInitialData = async () => {
|
|
||||||
try {
|
useEffect(() => {
|
||||||
const [userData, wsData] = await Promise.all([
|
if (localStorage.getItem('accessToken')) {
|
||||||
getUserProfile(),
|
void refreshUser();
|
||||||
fetchWorkspaces() // fetchWorkspaces({ limit: 50 })
|
}
|
||||||
]);
|
}, []);
|
||||||
|
|
||||||
setUser(userData);
|
return (
|
||||||
|
<AppContext.Provider value={{ user, refreshUser }}>
|
||||||
const workspacesList = Array.isArray(wsData.data) ? wsData.data : (wsData?.data?.results || []);
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
setWorkspaces(workspacesList);
|
);
|
||||||
|
};
|
||||||
const savedWsId = localStorage.getItem('active_workspace');
|
|
||||||
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("Failed to fetch initial context data:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (localStorage.getItem('accessToken')) {
|
|
||||||
fetchInitialData();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContext.Provider value={{ user, workspaces, activeWorkspace, setActiveWorkspace, fetchInitialData }}>
|
|
||||||
{children}
|
|
||||||
</AppContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAppContext = () => {
|
export const useAppContext = () => {
|
||||||
const context = useContext(AppContext);
|
const context = useContext(AppContext);
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"
|
||||||
import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces"
|
import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces"
|
||||||
import { useTranslation } from "../hooks/useTranslation"
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "../components/ui/button"
|
import { Button } from "../components/ui/button"
|
||||||
import { Input } from "../components/ui/input"
|
import { Input } from "../components/ui/input"
|
||||||
|
|
||||||
interface WorkspaceContextType {
|
interface WorkspaceContextType {
|
||||||
workspaces: Workspace[]
|
workspaces: Workspace[]
|
||||||
activeWorkspace: Workspace | null
|
activeWorkspace: Workspace | null
|
||||||
setActiveWorkspace: (workspace: Workspace) => void
|
setActiveWorkspace: (workspace: Workspace | null) => void
|
||||||
addWorkspace: (name: string) => Promise<void>
|
addWorkspace: (name: string) => Promise<void>
|
||||||
isLoading: boolean
|
refreshWorkspaces: () => Promise<void>
|
||||||
}
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(undefined)
|
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(undefined)
|
||||||
|
|
||||||
@@ -29,45 +30,58 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [newWorkspaceName, setNewWorkspaceName] = useState("")
|
const [newWorkspaceName, setNewWorkspaceName] = useState("")
|
||||||
const [isCreatingFirst, setIsCreatingFirst] = useState(false)
|
const [isCreatingFirst, setIsCreatingFirst] = useState(false)
|
||||||
|
|
||||||
const isAuthenticated = !!localStorage.getItem("accessToken")
|
const isAuthenticated = !!localStorage.getItem("accessToken")
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshWorkspaces = async () => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadWorkspaces = async () => {
|
try {
|
||||||
try {
|
setIsLoading(true)
|
||||||
const response = await fetchWorkspaces()
|
const response = await fetchWorkspaces()
|
||||||
|
|
||||||
const data = Array.isArray(response) ? response : (response?.results || [])
|
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: Workspace) => w.id === storedId)
|
const stored = data.find((w: Workspace) => w.id === storedId)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setActiveWorkspaceState(stored)
|
setActiveWorkspaceState(stored)
|
||||||
} else {
|
} else {
|
||||||
setActiveWorkspaceState(data[0])
|
setActiveWorkspaceState(data[0])
|
||||||
localStorage.setItem("activeWorkspaceId", data[0].id)
|
localStorage.setItem("activeWorkspaceId", data[0].id)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
setActiveWorkspaceState(null)
|
||||||
console.error(error)
|
localStorage.removeItem("activeWorkspaceId")
|
||||||
} finally {
|
}
|
||||||
setIsLoading(false)
|
} catch (error) {
|
||||||
}
|
console.error(error)
|
||||||
}
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
loadWorkspaces()
|
}
|
||||||
}, [isAuthenticated])
|
}
|
||||||
|
|
||||||
const setActiveWorkspace = (workspace: Workspace) => {
|
useEffect(() => {
|
||||||
setActiveWorkspaceState(workspace)
|
if (!isAuthenticated) {
|
||||||
localStorage.setItem("activeWorkspaceId", workspace.id)
|
setIsLoading(false)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshWorkspaces()
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
const setActiveWorkspace = (workspace: Workspace | null) => {
|
||||||
|
setActiveWorkspaceState(workspace)
|
||||||
|
if (workspace) {
|
||||||
|
localStorage.setItem("activeWorkspaceId", workspace.id)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("activeWorkspaceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addWorkspace = async (name: string) => {
|
const addWorkspace = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -75,10 +89,10 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const newWs = await createWorkspace({ name, description: "" })
|
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?.successCreate || t.workspace?.toast?.successCreate || "Workspace created!")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.workspace?.createError || "Failed to create workspace")
|
toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace")
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingFirst(false)
|
setIsCreatingFirst(false)
|
||||||
setNewWorkspaceName("")
|
setNewWorkspaceName("")
|
||||||
@@ -123,10 +137,10 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceContext.Provider
|
<WorkspaceContext.Provider
|
||||||
value={{ workspaces, activeWorkspace, setActiveWorkspace, addWorkspace, isLoading }}
|
value={{ workspaces, activeWorkspace, setActiveWorkspace, addWorkspace, refreshWorkspaces, isLoading }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WorkspaceContext.Provider>
|
</WorkspaceContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,25 @@ export const en = {
|
|||||||
logoutToast: "Successfully logged out!",
|
logoutToast: "Successfully logged out!",
|
||||||
confirmLogoutTitle: "Confirm Logout",
|
confirmLogoutTitle: "Confirm Logout",
|
||||||
confirmLogoutMessage: "Are you sure you want to log out of your account?",
|
confirmLogoutMessage: "Are you sure you want to log out of your account?",
|
||||||
|
confirmLeave: "You have unsaved changes. Are you sure you want to leave?",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
|
save: "Save",
|
||||||
lightMode: "Light Mode",
|
lightMode: "Light Mode",
|
||||||
darkMode: "Dark Mode",
|
darkMode: "Dark Mode",
|
||||||
|
loadingText: "Loading...",
|
||||||
|
loading: "Loading...",
|
||||||
|
add: "Add",
|
||||||
|
create: "Create",
|
||||||
|
remove: "Remove",
|
||||||
|
noMoreResults: "No more results.",
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
create: "Create",
|
||||||
|
view: "View",
|
||||||
|
edit: "Edit",
|
||||||
|
delete: "Delete",
|
||||||
|
cancel: "Cancel",
|
||||||
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
|
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
|
||||||
@@ -132,16 +148,15 @@ export const en = {
|
|||||||
deleteError: "Error deleting workspace",
|
deleteError: "Error deleting workspace",
|
||||||
subtitle: "Manage your workspaces",
|
subtitle: "Manage your workspaces",
|
||||||
noDescription: "No description",
|
noDescription: "No description",
|
||||||
view: "View",
|
|
||||||
edit: "Edit",
|
|
||||||
delete: "Delete",
|
|
||||||
emptyState: "You are not a member of any workspace.",
|
emptyState: "You are not a member of any workspace.",
|
||||||
createTitle: "Create Workspace",
|
createTitle: "Create Workspace",
|
||||||
editTitle: "Edit Workspace",
|
editTitle: "Edit Workspace",
|
||||||
detailTitle: "Workspace Details",
|
detailTitle: "Workspace Details",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
back: "Back to Workspaces",
|
noWorkspaceTitle: "Welcome!",
|
||||||
|
noWorkspaceDesc: "Please create your first workspace.",
|
||||||
|
back: "Back to Workspaces",
|
||||||
roleLabel: "Your Role",
|
roleLabel: "Your Role",
|
||||||
roles: {
|
roles: {
|
||||||
owner: "Owner",
|
owner: "Owner",
|
||||||
@@ -172,9 +187,9 @@ export const en = {
|
|||||||
confirmDeleteTitle: "Remove Member",
|
confirmDeleteTitle: "Remove Member",
|
||||||
confirmDeleteMessage: "Are you sure you want to remove this member from the workspace?",
|
confirmDeleteMessage: "Are you sure you want to remove this member from the workspace?",
|
||||||
successCreate: "Workspace created successfully.",
|
successCreate: "Workspace created successfully.",
|
||||||
errorCreate: "Failed to create workspace.",
|
|
||||||
toast: {
|
toast: {
|
||||||
successCreate: "Workspace created successfully.",
|
successCreate: "Workspace created successfully.",
|
||||||
|
errorCreate: "Failed to create workspace.",
|
||||||
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.",
|
||||||
@@ -186,6 +201,7 @@ export const en = {
|
|||||||
errorLoad: "Failed to load workspace data.",
|
errorLoad: "Failed to load workspace data.",
|
||||||
cannotAddSelf: "You are automatically the owner.",
|
cannotAddSelf: "You are automatically the owner.",
|
||||||
},
|
},
|
||||||
|
onlyNumbersAllowed: "Only numbers are allowed for mobile number.",
|
||||||
},
|
},
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
@@ -228,10 +244,151 @@ export const en = {
|
|||||||
next: "Next",
|
next: "Next",
|
||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
workspaces: 'Workspaces',
|
timesheet: "Timesheet",
|
||||||
clients: 'Clients',
|
workspaces: 'Workspaces',
|
||||||
expand: 'Expand',
|
clients: 'Clients',
|
||||||
collapse: 'Collapse',
|
projects: "Projects",
|
||||||
|
tags: "Tags",
|
||||||
|
expand: 'Expand',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
},
|
||||||
|
|
||||||
|
ordering: {
|
||||||
|
createdAtDesc: "Newest First",
|
||||||
|
createdAt: "Olders First",
|
||||||
|
updatedAtDesc: "Recently Updated",
|
||||||
|
name: "Name (A-Z)",
|
||||||
|
nameDesc: "Name (Z-A)",
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
projects: {
|
||||||
|
title: "Projects",
|
||||||
|
description: (workspaceName: string) => `Manage projects for ${workspaceName}`,
|
||||||
|
active: "Active Projects",
|
||||||
|
archived: "Archived Projects",
|
||||||
|
createNew: "Create New",
|
||||||
|
searchPlaceholder: "Search projects...",
|
||||||
|
titlePlaceholder: "Enter title",
|
||||||
|
descriptionPlaceholder: "Enter desription",
|
||||||
|
titleLabel: "Title",
|
||||||
|
clientLabel: "Client",
|
||||||
|
colorLabel: "Color",
|
||||||
|
descriptionLabel: "Description",
|
||||||
|
loading: "Loading...",
|
||||||
|
client: "Client",
|
||||||
|
noClient: "No client",
|
||||||
|
emptyState: "No projects found",
|
||||||
|
deleteTitle: "Delete Project",
|
||||||
|
deleteWarning: "To confirm deletion, please type the project name:",
|
||||||
|
deleteSuccess: "Project deleted successfully",
|
||||||
|
deleteError: "Failed to delete project",
|
||||||
|
cancel: "Cancel",
|
||||||
|
create: "Create",
|
||||||
|
createProject: "Create New Project",
|
||||||
|
editProject: "Edit Project",
|
||||||
|
restore: "Restore",
|
||||||
|
archive: "Archive",
|
||||||
|
clientFetchError: "Failed to load clients.",
|
||||||
|
namePlaceholder: "Project name...",
|
||||||
|
teamMembers: "Team Members",
|
||||||
|
creator: "Creator",
|
||||||
|
addUser: "Add user by mobile",
|
||||||
|
addFromWorkspace: "Add from workspace",
|
||||||
|
searchMembers: "Search members...",
|
||||||
|
addAllWorkspaceMembers: "Add all workspace members",
|
||||||
|
confirmDeleteTitle: "Remove Member",
|
||||||
|
confirmDeleteDesc: "Are you sure you want to remove this member from the project?",
|
||||||
|
createSuccess: "Project created successfully.",
|
||||||
|
createError: "Failed to create project.",
|
||||||
|
updateSuccess: "Project updated successfully.",
|
||||||
|
updateError: "Failed to update project.",
|
||||||
|
edit: "Edit Project",
|
||||||
|
memberAlreadyAdded: "This user is already on the project team.",
|
||||||
|
roles: {
|
||||||
|
member: "Member",
|
||||||
|
manager: "Manager"
|
||||||
|
},
|
||||||
|
projectMembers: "Project Members",
|
||||||
|
removeAllWorkspaceMembers: "Remove All",
|
||||||
|
searchWorkspaceMembers: "Search by name or enter mobile number...",
|
||||||
|
userNotFound: "No user found with this mobile number.",
|
||||||
|
alreadyInProject: "Already Added",
|
||||||
|
addToProject: "Add to Project",
|
||||||
|
noWorkspaceMembers: "No members found.",
|
||||||
|
},
|
||||||
|
|
||||||
|
tags: {
|
||||||
|
title: "Tags",
|
||||||
|
description: (workspaceName: string) => `Manage tags for ${workspaceName}`,
|
||||||
|
create: "Create Tag",
|
||||||
|
createTitle: "Create Tag",
|
||||||
|
editTitle: "Edit Tag",
|
||||||
|
searchPlaceholder: "Search tags...",
|
||||||
|
nameLabel: "Tag Name",
|
||||||
|
namePlaceholder: "e.g. Design",
|
||||||
|
colorLabel: "Color",
|
||||||
|
emptyState: "No tags found",
|
||||||
|
selectWorkspace: "Please select a workspace first.",
|
||||||
|
fetchError: "Failed to load tags",
|
||||||
|
createSuccess: "Tag created successfully.",
|
||||||
|
updateSuccess: "Tag updated successfully.",
|
||||||
|
saveError: "Failed to save tag.",
|
||||||
|
deleteSuccess: "Tag deleted successfully.",
|
||||||
|
deleteError: "Failed to delete tag.",
|
||||||
|
},
|
||||||
|
|
||||||
|
timesheet: {
|
||||||
|
title: "Timesheet",
|
||||||
|
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
|
||||||
|
selectWorkspace: "Please select a workspace first.",
|
||||||
|
addEntry: "Add Entry",
|
||||||
|
startTimer: "Start Timer",
|
||||||
|
stopTimer: "Stop Timer",
|
||||||
|
timerRunning: "Timer Running",
|
||||||
|
runningLabel: "Current timer",
|
||||||
|
runningBadge: "Running",
|
||||||
|
noRunningEntry: "No running entry",
|
||||||
|
searchPlaceholder: "Search time entries...",
|
||||||
|
orderingNewest: "Newest first",
|
||||||
|
orderingOldest: "Oldest first",
|
||||||
|
emptyState: "No time entries found",
|
||||||
|
emptyDescription: "No description",
|
||||||
|
createTitle: "Add Time Entry",
|
||||||
|
startTitle: "Start Timer",
|
||||||
|
editTitle: "Edit Time Entry",
|
||||||
|
createSuccess: "Time entry created successfully.",
|
||||||
|
startSuccess: "Timer started successfully.",
|
||||||
|
updateSuccess: "Time entry updated successfully.",
|
||||||
|
saveError: "Failed to save time entry.",
|
||||||
|
stopSuccess: "Timer stopped successfully.",
|
||||||
|
stopError: "Failed to stop timer.",
|
||||||
|
deleteSuccess: "Time entry deleted successfully.",
|
||||||
|
deleteError: "Failed to delete time entry.",
|
||||||
|
fetchError: "Failed to load time entries.",
|
||||||
|
optionsError: "Failed to load projects and tags.",
|
||||||
|
descriptionLabel: "Description",
|
||||||
|
descriptionPlaceholder: "What are you working on?",
|
||||||
|
projectLabel: "Project",
|
||||||
|
noProject: "No project",
|
||||||
|
startLabel: "Start",
|
||||||
|
endLabel: "End",
|
||||||
|
billable: "Billable",
|
||||||
|
noTagsHint: "Create tags first from the Tags page.",
|
||||||
|
clearFilters: "Clear filters",
|
||||||
|
customFromLabel: "From",
|
||||||
|
customToLabel: "To",
|
||||||
|
allClientsLabel: "All clients",
|
||||||
|
allProjectsLabel: "All projects",
|
||||||
|
allTagsLabel: "All tags",
|
||||||
|
showFiltersLabel: "Show filters",
|
||||||
|
hideFiltersLabel: "Hide filters",
|
||||||
|
applyFiltersLabel: "Apply",
|
||||||
|
clientFilterPrefix: "Client",
|
||||||
|
projectFilterPrefix: "Project",
|
||||||
|
tagFilterPrefix: "Tag",
|
||||||
|
fromFilterPrefix: "From",
|
||||||
|
toFilterPrefix: "To",
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,25 @@ export const fa = {
|
|||||||
logoutToast: "با موفقیت خارج شدید!",
|
logoutToast: "با موفقیت خارج شدید!",
|
||||||
confirmLogoutTitle: "تایید خروج",
|
confirmLogoutTitle: "تایید خروج",
|
||||||
confirmLogoutMessage: "آیا مطمئن هستید که میخواهید از حساب خود خارج شوید؟",
|
confirmLogoutMessage: "آیا مطمئن هستید که میخواهید از حساب خود خارج شوید؟",
|
||||||
|
confirmLeave: "تغییرات ذخیره نشدهای دارید. آیا مطمئن هستید که میخواهید خارج شوید؟",
|
||||||
|
add: "افزودن",
|
||||||
|
create: "ایجاد",
|
||||||
cancel: "لغو",
|
cancel: "لغو",
|
||||||
|
save: "ذخیره",
|
||||||
|
remove: "حذف",
|
||||||
lightMode: "حالت روشن",
|
lightMode: "حالت روشن",
|
||||||
darkMode: "حالت تاریک",
|
darkMode: "حالت تاریک",
|
||||||
|
loadingText: "در حال بارگذاری...",
|
||||||
|
loading: "در حال بارگذاری...",
|
||||||
|
noMoreResults: "نتیجه دیگری نیست.",
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
create: "ایجاد",
|
||||||
|
view: "مشاهده",
|
||||||
|
edit: "ویرایش",
|
||||||
|
delete: "حذف",
|
||||||
|
cancel: "لغو",
|
||||||
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
||||||
@@ -111,10 +127,10 @@ export const fa = {
|
|||||||
|
|
||||||
workspace: {
|
workspace: {
|
||||||
title: "مدیریت ورکاسپیسها",
|
title: "مدیریت ورکاسپیسها",
|
||||||
createNew: "ایجاد فضای کاری جدید",
|
createNew: "ایجاد ورکاسپیس جدید",
|
||||||
manage: "مدیریت ورکاسپیسها",
|
manage: "مدیریت ورکاسپیسها",
|
||||||
nameLabel: "نام فضای کاری",
|
nameLabel: "عنوان",
|
||||||
namePlaceholder: "نام فضای کاری را وارد کنید",
|
namePlaceholder: "نام ورکاسپیس را وارد کنید",
|
||||||
descriptionLabel: "توضیحات",
|
descriptionLabel: "توضیحات",
|
||||||
descriptionPlaceholder: "توضیحات (اختیاری)",
|
descriptionPlaceholder: "توضیحات (اختیاری)",
|
||||||
searchMemberPlaceholder: "جستجو با موبایل دقیق (مثلا 09123456789)",
|
searchMemberPlaceholder: "جستجو با موبایل دقیق (مثلا 09123456789)",
|
||||||
@@ -129,20 +145,19 @@ export const fa = {
|
|||||||
submit: "ایجاد",
|
submit: "ایجاد",
|
||||||
cancel: "لغو",
|
cancel: "لغو",
|
||||||
loading: "در حال بارگذاری...",
|
loading: "در حال بارگذاری...",
|
||||||
confirmDelete: "آیا از حذف این فضای کاری اطمینان دارید؟",
|
confirmDelete: "آیا از حذف این ورکاسپیس اطمینان دارید؟",
|
||||||
deleteError: "خطا در حذف فضای کاری",
|
deleteError: "خطا در حذف ورکاسپیس",
|
||||||
subtitle: "ورکاسپیسهای خود را مدیریت کنید",
|
subtitle: "ورکاسپیسهای خود را مدیریت کنید",
|
||||||
noDescription: "بدون توضیحات",
|
noDescription: "بدون توضیحات",
|
||||||
view: "مشاهده",
|
emptyState: "شما در هیچ ورکاسپیس عضو نیستید.",
|
||||||
edit: "ویرایش",
|
createTitle: "ایجاد ورکاسپیس",
|
||||||
delete: "حذف",
|
editTitle: "ویرایش ورکاسپیس",
|
||||||
emptyState: "شما در هیچ فضای کاری عضو نیستید.",
|
detailTitle: "جزئیات ورکاسپیس",
|
||||||
createTitle: "ایجاد فضای کاری",
|
save: "ذخیره",
|
||||||
editTitle: "ویرایش فضای کاری",
|
create: "ایجاد",
|
||||||
detailTitle: "جزئیات فضای کاری",
|
noWorkspaceTitle: "خوش آمدید!",
|
||||||
save: "ذخیره",
|
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
||||||
create: "ایجاد",
|
back: "بازگشت به ورکاسپیسها",
|
||||||
back: "بازگشت به ورکاسپیسها",
|
|
||||||
roleLabel: "نقش شما",
|
roleLabel: "نقش شما",
|
||||||
roles: {
|
roles: {
|
||||||
owner: "مالک",
|
owner: "مالک",
|
||||||
@@ -150,43 +165,40 @@ export const fa = {
|
|||||||
member: "عضو",
|
member: "عضو",
|
||||||
guest: "مهمان",
|
guest: "مهمان",
|
||||||
},
|
},
|
||||||
createdSuccess: "فضای کاری با موفقیت ایجاد شد",
|
createdSuccess: "ورکاسپیس با موفقیت ایجاد شد",
|
||||||
updatedSuccess: "فضای کاری با موفقیت ویرایش شد",
|
updatedSuccess: "ورکاسپیس با موفقیت ویرایش شد",
|
||||||
fetchError: "خطا در دریافت اطلاعات فضای کاری",
|
fetchError: "خطا در دریافت اطلاعات ورکاسپیس",
|
||||||
remove: "حذف",
|
remove: "حذف",
|
||||||
noUsersFound: "کاربری یافت نشد",
|
noUsersFound: "کاربری یافت نشد",
|
||||||
selectRole: "انتخاب نقش",
|
selectRole: "انتخاب نقش",
|
||||||
add: "افزودن",
|
add: "افزودن",
|
||||||
searchPlaceholder: "جستوجوی ورکاسپیسها...",
|
searchPlaceholder: "جستوجوی ورکاسپیسها...",
|
||||||
orderByUpdatedDesc: "آخرین ویرایش",
|
deleteSuccess: "ورکاسپیس با موفقیت حذف شد",
|
||||||
orderByCreatedDesc: "جدیدترین",
|
deleteTitle: "حذف ورکاسپیس",
|
||||||
orderByCreatedAsc: "قدیمیترین",
|
deleteWarning: "برای تأیید حذف، لطفاً نام ورکاسپیس را وارد کنید:",
|
||||||
orderByName: "نام (الفبایی)",
|
|
||||||
deleteSuccess: "فضای کاری با موفقیت حذف شد",
|
|
||||||
deleteTitle: "حذف فضای کاری",
|
|
||||||
deleteWarning: "برای تأیید حذف، لطفاً نام فضای کاری را وارد کنید:",
|
|
||||||
members: "اعضا",
|
members: "اعضا",
|
||||||
searchUser: "جستجوی کاربر با شماره موبایل",
|
searchUser: "جستجوی کاربر با شماره موبایل",
|
||||||
searching: "در حال جستجو...",
|
searching: "در حال جستجو...",
|
||||||
noMembers: "عضوی یافت نشد.",
|
noMembers: "عضوی یافت نشد.",
|
||||||
removeMemberTitle: "حذف عضو",
|
removeMemberTitle: "حذف عضو",
|
||||||
confirmDeleteTitle: "حذف عضو",
|
confirmDeleteTitle: "حذف عضو",
|
||||||
confirmDeleteMessage: "آیا مطمئن هستید که میخواهید این عضو را از فضای کاری حذف کنید؟",
|
confirmDeleteMessage: "آیا مطمئن هستید که میخواهید این عضو را از ورکاسپیس حذف کنید؟",
|
||||||
|
successCreate: "ورکاسپیس با موفقیت ایجاد شد.",
|
||||||
toast: {
|
toast: {
|
||||||
successCreate: "فضای کاری با موفقیت ساخته شد.",
|
successCreate: "ورکاسپیس با موفقیت ساخته شد.",
|
||||||
successUpdate: "فضای کاری با موفقیت بهروزرسانی شد.",
|
errorCreate: "ایجاد ورکاسپیس ناموفق بود.",
|
||||||
errorUpdate: "بهروزرسانی فضای کاری با خطا مواجه شد.",
|
successUpdate: "ورکاسپیس با موفقیت بهروزرسانی شد.",
|
||||||
successAdd: "کاربر جدید با موفقیت به فضای کاری افزوده شد.",
|
errorUpdate: "بهروزرسانی ورکاسپیس با خطا مواجه شد.",
|
||||||
|
successAdd: "کاربر جدید با موفقیت به ورکاسپیس افزوده شد.",
|
||||||
errorAdd: "افزودن کاربر با خطا مواجه شد.",
|
errorAdd: "افزودن کاربر با خطا مواجه شد.",
|
||||||
successRemove: "کاربر با موفقیت از فضای کاری حذف شد.",
|
successRemove: "کاربر با موفقیت از ورکاسپیس حذف شد.",
|
||||||
errorRemove: "حذف کاربر با خطا مواجه شد.",
|
errorRemove: "حذف کاربر با خطا مواجه شد.",
|
||||||
successRole: "نقش کاربر با موفقیت تغییر کرد.",
|
successRole: "نقش کاربر با موفقیت تغییر کرد.",
|
||||||
errorRole: "تغییر نقش کاربر با خطا مواجه شد.",
|
errorRole: "تغییر نقش کاربر با خطا مواجه شد.",
|
||||||
errorLoad: "دریافت اطلاعات فضای کاری با خطا مواجه شد.",
|
errorLoad: "دریافت اطلاعات ورکاسپیس با خطا مواجه شد.",
|
||||||
cannotAddSelf: "شما بهصورت خودکار مالک هستید.",
|
cannotAddSelf: "شما بهصورت خودکار مالک هستید.",
|
||||||
},
|
},
|
||||||
errorCreate: "ایجاد فضای کاری ناموفق بود.",
|
onlyNumbersAllowed: "برای شماره موبایل فقط مجاز به وارد کردن عدد هستید.",
|
||||||
successCreate: "فضای کاری با موفقیت ایجاد شد.",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
@@ -198,7 +210,7 @@ export const fa = {
|
|||||||
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
|
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
|
||||||
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
|
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
|
||||||
addedOn: "تاریخ افزودن",
|
addedOn: "تاریخ افزودن",
|
||||||
selectWorkspace: "لطفاً ابتدا یک فضای کاری انتخاب کنید.",
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
modalTitle: "ایجاد مشتری جدید",
|
modalTitle: "ایجاد مشتری جدید",
|
||||||
clientName: "نام مشتری",
|
clientName: "نام مشتری",
|
||||||
clientNamePlaceholder: "مثال: شرکت الف",
|
clientNamePlaceholder: "مثال: شرکت الف",
|
||||||
@@ -229,10 +241,150 @@ export const fa = {
|
|||||||
next: "بعدی",
|
next: "بعدی",
|
||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
workspaces: 'ورکاسپیسها',
|
timesheet: 'تایمشیت',
|
||||||
clients: 'مشتریان',
|
workspaces: 'ورکاسپیسها',
|
||||||
expand: 'باز کردن',
|
clients: 'مشتریان',
|
||||||
collapse: 'جمع کردن',
|
projects: "پروژهها",
|
||||||
|
tags: "تگها",
|
||||||
|
expand: 'باز کردن',
|
||||||
|
collapse: 'جمع کردن',
|
||||||
|
},
|
||||||
|
|
||||||
|
ordering: {
|
||||||
|
createdAtDesc: "جدیدترین",
|
||||||
|
createdAt: "قدیمیترین",
|
||||||
|
updatedAtDesc: "اخیراً بروزرسانی شده",
|
||||||
|
name: "نام (صعودی)",
|
||||||
|
nameDesc: "نام (نزولی)",
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
projects: {
|
||||||
|
title: "پروژهها",
|
||||||
|
description: (workspaceName: string) => `مدیریت پروژهها برای ${workspaceName}`,
|
||||||
|
active: "پروژههای فعال",
|
||||||
|
archived: "پروژههای بایگانی شده",
|
||||||
|
createNew: "ایجاد پروژه جدید",
|
||||||
|
searchPlaceholder: "جستجوی پروژهها...",
|
||||||
|
titlePlaceholder: "عنوان پروژه",
|
||||||
|
descriptionPlaceholder: "توضیحات پروژه",
|
||||||
|
titleLabel: "عنوان",
|
||||||
|
descriptionLabel: "توضیحات",
|
||||||
|
clientLabel: "مشتری",
|
||||||
|
colorLabel: "رنگ",
|
||||||
|
loading: "در حال بارگذاری...",
|
||||||
|
client: "مشتری",
|
||||||
|
noClient: "بدون مشتری",
|
||||||
|
emptyState: "پروژهای یافت نشد",
|
||||||
|
deleteTitle: "حذف پروژه",
|
||||||
|
deleteWarning: "برای تایید حذف، لطفاً نام پروژه را تایپ کنید:",
|
||||||
|
deleteSuccess: "پروژه با موفقیت حذف شد",
|
||||||
|
deleteError: "خطا در حذف پروژه",
|
||||||
|
create: "ایجاد",
|
||||||
|
cancel: "انصراف",
|
||||||
|
createProject: "ایجاد پروژه",
|
||||||
|
editProject: "ویرایش پروژه",
|
||||||
|
restore: "بازیابی",
|
||||||
|
archive: "بایگانی",
|
||||||
|
clientFetchError: "خطا در دریافت لیست مشتریان.",
|
||||||
|
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
||||||
|
creator: "سازنده",
|
||||||
|
addUser: "افزودن کاربر",
|
||||||
|
addFromWorkspace: "افزودن از اعضای ورکاسپیس",
|
||||||
|
searchMembers: "جستجوی اعضا",
|
||||||
|
addAllWorkspaceMembers: "افزودن همه اعضای ورکاسپیس",
|
||||||
|
confirmDeleteTitle: "حذف عضو",
|
||||||
|
confirmDeleteDesc: "آیا مطمئن هستید که میخواهید این عضو را حذف کنید؟",
|
||||||
|
roles: {
|
||||||
|
member: "عضو",
|
||||||
|
manager: "مدیر"
|
||||||
|
},
|
||||||
|
namePlaceholder: "نام پروژه...",
|
||||||
|
teamMembers: "اعضای تیم",
|
||||||
|
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
||||||
|
createError: "خطا در ایجاد پروژه.",
|
||||||
|
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
||||||
|
updateError: "بهروزرسانی پروژه با خطا مواجه شد.",
|
||||||
|
edit: "ویرایش پروژه",
|
||||||
|
projectMembers: "اعضای پروژه",
|
||||||
|
removeAllWorkspaceMembers: "حذف همه",
|
||||||
|
searchWorkspaceMembers: "جستجو با نام یا وارد کردن شماره موبایل...",
|
||||||
|
userNotFound: "کاربری با این شماره موبایل یافت نشد.",
|
||||||
|
alreadyInProject: "قبلاً اضافه شده",
|
||||||
|
addToProject: "افزودن به پروژه",
|
||||||
|
noWorkspaceMembers: "عضوی یافت نشد.",
|
||||||
|
},
|
||||||
|
|
||||||
|
tags: {
|
||||||
|
title: "تگها",
|
||||||
|
description: (workspaceName: string) => `مدیریت تگها برای ${workspaceName}`,
|
||||||
|
create: "ایجاد تگ",
|
||||||
|
createTitle: "ایجاد تگ",
|
||||||
|
editTitle: "ویرایش تگ",
|
||||||
|
searchPlaceholder: "جستوجوی تگها...",
|
||||||
|
nameLabel: "نام تگ",
|
||||||
|
namePlaceholder: "مثلاً طراحی",
|
||||||
|
colorLabel: "رنگ",
|
||||||
|
emptyState: "تگی یافت نشد",
|
||||||
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
|
fetchError: "دریافت تگها با خطا مواجه شد.",
|
||||||
|
createSuccess: "تگ با موفقیت ایجاد شد.",
|
||||||
|
updateSuccess: "تگ با موفقیت بهروزرسانی شد.",
|
||||||
|
saveError: "ذخیره تگ با خطا مواجه شد.",
|
||||||
|
deleteSuccess: "تگ با موفقیت حذف شد.",
|
||||||
|
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||||
|
},
|
||||||
|
|
||||||
|
timesheet: {
|
||||||
|
title: "تایمشیت",
|
||||||
|
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||||
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
|
addEntry: "افزودن ورودی",
|
||||||
|
startTimer: "شروع تایمر",
|
||||||
|
stopTimer: "توقف تایمر",
|
||||||
|
timerRunning: "تایمر فعال است",
|
||||||
|
runningLabel: "تایمر فعلی",
|
||||||
|
runningBadge: "در حال اجرا",
|
||||||
|
noRunningEntry: "تایمر فعالی وجود ندارد",
|
||||||
|
searchPlaceholder: "جستوجوی ورودیهای زمان...",
|
||||||
|
orderingNewest: "جدیدترین",
|
||||||
|
orderingOldest: "قدیمیترین",
|
||||||
|
emptyState: "ورودی زمانی یافت نشد",
|
||||||
|
emptyDescription: "بدون توضیح",
|
||||||
|
createTitle: "افزودن ورودی زمان",
|
||||||
|
startTitle: "شروع تایمر",
|
||||||
|
editTitle: "ویرایش ورودی زمان",
|
||||||
|
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
|
||||||
|
startSuccess: "تایمر با موفقیت شروع شد.",
|
||||||
|
updateSuccess: "ورودی زمان با موفقیت بهروزرسانی شد.",
|
||||||
|
saveError: "ذخیره ورودی زمان با خطا مواجه شد.",
|
||||||
|
stopSuccess: "تایمر با موفقیت متوقف شد.",
|
||||||
|
stopError: "توقف تایمر با خطا مواجه شد.",
|
||||||
|
deleteSuccess: "ورودی زمان با موفقیت حذف شد.",
|
||||||
|
deleteError: "حذف ورودی زمان با خطا مواجه شد.",
|
||||||
|
fetchError: "دریافت ورودیهای زمان با خطا مواجه شد.",
|
||||||
|
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
||||||
|
descriptionLabel: "توضیحات",
|
||||||
|
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
||||||
|
projectLabel: "پروژه",
|
||||||
|
noProject: "بدون پروژه",
|
||||||
|
startLabel: "شروع",
|
||||||
|
endLabel: "پایان",
|
||||||
|
billable: "قابل صورتحساب",
|
||||||
|
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||||
|
clearFilters: "پاک کردن فیلترها",
|
||||||
|
customFromLabel: "از",
|
||||||
|
customToLabel: "تا",
|
||||||
|
allClientsLabel: "همه مشتریها",
|
||||||
|
allProjectsLabel: "همه پروژهها",
|
||||||
|
allTagsLabel: "همه تگها",
|
||||||
|
showFiltersLabel: "نمایش فیلترها",
|
||||||
|
hideFiltersLabel: "مخفی کردن فیلترها",
|
||||||
|
applyFiltersLabel: "اعمال",
|
||||||
|
clientFilterPrefix: "مشتری",
|
||||||
|
projectFilterPrefix: "پروژه",
|
||||||
|
tagFilterPrefix: "تگ",
|
||||||
|
fromFilterPrefix: "از",
|
||||||
|
toFilterPrefix: "تا",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "
|
|||||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker"
|
import JalaliDatePicker from "../components/ui/JalaliDatePicker"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Modal } from "../components/Modal"
|
import { Modal } from "../components/Modal"
|
||||||
|
import { Input } from "../components/ui/input"
|
||||||
|
import { TextAreaInput } from "../components/ui/TextAreaInput"
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -244,7 +246,7 @@ export default function Profile() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.firstName || 'First Name'}</span>
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.firstName || 'First Name'}</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.first_name || ""}
|
value={formData.first_name || ""}
|
||||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||||
@@ -259,7 +261,7 @@ export default function Profile() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.lastName || 'Last Name'}</span>
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.lastName || 'Last Name'}</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.last_name || ""}
|
value={formData.last_name || ""}
|
||||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||||
@@ -282,7 +284,7 @@ export default function Profile() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.email || 'Email'}</span>
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.email || 'Email'}</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email || ""}
|
value={formData.email || ""}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
@@ -327,7 +329,7 @@ export default function Profile() {
|
|||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.description || 'Description'}</span>
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.description || 'Description'}</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<textarea
|
<TextAreaInput
|
||||||
rows={4}
|
rows={4}
|
||||||
value={formData.description || ""}
|
value={formData.description || ""}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
@@ -345,7 +347,7 @@ export default function Profile() {
|
|||||||
<div className="mt-8 pt-4 border-t border-slate-100 dark:border-slate-800 flex justify-end items-center gap-3">
|
<div className="mt-8 pt-4 border-t border-slate-100 dark:border-slate-800 flex justify-end items-center gap-3">
|
||||||
<Button variant="outline" onClick={handleCancelEdit} disabled={isSaving} className="flex items-center gap-2">
|
<Button variant="outline" onClick={handleCancelEdit} disabled={isSaving} className="flex items-center gap-2">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
{t.cancel || 'Cancel'}
|
{t.actions?.cancel || 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveProfile} disabled={isSaving} className="flex items-center gap-2 bg-blue-600 text-white hover:bg-blue-700">
|
<Button onClick={handleSaveProfile} disabled={isSaving} className="flex items-center gap-2 bg-blue-600 text-white hover:bg-blue-700">
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -372,7 +374,7 @@ export default function Profile() {
|
|||||||
onClick={() => setIsPicModalOpen(false)}
|
onClick={() => setIsPicModalOpen(false)}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{t.profile?.cancel || t.cancel || 'Cancel'}
|
{t.actions?.cancel || 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -390,7 +392,7 @@ export default function Profile() {
|
|||||||
: "border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-800/80"
|
: "border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-800/80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
|||||||
633
src/pages/ProjectCreate.tsx
Normal file
633
src/pages/ProjectCreate.tsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate, useBlocker } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { createProject } from "../api/projects";
|
||||||
|
import { getClients } from "../api/clients";
|
||||||
|
import { fetchWorkspaceMemberships } from "../api/workspaces";
|
||||||
|
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||||
|
import { useAppContext } from "../context/AppContext";
|
||||||
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Select } from "../components/ui/Select";
|
||||||
|
import { TextAreaInput } from "../components/ui/TextAreaInput";
|
||||||
|
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
|
||||||
|
type ProjectRole = "manager" | "member";
|
||||||
|
|
||||||
|
interface LocalMember {
|
||||||
|
localId: string;
|
||||||
|
user: any;
|
||||||
|
role: ProjectRole;
|
||||||
|
isCreator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#3B82F6",
|
||||||
|
"#10B981",
|
||||||
|
"#F59E0B",
|
||||||
|
"#EF4444",
|
||||||
|
"#8B5CF6",
|
||||||
|
"#EC4899",
|
||||||
|
"#14B8A6",
|
||||||
|
"#64748B",
|
||||||
|
];
|
||||||
|
|
||||||
|
const toEnglishDigits = (str: string) => {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
|
||||||
|
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
|
export default function ProjectCreate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user } = useAppContext();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const currentUserId = user?.id || "";
|
||||||
|
|
||||||
|
// Project Detail States
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [client, setClient] = useState("");
|
||||||
|
const [clientsList, setClientsList] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Workspace List & Pagination States
|
||||||
|
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
|
||||||
|
// Member Management States
|
||||||
|
const [members, setMembers] = useState<LocalMember[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [addAllMembers, setAddAllMembers] = useState(false);
|
||||||
|
const [isAddingAll, setIsAddingAll] = useState(false);
|
||||||
|
|
||||||
|
// External Search States
|
||||||
|
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState(false);
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1;
|
||||||
|
|
||||||
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
|
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
|
||||||
|
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// EXACT same pagination structure as EditWorkspace.tsx
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspace?.id) {
|
||||||
|
const workspaceId = activeWorkspace.id;
|
||||||
|
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setColor(COLORS[0]);
|
||||||
|
setClient("");
|
||||||
|
setClientsList([]);
|
||||||
|
setWorkspaceMembers([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResult(null);
|
||||||
|
setSearchError(false);
|
||||||
|
setAddAllMembers(false);
|
||||||
|
|
||||||
|
// Reset pagination state
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(true);
|
||||||
|
setIsLoadingData(true);
|
||||||
|
|
||||||
|
if (user?.id) {
|
||||||
|
setMembers([{ localId: user.id, user: user, role: "manager", isCreator: true }]);
|
||||||
|
} else {
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitialData = async () => {
|
||||||
|
try {
|
||||||
|
const clientsRes = await getClients(workspaceId);
|
||||||
|
setClientsList(clientsRes.results || []);
|
||||||
|
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: workspaceId,
|
||||||
|
limit: LIMIT,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const results = res.results || (Array.isArray(res) ? res : []);
|
||||||
|
|
||||||
|
setWorkspaceMembers(results);
|
||||||
|
setOffset(LIMIT);
|
||||||
|
setHasMore(res.next ? true : results.length >= LIMIT);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch initial data", err);
|
||||||
|
toast.error("Failed to load initial data.");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitialData();
|
||||||
|
}
|
||||||
|
}, [activeWorkspace?.id, user?.id]);
|
||||||
|
|
||||||
|
// EXACT same LoadMore logic and deduplication as EditWorkspace.tsx
|
||||||
|
const loadMoreMembers = useCallback(async () => {
|
||||||
|
if (isLoadingMore || !hasMore || !activeWorkspace?.id) return;
|
||||||
|
try {
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
limit: LIMIT,
|
||||||
|
offset: offset
|
||||||
|
});
|
||||||
|
const results = res.results || (Array.isArray(res) ? res : []);
|
||||||
|
|
||||||
|
setWorkspaceMembers((prev) => {
|
||||||
|
// Safe deduplication to avoid React key warnings breaking the DOM observer
|
||||||
|
const existingIds = new Set(prev.map(m => m.id));
|
||||||
|
const newItems = results.filter((item: any) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...newItems];
|
||||||
|
});
|
||||||
|
|
||||||
|
setOffset(prev => prev + LIMIT);
|
||||||
|
setHasMore(res.next ? true : results.length >= LIMIT);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more members", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [activeWorkspace?.id, isLoadingMore, hasMore, offset]);
|
||||||
|
|
||||||
|
// Unified Search Logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
|
const cleanQuery = toEnglishDigits(searchQuery.trim());
|
||||||
|
setSearchError(false);
|
||||||
|
|
||||||
|
if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) {
|
||||||
|
searchTimeoutRef.current = setTimeout(async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const foundUser = await searchUserByExactMobile(cleanQuery);
|
||||||
|
if (foundUser && foundUser.id) {
|
||||||
|
if (foundUser.id === currentUserId) {
|
||||||
|
setSearchResult(null);
|
||||||
|
} else {
|
||||||
|
setSearchResult(foundUser);
|
||||||
|
setSearchError(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSearchResult(null);
|
||||||
|
setSearchError(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSearchResult(null);
|
||||||
|
setSearchError(true);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setSearchResult(null);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery, currentUserId]);
|
||||||
|
|
||||||
|
const handleAddMember = (userToAdd: any) => {
|
||||||
|
if (members.some((m) => m.user.id === userToAdd.id)) return;
|
||||||
|
const newMember: LocalMember = {
|
||||||
|
localId: Math.random().toString(36).substr(2, 9),
|
||||||
|
user: userToAdd,
|
||||||
|
role: "member",
|
||||||
|
};
|
||||||
|
setMembers((prev) => [newMember, ...prev]);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAddAllMembers = async () => {
|
||||||
|
if (addAllMembers) {
|
||||||
|
setMembers((prev) => prev.filter(m => m.isCreator || m.role === "manager"));
|
||||||
|
setAddAllMembers(false);
|
||||||
|
} else {
|
||||||
|
if (!activeWorkspace?.id) return;
|
||||||
|
setIsAddingAll(true);
|
||||||
|
try {
|
||||||
|
let currentOffset = 0;
|
||||||
|
let continueFetching = true;
|
||||||
|
const allWsMembers: any[] = [];
|
||||||
|
|
||||||
|
while (continueFetching) {
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
limit: 50,
|
||||||
|
offset: currentOffset,
|
||||||
|
});
|
||||||
|
const fetchedResults = res.results || (Array.isArray(res) ? res : []);
|
||||||
|
allWsMembers.push(...fetchedResults);
|
||||||
|
if (res.next) {
|
||||||
|
currentOffset += 50;
|
||||||
|
} else {
|
||||||
|
continueFetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMembersToAdd = allWsMembers
|
||||||
|
.map((wm) => wm.user)
|
||||||
|
.filter((u) => u && u.id !== currentUserId && !members.some((m) => m.user.id === u.id));
|
||||||
|
|
||||||
|
const localMembers: LocalMember[] = newMembersToAdd.map((u) => ({
|
||||||
|
localId: Math.random().toString(36).substr(2, 9),
|
||||||
|
user: u,
|
||||||
|
role: "member",
|
||||||
|
}));
|
||||||
|
|
||||||
|
setMembers((prev) => [...prev, ...localMembers]);
|
||||||
|
setAddAllMembers(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Could not add all workspace members.");
|
||||||
|
} finally {
|
||||||
|
setIsAddingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteModal = (userId: string) => {
|
||||||
|
setMemberIdToDelete(userId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMember = () => {
|
||||||
|
if (!memberIdToDelete) return;
|
||||||
|
setMembers(members.filter((m) => m.user.id !== memberIdToDelete));
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setMemberIdToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRole = (userId: string, newRole: string) => {
|
||||||
|
setMembers(
|
||||||
|
members.map((m) => (m.user.id === userId ? { ...m, role: newRole as ProjectRole } : m))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !activeWorkspace) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const membersPayload = members
|
||||||
|
.filter((m) => !m.isCreator)
|
||||||
|
.map((m) => ({ user_id: m.user.id, role: m.role }));
|
||||||
|
|
||||||
|
const projectPayload: any = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
members: membersPayload,
|
||||||
|
};
|
||||||
|
if (client) projectPayload.client = client;
|
||||||
|
|
||||||
|
const newProject = await createProject(projectPayload);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
||||||
|
toast.success(t.projects?.createSuccess || "Project created successfully.");
|
||||||
|
navigate("/projects");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || t.projects?.createError || "Failed to create project.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare unified display list
|
||||||
|
const filteredWorkspaceMembers = workspaceMembers.filter((m) => {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
|
||||||
|
const phone = toEnglishDigits(m.user.mobile || "");
|
||||||
|
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceMemberUserIds = new Set(filteredWorkspaceMembers.map((m) => m.user.id));
|
||||||
|
|
||||||
|
const externalAddedMembers = members.filter((m) => {
|
||||||
|
if (workspaceMemberUserIds.has(m.user.id)) return false;
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
|
||||||
|
const phone = toEnglishDigits(m.user.mobile || "");
|
||||||
|
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayList = [
|
||||||
|
...externalAddedMembers.map((m) => ({ listId: m.localId, user: m.user })),
|
||||||
|
...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user }))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!activeWorkspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
|
||||||
|
{t.projects?.createNew || "Create New Project"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||||
|
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
|
||||||
|
<form id="create-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
|
||||||
|
color === c
|
||||||
|
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
|
||||||
|
: "hover:scale-110 shadow-sm"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
aria-label={`Select color ${c}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{t.projects?.client || "Client"}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={client}
|
||||||
|
onChange={setClient}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||||
|
...clientsList.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
{t.projects?.descriptionLabel || "Description (Optional)"}
|
||||||
|
</label>
|
||||||
|
<TextAreaInput
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
|
||||||
|
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||||
|
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t.create || "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<Users size={18} />
|
||||||
|
{t.projects?.projectMembers || "Project Members"}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={addAllMembers ? "destructive" : "outline"}
|
||||||
|
disabled={isAddingAll || isLoadingData}
|
||||||
|
onClick={handleToggleAddAllMembers}
|
||||||
|
>
|
||||||
|
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{addAllMembers
|
||||||
|
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
|
||||||
|
: (t.projects?.addAllWorkspaceMembers || "Add All")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
|
||||||
|
className="ps-10"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
|
||||||
|
{t.projects?.userNotFound || "No user found with this mobile number."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResult && !searchError && (
|
||||||
|
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{searchResult.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={searchResult.profile_picture}
|
||||||
|
alt={searchResult.first_name}
|
||||||
|
className="w-10 h-10 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{searchResult.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{searchResult.first_name} {searchResult.last_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{searchResult.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={members.some((m) => m.user.id === searchResult.id)}
|
||||||
|
onClick={() => handleAddMember(searchResult)}
|
||||||
|
>
|
||||||
|
{members.some((m) => m.user.id === searchResult.id)
|
||||||
|
? (t.projects?.alreadyInProject || "Already Added")
|
||||||
|
: (t.projects?.addToProject || "Add to Project")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoadingData ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
{t.loading || "Loading..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InfiniteScroll
|
||||||
|
onLoadMore={loadMoreMembers}
|
||||||
|
hasMore={hasMore && searchQuery.trim().length === 0}
|
||||||
|
isLoading={isLoadingMore}
|
||||||
|
>
|
||||||
|
{displayList.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 text-center">
|
||||||
|
{t.projects?.noWorkspaceMembers || "No members found."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
|
{displayList.map((item) => {
|
||||||
|
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
|
||||||
|
const isAdded = !!addedMemberData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{item.user.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={item.user.profile_picture}
|
||||||
|
alt={item.user.first_name}
|
||||||
|
className="w-9 h-9 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{item.user.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
{item.user.first_name} {item.user.last_name}
|
||||||
|
{addedMemberData?.isCreator && (
|
||||||
|
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
|
||||||
|
{t.projects?.creator || "Creator"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{item.user.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isAdded ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!addedMemberData.isCreator && (
|
||||||
|
<Select
|
||||||
|
value={addedMemberData.role}
|
||||||
|
onChange={(val) => handleChangeRole(item.user.id, val)}
|
||||||
|
options={[
|
||||||
|
{ value: "member", label: t.projects?.roles?.member || "Member" },
|
||||||
|
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
|
||||||
|
]}
|
||||||
|
buttonClassName="text-xs h-8 w-28"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!addedMemberData.isCreator && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
||||||
|
onClick={() => openDeleteModal(item.user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleAddMember(item.user)}
|
||||||
|
>
|
||||||
|
{t.projects?.addToProject || "Add to Project"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteDialogOpen}
|
||||||
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
|
title={t.projects?.confirmDeleteTitle || "Remove Member"}
|
||||||
|
description={
|
||||||
|
t.projects?.confirmDeleteDesc || "Are you sure you want to remove this member from the project?"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteMember}>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
619
src/pages/ProjectEdit.tsx
Normal file
619
src/pages/ProjectEdit.tsx
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate, useParams, useBlocker } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { getProject, updateProject } from "../api/projects";
|
||||||
|
import { getClients } from "../api/clients";
|
||||||
|
import { fetchWorkspaceMemberships } from "../api/workspaces";
|
||||||
|
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||||
|
import { useAppContext } from "../context/AppContext";
|
||||||
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Select } from "../components/ui/Select";
|
||||||
|
import { TextAreaInput } from "../components/ui/TextAreaInput";
|
||||||
|
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
|
||||||
|
type ProjectRole = "manager" | "member";
|
||||||
|
|
||||||
|
interface LocalMember {
|
||||||
|
localId: string;
|
||||||
|
user: any;
|
||||||
|
role: ProjectRole;
|
||||||
|
isCreator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#3B82F6",
|
||||||
|
"#10B981",
|
||||||
|
"#F59E0B",
|
||||||
|
"#EF4444",
|
||||||
|
"#8B5CF6",
|
||||||
|
"#EC4899",
|
||||||
|
"#14B8A6",
|
||||||
|
"#64748B",
|
||||||
|
];
|
||||||
|
|
||||||
|
const toEnglishDigits = (str: string) => {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
|
||||||
|
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
|
export default function ProjectEdit() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user } = useAppContext();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const currentUserId = user?.id || "";
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [client, setClient] = useState("");
|
||||||
|
const [clientsList, setClientsList] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<LocalMember[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [addAllMembers, setAddAllMembers] = useState(false);
|
||||||
|
const [isAddingAll, setIsAddingAll] = useState(false);
|
||||||
|
|
||||||
|
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState(false);
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = name.trim() !== "";
|
||||||
|
|
||||||
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
|
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||||
|
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspace?.id && id) {
|
||||||
|
const loadInitialData = async () => {
|
||||||
|
try {
|
||||||
|
const clientsRes = await getClients(activeWorkspace.id);
|
||||||
|
setClientsList(clientsRes.results || []);
|
||||||
|
|
||||||
|
const projectRes = await getProject(id);
|
||||||
|
setName(projectRes.name || "");
|
||||||
|
setDescription(projectRes.description || "");
|
||||||
|
setColor(projectRes.color || COLORS[0]);
|
||||||
|
setClient(projectRes.client?.id || projectRes.client || "");
|
||||||
|
|
||||||
|
if (projectRes.members) {
|
||||||
|
const mappedMembers = projectRes.members.map((m: any) => ({
|
||||||
|
localId: m.id,
|
||||||
|
user: {
|
||||||
|
id: m.user_details?.id || m.user,
|
||||||
|
first_name: m.user_details?.first_name || "",
|
||||||
|
last_name: m.user_details?.last_name || "",
|
||||||
|
mobile: m.user_details?.phone_number || "",
|
||||||
|
profile_picture: m.user_details?.avatar || "",
|
||||||
|
},
|
||||||
|
role: m.role as ProjectRole,
|
||||||
|
isCreator: m.user === currentUserId && m.role === "manager",
|
||||||
|
}));
|
||||||
|
setMembers(mappedMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
limit: LIMIT,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const results = res.results || (Array.isArray(res) ? res : []);
|
||||||
|
|
||||||
|
setWorkspaceMembers(results);
|
||||||
|
setOffset(LIMIT);
|
||||||
|
setHasMore(res.next ? true : results.length >= LIMIT);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to load project data.");
|
||||||
|
navigate("/projects");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
setIsProjectLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitialData();
|
||||||
|
}
|
||||||
|
}, [activeWorkspace?.id, id, currentUserId, navigate]);
|
||||||
|
|
||||||
|
const loadMoreMembers = useCallback(async () => {
|
||||||
|
if (isLoadingMore || !hasMore || !activeWorkspace?.id) return;
|
||||||
|
try {
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
limit: LIMIT,
|
||||||
|
offset: offset
|
||||||
|
});
|
||||||
|
const results = res.results || (Array.isArray(res) ? res : []);
|
||||||
|
|
||||||
|
setWorkspaceMembers((prev) => {
|
||||||
|
const existingIds = new Set(prev.map(m => m.id));
|
||||||
|
const newItems = results.filter((item: any) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...newItems];
|
||||||
|
});
|
||||||
|
|
||||||
|
setOffset(prev => prev + LIMIT);
|
||||||
|
setHasMore(res.next ? true : results.length >= LIMIT);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more members", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [activeWorkspace?.id, isLoadingMore, hasMore, offset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
|
const cleanQuery = toEnglishDigits(searchQuery.trim());
|
||||||
|
setSearchError(false);
|
||||||
|
|
||||||
|
if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) {
|
||||||
|
searchTimeoutRef.current = setTimeout(async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const foundUser = await searchUserByExactMobile(cleanQuery);
|
||||||
|
if (foundUser && foundUser.id) {
|
||||||
|
setSearchResult(foundUser);
|
||||||
|
setSearchError(false);
|
||||||
|
} else {
|
||||||
|
setSearchResult(null);
|
||||||
|
setSearchError(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSearchResult(null);
|
||||||
|
setSearchError(true);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setSearchResult(null);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleAddMember = (userToAdd: any) => {
|
||||||
|
if (members.some((m) => m.user.id === userToAdd.id)) return;
|
||||||
|
const newMember: LocalMember = {
|
||||||
|
localId: Math.random().toString(36).substr(2, 9),
|
||||||
|
user: userToAdd,
|
||||||
|
role: "member",
|
||||||
|
};
|
||||||
|
setMembers((prev) => [newMember, ...prev]);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAddAllMembers = async () => {
|
||||||
|
if (addAllMembers) {
|
||||||
|
setMembers((prev) => prev.filter(m => m.isCreator || m.role === "manager"));
|
||||||
|
setAddAllMembers(false);
|
||||||
|
} else {
|
||||||
|
if (!activeWorkspace?.id) return;
|
||||||
|
setIsAddingAll(true);
|
||||||
|
try {
|
||||||
|
let currentOffset = 0;
|
||||||
|
let continueFetching = true;
|
||||||
|
const allWsMembers: any[] = [];
|
||||||
|
|
||||||
|
while (continueFetching) {
|
||||||
|
const res = await fetchWorkspaceMemberships({
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
limit: 50,
|
||||||
|
offset: currentOffset,
|
||||||
|
});
|
||||||
|
const fetchedResults = res.results || (Array.isArray(res) ? res : []);
|
||||||
|
allWsMembers.push(...fetchedResults);
|
||||||
|
if (res.next) {
|
||||||
|
currentOffset += 50;
|
||||||
|
} else {
|
||||||
|
continueFetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMembersToAdd = allWsMembers
|
||||||
|
.map((wm) => wm.user)
|
||||||
|
.filter((u) => u && !members.some((m) => m.user.id === u.id));
|
||||||
|
|
||||||
|
const localMembers: LocalMember[] = newMembersToAdd.map((u) => ({
|
||||||
|
localId: Math.random().toString(36).substr(2, 9),
|
||||||
|
user: u,
|
||||||
|
role: "member",
|
||||||
|
}));
|
||||||
|
|
||||||
|
setMembers((prev) => [...prev, ...localMembers]);
|
||||||
|
setAddAllMembers(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Could not add all workspace members.");
|
||||||
|
} finally {
|
||||||
|
setIsAddingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteModal = (userId: string) => {
|
||||||
|
setMemberIdToDelete(userId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMember = () => {
|
||||||
|
if (!memberIdToDelete) return;
|
||||||
|
setMembers(members.filter((m) => m.user.id !== memberIdToDelete));
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setMemberIdToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRole = (userId: string, newRole: string) => {
|
||||||
|
setMembers(
|
||||||
|
members.map((m) => (m.user.id === userId ? { ...m, role: newRole as ProjectRole } : m))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !activeWorkspace || !id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const membersPayload = members.map((m) => ({ user_id: m.user.id, role: m.role }));
|
||||||
|
|
||||||
|
const projectPayload: any = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
workspace: activeWorkspace.id,
|
||||||
|
members: membersPayload,
|
||||||
|
client: client || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedProject = await updateProject(id, projectPayload);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
|
||||||
|
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||||
|
navigate("/projects");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || t.projects?.updateError || "Failed to update project.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredWorkspaceMembers = workspaceMembers.filter((m) => {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
|
||||||
|
const phone = toEnglishDigits(m.user.mobile || "");
|
||||||
|
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceMemberUserIds = new Set(filteredWorkspaceMembers.map((m) => m.user.id));
|
||||||
|
|
||||||
|
const externalAddedMembers = members.filter((m) => {
|
||||||
|
if (workspaceMemberUserIds.has(m.user.id)) return false;
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
|
||||||
|
const phone = toEnglishDigits(m.user.mobile || "");
|
||||||
|
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayList = [
|
||||||
|
...externalAddedMembers.map((m) => ({ listId: m.localId, user: m.user })),
|
||||||
|
...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user }))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!activeWorkspace) return null;
|
||||||
|
|
||||||
|
if (isProjectLoading) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
|
||||||
|
{t.projects?.edit || "Edit Project"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||||
|
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
|
||||||
|
<form id="edit-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
|
||||||
|
color === c
|
||||||
|
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
|
||||||
|
: "hover:scale-110 shadow-sm"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
aria-label={`Select color ${c}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{t.projects?.client || "Client"}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={client}
|
||||||
|
onChange={setClient}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||||
|
...clientsList.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
{t.projects?.descriptionLabel || "Description (Optional)"}
|
||||||
|
</label>
|
||||||
|
<TextAreaInput
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
|
||||||
|
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||||
|
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t.save || "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<Users size={18} />
|
||||||
|
{t.projects?.projectMembers || "Project Members"}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={addAllMembers ? "destructive" : "outline"}
|
||||||
|
disabled={isAddingAll || isLoadingData}
|
||||||
|
onClick={handleToggleAddAllMembers}
|
||||||
|
>
|
||||||
|
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{addAllMembers
|
||||||
|
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
|
||||||
|
: (t.projects?.addAllWorkspaceMembers || "Add All")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
|
||||||
|
className="ps-10"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
|
||||||
|
{t.projects?.userNotFound || "No user found with this mobile number."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResult && !searchError && (
|
||||||
|
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{searchResult.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={searchResult.profile_picture}
|
||||||
|
alt={searchResult.first_name}
|
||||||
|
className="w-10 h-10 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{searchResult.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{searchResult.first_name} {searchResult.last_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{searchResult.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={members.some((m) => m.user.id === searchResult.id)}
|
||||||
|
onClick={() => handleAddMember(searchResult)}
|
||||||
|
>
|
||||||
|
{members.some((m) => m.user.id === searchResult.id)
|
||||||
|
? (t.projects?.alreadyInProject || "Already Added")
|
||||||
|
: (t.projects?.addToProject || "Add to Project")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoadingData ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
{t.loading || "Loading..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<InfiniteScroll
|
||||||
|
onLoadMore={loadMoreMembers}
|
||||||
|
hasMore={hasMore && searchQuery.trim().length === 0}
|
||||||
|
isLoading={isLoadingMore}
|
||||||
|
>
|
||||||
|
{displayList.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-slate-500 text-center">
|
||||||
|
{t.projects?.noWorkspaceMembers || "No members found."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
|
{displayList.map((item) => {
|
||||||
|
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
|
||||||
|
const isAdded = !!addedMemberData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{item.user.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={item.user.profile_picture}
|
||||||
|
alt={item.user.first_name}
|
||||||
|
className="w-9 h-9 rounded-full object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||||
|
{item.user.first_name?.[0] || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
{item.user.first_name} {item.user.last_name}
|
||||||
|
{addedMemberData?.isCreator && (
|
||||||
|
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
|
||||||
|
{t.projects?.creator || "Creator"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{item.user.mobile}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isAdded ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={addedMemberData.role}
|
||||||
|
onChange={(val) => handleChangeRole(item.user.id, val)}
|
||||||
|
options={[
|
||||||
|
{ value: "member", label: t.projects?.roles?.member || "Member" },
|
||||||
|
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
|
||||||
|
]}
|
||||||
|
buttonClassName="text-xs h-8 w-28"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
||||||
|
onClick={() => openDeleteModal(item.user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleAddMember(item.user)}
|
||||||
|
>
|
||||||
|
{t.projects?.addToProject || "Add to Project"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteDialogOpen}
|
||||||
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
|
title={t.projects?.confirmDeleteTitle || "Remove Member"}
|
||||||
|
description={
|
||||||
|
t.projects?.confirmDeleteDesc || "Are you sure you want to remove this member from the project?"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
|
{t.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteMember}>
|
||||||
|
{t.remove || "Remove"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
src/pages/Projects.tsx
Normal file
284
src/pages/Projects.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import { getProjects, deleteProject, type Project } from "../api/projects";
|
||||||
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
|
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
|
||||||
|
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
||||||
|
import { Pagination } from "../components/Pagination";
|
||||||
|
import { Plus, Archive, Trash2, Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
import FilterBar from "../components/FilterBar";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
|
||||||
|
export const Projects: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<any | null>(null);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [ordering, setOrdering] = useState("-created_at");
|
||||||
|
const [isArchived, setIsArchived] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||||
|
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
|
||||||
|
const [deleteInput, setDeleteInput] = useState('');
|
||||||
|
|
||||||
|
const orderingOptions = [
|
||||||
|
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
||||||
|
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' },
|
||||||
|
{ value: 'name', label: t.ordering?.name || 'Name (A-Z)' },
|
||||||
|
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchProjectList = async () => {
|
||||||
|
if (!activeWorkspace) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const offset = (currentPage - 1) * limit;
|
||||||
|
const data = await getProjects(activeWorkspace.id, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
search,
|
||||||
|
is_archived: isArchived,
|
||||||
|
ordering
|
||||||
|
});
|
||||||
|
const items = data?.results || (Array.isArray(data) ? data : [])
|
||||||
|
const count = data?.count !== undefined ? data.count : items.length
|
||||||
|
setProjects(items);
|
||||||
|
setTotalItems(count)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch projects", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delayDebounceFn = setTimeout(() => {
|
||||||
|
fetchProjectList();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCreated = () => fetchProjectList();
|
||||||
|
const handleUpdated = () => fetchProjectList();
|
||||||
|
|
||||||
|
window.addEventListener("project_created", handleCreated);
|
||||||
|
window.addEventListener("project_updated", handleUpdated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("project_created", handleCreated);
|
||||||
|
window.removeEventListener("project_updated", handleUpdated);
|
||||||
|
};
|
||||||
|
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
|
||||||
|
|
||||||
|
const handleDeleteClick = (project: Project) => {
|
||||||
|
setProjectToDelete(project);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteModal.project) return;
|
||||||
|
try {
|
||||||
|
const deletedId = deleteModal.project.id;
|
||||||
|
await deleteProject(deletedId);
|
||||||
|
|
||||||
|
fetchProjectList();
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('project_deleted', {
|
||||||
|
detail: { id: deletedId }
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
|
||||||
|
setDeleteModal({ isOpen: false, project: null });
|
||||||
|
setDeleteInput('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t.projects?.deleteError || 'Failed to delete project');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
|
<Button
|
||||||
|
variant={isArchived ? "default" : "secondary"}
|
||||||
|
onClick={() => setIsArchived(!isArchived)}
|
||||||
|
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
{t.projects?.createNew || 'Create New'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
searchQuery={search}
|
||||||
|
setSearchQuery={setSearch}
|
||||||
|
ordering={ordering}
|
||||||
|
setOrdering={setOrdering}
|
||||||
|
orderingOptions={orderingOptions}
|
||||||
|
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 flex justify-center text-slate-500">
|
||||||
|
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Card key={project.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||||
|
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 px-6 gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<CardTitle className="text-lg line-clamp-1">
|
||||||
|
{project.name}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-1">
|
||||||
|
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || 'No client'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300 mt-2 line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setEditingProject(project)}
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||||
|
title={t.actions?.edit || 'Edit'}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
title={t.actions?.delete || 'Delete'}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl">
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<ProjectCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingProject && (
|
||||||
|
<ProjectEditModal
|
||||||
|
project={editingProject}
|
||||||
|
isOpen={!!editingProject}
|
||||||
|
onClose={() => setEditingProject(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteModal.project && (
|
||||||
|
<Modal
|
||||||
|
isOpen={deleteModal.isOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteModal({ isOpen: false, project: null });
|
||||||
|
setDeleteInput('');
|
||||||
|
}}
|
||||||
|
title={t.projects?.deleteTitle || 'Delete Project'}
|
||||||
|
maxWidth="max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteModal({ isOpen: false, project: null });
|
||||||
|
setDeleteInput('');
|
||||||
|
}}
|
||||||
|
className="rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
{t.actions?.cancel || 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteInput !== deleteModal.project.name}
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
{t.actions?.delete || 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
||||||
|
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={deleteInput}
|
||||||
|
onChange={(e) => setDeleteInput(e.target.value)}
|
||||||
|
placeholder={deleteModal.project.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
242
src/pages/Tags.tsx
Normal file
242
src/pages/Tags.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
|
||||||
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import FilterBar from "../components/FilterBar";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { Pagination } from "../components/Pagination";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = "#3B82F6";
|
||||||
|
|
||||||
|
export default function Tags() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [ordering, setOrdering] = useState("-updated_at");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const orderingOptions = [
|
||||||
|
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
|
||||||
|
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
||||||
|
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
|
||||||
|
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
|
||||||
|
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, ordering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeWorkspace?.id) return;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
void loadTags();
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [activeWorkspace?.id, searchQuery, ordering, currentPage, limit]);
|
||||||
|
|
||||||
|
const loadTags = async () => {
|
||||||
|
if (!activeWorkspace?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await getTags(activeWorkspace.id, {
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
ordering,
|
||||||
|
search: searchQuery,
|
||||||
|
});
|
||||||
|
setTags(data.results || []);
|
||||||
|
setTotalItems(data.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(t.tags?.fetchError || "Failed to load tags");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingTag(null);
|
||||||
|
setFormName("");
|
||||||
|
setFormColor(DEFAULT_COLOR);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (tag: Tag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
setFormName(tag.name);
|
||||||
|
setFormColor(tag.color || DEFAULT_COLOR);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingTag(null);
|
||||||
|
setFormName("");
|
||||||
|
setFormColor(DEFAULT_COLOR);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!activeWorkspace?.id || !formName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
if (editingTag) {
|
||||||
|
await updateTag(editingTag.id, { name: formName.trim(), color: formColor });
|
||||||
|
toast.success(t.tags?.updateSuccess || "Tag updated");
|
||||||
|
} else {
|
||||||
|
await createTag(activeWorkspace.id, { name: formName.trim(), color: formColor });
|
||||||
|
toast.success(t.tags?.createSuccess || "Tag created");
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
await loadTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(t.tags?.saveError || "Failed to save tag");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (tag: Tag) => {
|
||||||
|
try {
|
||||||
|
await deleteTag(tag.id);
|
||||||
|
toast.success(t.tags?.deleteSuccess || "Tag deleted");
|
||||||
|
await loadTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(t.tags?.deleteError || "Failed to delete tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeWorkspace) {
|
||||||
|
return <div className="p-6 text-center text-slate-500">{t.tags?.selectWorkspace || t.clients.selectWorkspace}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t.tags?.create || "Create Tag"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
ordering={ordering}
|
||||||
|
setOrdering={setOrdering}
|
||||||
|
orderingOptions={orderingOptions}
|
||||||
|
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Card key={tag.id} className="dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4 px-6">
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-700" style={{ backgroundColor: tag.color || DEFAULT_COLOR }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-lg truncate text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">{tag.color || DEFAULT_COLOR}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-500 dark:text-slate-400">
|
||||||
|
<TagIcon className="w-10 h-10 mb-3" />
|
||||||
|
<p>{t.tags?.emptyState || "No tags found"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingTag ? (t.tags?.editTitle || "Edit Tag") : (t.tags?.createTitle || "Create Tag")}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={closeModal}>
|
||||||
|
{t.actions?.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}>
|
||||||
|
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.tags?.nameLabel || "Tag name"}
|
||||||
|
</label>
|
||||||
|
<Input value={formName} onChange={(event) => setFormName(event.target.value)} placeholder={t.tags?.namePlaceholder || "Design"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.tags?.colorLabel || "Color"}
|
||||||
|
</label>
|
||||||
|
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2490
src/pages/Timesheet.tsx
Normal file
2490
src/pages/Timesheet.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,7 @@ export default function WorkspaceCreate() {
|
|||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0;
|
const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, Fragment, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
|
||||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
|
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
|
||||||
@@ -15,7 +15,7 @@ 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';
|
import { InfiniteScroll } from '../components/InfiniteScroll';
|
||||||
import { Select } from '../components/ui/Select';
|
import { Select } from '../components/ui/Select';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
||||||
@@ -67,12 +67,13 @@ export default function EditWorkspace() {
|
|||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [initialData, setInitialData] = useState({
|
const [initialData, setInitialData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasUnsavedChanges = useMemo(() => {
|
const hasUnsavedChanges = useMemo(() => {
|
||||||
if (isLoading) return false;
|
if (isLoading) return false;
|
||||||
|
|
||||||
@@ -100,13 +101,6 @@ export default function EditWorkspace() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useBlocker(({ currentLocation, nextLocation }) => {
|
|
||||||
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
|
|
||||||
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) loadData();
|
if (id) loadData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -125,7 +119,9 @@ export default function EditWorkspace() {
|
|||||||
|
|
||||||
setMembers(results);
|
setMembers(results);
|
||||||
setOffset(LIMIT);
|
setOffset(LIMIT);
|
||||||
setHasMore(!!membersData.next);
|
|
||||||
|
// Robust hasMore check: use `.next` if available, otherwise check if array filled the limit
|
||||||
|
setHasMore(membersData.next ? true : results.length >= LIMIT);
|
||||||
|
|
||||||
setInitialData({
|
setInitialData({
|
||||||
name: workspaceData.name,
|
name: workspaceData.name,
|
||||||
@@ -139,22 +135,35 @@ export default function EditWorkspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreMembers = async () => {
|
const loadMoreMembers = useCallback(async () => {
|
||||||
if (isLoadingMembers || !hasMore || !id) return;
|
if (isLoadingMembers || !hasMore || !id) return;
|
||||||
try {
|
try {
|
||||||
setIsLoadingMembers(true);
|
setIsLoadingMembers(true);
|
||||||
const membersData = await fetchWorkspaceMemberships({ workspace: id, limit: `${LIMIT}`, offset: `${offset}` });
|
|
||||||
const results = membersData.results || [];
|
|
||||||
|
|
||||||
setMembers((prev) => [...prev, ...results]);
|
// Send as pure numbers, axios handles them cleanly
|
||||||
setOffset((prev) => prev + LIMIT);
|
const membersData = await fetchWorkspaceMemberships({
|
||||||
setHasMore(!!membersData.next);
|
workspace: id,
|
||||||
|
limit: LIMIT,
|
||||||
|
offset: offset
|
||||||
|
});
|
||||||
|
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
|
||||||
|
|
||||||
|
setMembers((prev) => {
|
||||||
|
// Safe deduplication to avoid React key warnings
|
||||||
|
const existingIds = new Set(prev.map(m => m.id));
|
||||||
|
const newItems = results.filter((item: any) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...newItems];
|
||||||
|
});
|
||||||
|
|
||||||
|
setOffset(offset + LIMIT);
|
||||||
|
setHasMore(membersData.next ? true : results.length >= LIMIT);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load more members", error);
|
console.error("Failed to load more members", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMembers(false);
|
setIsLoadingMembers(false);
|
||||||
}
|
}
|
||||||
};
|
}, [id, isLoadingMembers, hasMore, offset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
@@ -191,14 +200,11 @@ 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', {
|
window.dispatchEvent(new CustomEvent('workspace_edited', {
|
||||||
detail: { id, name, description }
|
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.");
|
||||||
@@ -210,11 +216,11 @@ export default function EditWorkspace() {
|
|||||||
const handleAddMember = async () => {
|
const handleAddMember = async () => {
|
||||||
if (!searchResult || !id) return;
|
if (!searchResult || !id) return;
|
||||||
try {
|
try {
|
||||||
const newMembership = await addWorkspaceMembership({
|
const newMembership = await addWorkspaceMembership({
|
||||||
workspace: id,
|
workspace: id,
|
||||||
user: searchResult.id,
|
user: String(searchResult.id),
|
||||||
role: newMemberRole
|
role: newMemberRole
|
||||||
});
|
});
|
||||||
setMembers([newMembership, ...members]);
|
setMembers([newMembership, ...members]);
|
||||||
toast.success(t.workspace?.toast?.successAdd || "Member added successfully.");
|
toast.success(t.workspace?.toast?.successAdd || "Member added successfully.");
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
@@ -264,8 +270,6 @@ export default function EditWorkspace() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
<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 flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
<div className="w-full lg:w-1/3 lg:max-w-md flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -292,27 +296,17 @@ export default function EditWorkspace() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
|
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
|
||||||
<Button
|
<Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}>
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate('/workspaces')}
|
|
||||||
>
|
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={isSaving || !name.trim()}
|
|
||||||
>
|
|
||||||
{isSaving ? (t.workspace?.loading || "Saving...") : (t.workspace?.save || "Save")}
|
{isSaving ? (t.workspace?.loading || "Saving...") : (t.workspace?.save || "Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* --- ستون سمت راست: لیست اعضا --- */}
|
|
||||||
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{/* بخش جستجو و هدر (ثابت در بالا) */}
|
|
||||||
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
||||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||||
{ t.workspace?.members || "Members" }
|
{ t.workspace?.members || "Members" }
|
||||||
@@ -391,7 +385,6 @@ export default function EditWorkspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لیست اعضا (با قابلیت اسکرول مجزا) */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30">
|
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30">
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
onLoadMore={loadMoreMembers}
|
onLoadMore={loadMoreMembers}
|
||||||
@@ -438,8 +431,10 @@ export default function EditWorkspace() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
|
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
|
||||||
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
||||||
{m.role ? t.workspace?.roles?.[m.role] || m.role : "-"}
|
{m.role && m.role in t.workspace.roles
|
||||||
</span>
|
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
|
||||||
|
: m.role || "-"}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && (
|
{canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && (
|
||||||
@@ -504,16 +499,10 @@ export default function EditWorkspace() {
|
|||||||
{t.workspace?.confirmDeleteMessage || "Are you sure you want to remove this member from the workspace?"}
|
{t.workspace?.confirmDeleteMessage || "Are you sure you want to remove this member from the workspace?"}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
<Button
|
<Button variant="secondary" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setIsDeleteDialogOpen(false)}
|
|
||||||
>
|
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" onClick={handleDeleteMember}>
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteMember}
|
|
||||||
>
|
|
||||||
{t.actions?.delete || "Delete"}
|
{t.actions?.delete || "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
|
|||||||
Reference in New Issue
Block a user