Compare commits
11 Commits
8bd0e908a1
...
e60a4c9ab4
| Author | SHA1 | Date | |
|---|---|---|---|
| e60a4c9ab4 | |||
| cb4a7ae118 | |||
| 5082dab99e | |||
| e4b1dcf3c0 | |||
| b2101a2e22 | |||
| 05f2b4a4bb | |||
| 8868b7d1cc | |||
| d57f0b05e3 | |||
| 36a8c0e24c | |||
| 2b5ee2abf1 | |||
| 3efa04094d |
@@ -22,6 +22,7 @@ import Tags from "./pages/Tags"
|
||||
import Reports from "./pages/Reports"
|
||||
import Timesheet from "./pages/Timesheet"
|
||||
import Logs from "./pages/Logs"
|
||||
import NotificationsPage from "./pages/Notifications"
|
||||
|
||||
const MainLayout = () => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
@@ -66,6 +67,7 @@ const router = createBrowserRouter([
|
||||
{ path: "/profile", element: <Profile /> },
|
||||
{ path: "/timesheet", element: <Timesheet /> },
|
||||
{ path: "/reports", element: <Reports /> },
|
||||
{ path: "/notifications", element: <NotificationsPage /> },
|
||||
{ path: "/logs", element: <Logs /> },
|
||||
{ path: "/tags", element: <Tags /> },
|
||||
{ path: "/workspaces", element: <Workspaces /> },
|
||||
|
||||
@@ -5,7 +5,6 @@ export type WorkspaceLogSection =
|
||||
| "workspace_members"
|
||||
| "clients"
|
||||
| "projects"
|
||||
| "project_members"
|
||||
| "tags"
|
||||
| "time_entries"
|
||||
| "rates"
|
||||
|
||||
@@ -12,38 +12,17 @@ export interface ProjectClient {
|
||||
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;
|
||||
created_at?: string;
|
||||
is_archived: boolean;
|
||||
is_deleted?: boolean;
|
||||
workspace: string;
|
||||
created_by?: AuditUser | null;
|
||||
client: ProjectClient | null;
|
||||
my_role?: string;
|
||||
members?: ProjectMembership[];
|
||||
}
|
||||
|
||||
export interface ProjectPayload {
|
||||
@@ -55,24 +34,42 @@ export interface ProjectPayload {
|
||||
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 });
|
||||
export const getProjects = async (
|
||||
workspaceId: string,
|
||||
params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
client?: string;
|
||||
clients?: 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.search) queryParams.append("search", params.search);
|
||||
if (params.client) queryParams.append("client", params.client);
|
||||
params.clients?.forEach((clientId) => queryParams.append("clients", clientId));
|
||||
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();
|
||||
};
|
||||
const response = await authFetch(`/api/projects/?${queryParams.toString()}`);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to fetch projects");
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data?.items)) {
|
||||
return {
|
||||
...data,
|
||||
results: data.items,
|
||||
count: data.total_items ?? data.items.length,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getProject = async (id: string) => {
|
||||
const response = await authFetch(`/api/projects/${id}/`);
|
||||
@@ -84,13 +81,9 @@ export const getProject = async (id: string) => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
data: Partial<ProjectPayload> & {
|
||||
workspace: string;
|
||||
name: string;
|
||||
members?: ProjectMemberPayload[];
|
||||
}
|
||||
) => {
|
||||
export const createProject = async (
|
||||
data: Partial<ProjectPayload> & { workspace: string; name: string }
|
||||
) => {
|
||||
const response = await authFetch("/api/projects/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -103,10 +96,10 @@ export const createProject = async (
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
id: string,
|
||||
data: Partial<ProjectPayload> & { members?: ProjectMemberPayload[] }
|
||||
) => {
|
||||
export const updateProject = async (
|
||||
id: string,
|
||||
data: Partial<ProjectPayload>
|
||||
) => {
|
||||
const response = await authFetch(`/api/projects/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
@@ -143,50 +136,4 @@ export const toggleArchiveProject = async (id: string) => {
|
||||
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 }));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface DailyReportRow {
|
||||
billable_duration: string;
|
||||
non_billable_duration: string;
|
||||
total_duration: string;
|
||||
latest_hourly_rate: CurrencyTotal | null;
|
||||
income_totals: CurrencyTotal[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { createClient } from "../api/clients";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createClient } from "../api/clients";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -22,18 +23,20 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createClient(workspaceId, { name, notes });
|
||||
onSuccess();
|
||||
setName("");
|
||||
setNotes("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.createFailed, error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await createClient(workspaceId, { name, notes });
|
||||
toast.success(t.clients.createSuccess);
|
||||
onSuccess();
|
||||
setName("");
|
||||
setNotes("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.createFailed, error);
|
||||
toast.error(t.clients.errors.createFailed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
@@ -72,4 +75,4 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { type Client } from "../types/client";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type Client } from "../types/client";
|
||||
import { deleteClient } from "../api/clients";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -19,16 +20,18 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
||||
const handleDelete = async () => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteClient(client.id);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.deleteFailed, error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await deleteClient(client.id);
|
||||
toast.success(t.clients.deleteSuccess);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.deleteFailed, error);
|
||||
toast.error(t.clients.errors.deleteFailed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { type Client } from "../types/client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type Client } from "../types/client";
|
||||
import { updateClient } from "../api/clients";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -30,16 +31,18 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
||||
const handleSubmit = async () => {
|
||||
if (!client || !name.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateClient(client.id, { name, notes });
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.updateFailed, error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await updateClient(client.id, { name, notes });
|
||||
toast.success(t.clients.updateSuccess);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.updateFailed, error);
|
||||
toast.error(t.clients.errors.updateFailed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
@@ -78,4 +81,4 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
85
src/components/ListPageSkeleton.tsx
Normal file
85
src/components/ListPageSkeleton.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
|
||||
type ListPageSkeletonVariant = "list" | "standard-grid" | "dense-grid";
|
||||
|
||||
type ListPageSkeletonProps = {
|
||||
variant?: ListPageSkeletonVariant;
|
||||
};
|
||||
|
||||
export function ListPageSkeleton({
|
||||
variant = "standard-grid",
|
||||
}: ListPageSkeletonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cardCount = variant === "list" ? 5 : variant === "dense-grid" ? 8 : 6;
|
||||
const items = useMemo(
|
||||
() => Array.from({ length: cardCount }, (_, index) => index),
|
||||
[cardCount],
|
||||
);
|
||||
|
||||
const gridClassName =
|
||||
variant === "dense-grid"
|
||||
? "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
: "grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
{variant === "list" ? (
|
||||
<div className="flex flex-1 flex-col gap-4 animate-pulse">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-2xl border border-slate-200/80 bg-slate-50/70 p-5 dark:border-slate-800 dark:bg-slate-950/40"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="h-10 w-10 shrink-0 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="h-4 w-40 max-w-[60vw] rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-3 w-64 max-w-[70vw] rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${gridClassName} animate-pulse`}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-2xl border border-slate-200/80 bg-slate-50/70 p-5 dark:border-slate-800 dark:bg-slate-950/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="h-10 w-10 shrink-0 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="h-4 w-32 max-w-[40vw] rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-3 w-24 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="h-3 w-full rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-3 w-[82%] rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-3 w-[64%] rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
<div className="mt-5 h-3 w-28 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "../hooks/useTranslation"
|
||||
import { Button } from "./ui/button"
|
||||
import { SettingsMenu } from "./SettingsMenu"
|
||||
import { LogOut, User, Moon, Sun, Globe, Command, Menu } from "lucide-react"
|
||||
import { useTheme } from "./ThemeProvider"
|
||||
import { logoutUser, getUserProfile } from "../api/users"
|
||||
import { WorkspaceSelector } from "./WorkspaceSelector"
|
||||
import { toast } from "sonner"
|
||||
@@ -16,18 +17,16 @@ type NavbarProps = {
|
||||
|
||||
export function Navbar({ onOpenSidebar }: NavbarProps) {
|
||||
const { t, lang, setLanguage } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const [user, setUser] = useState<any>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const isFa = lang === "fa"
|
||||
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const savedTheme = localStorage.getItem("theme")
|
||||
if (savedTheme) return savedTheme === "dark"
|
||||
return document.documentElement.classList.contains("dark")
|
||||
})
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
(theme === "system" && document.documentElement.classList.contains("dark"))
|
||||
|
||||
useEffect(() => {
|
||||
const handleProfileUpdated = ((e: CustomEvent) => {
|
||||
@@ -40,14 +39,6 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
|
||||
return () => window.removeEventListener("profile_updated", handleProfileUpdated)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark")
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark")
|
||||
}
|
||||
}, [isDarkMode])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const token = getAccessToken()
|
||||
@@ -92,9 +83,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newThemeState = !isDarkMode
|
||||
setIsDarkMode(newThemeState)
|
||||
localStorage.setItem("theme", newThemeState ? "dark" : "light")
|
||||
setTheme(isDarkMode ? "light" : "dark")
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { Select } from './ui/Select';
|
||||
import { Button } from './ui/button';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Select } from './ui/Select';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@@ -12,7 +14,7 @@ interface PaginationProps {
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalCount,
|
||||
limit,
|
||||
@@ -29,56 +31,121 @@ export const Pagination: React.FC<PaginationProps> = ({
|
||||
return num.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d as any]);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit) || 1;
|
||||
|
||||
if (totalCount === 0) return null;
|
||||
|
||||
const startItem = ((currentPage - 1) * limit) + 1;
|
||||
const endItem = Math.min(currentPage * limit, totalCount);
|
||||
|
||||
return (
|
||||
<div className="mt-auto sticky bottom-0 bg-slate-50/60 dark:bg-slate-900/60 backdrop-blur-md left-0 right-0 z-10 flex items-center justify-between py-4 px-4 -mx-4 sm:px-0 sm:mx-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={String(limit)}
|
||||
onChange={(val) => {
|
||||
onLimitChange(Number(val));
|
||||
onPageChange(1);
|
||||
}}
|
||||
options={pageSizeOptions.map((option) => ({
|
||||
value: String(option),
|
||||
label: String(toPersianNum(option)),
|
||||
}))}
|
||||
className="w-20 shrink-0"
|
||||
buttonClassName=""
|
||||
/>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline-block">
|
||||
{t.pagination?.showing || 'Showing'} <strong className="text-slate-700 dark:text-slate-300">{toPersianNum(startItem)}</strong> {t.pagination?.to || '-'} <strong className="text-slate-700 dark:text-slate-300">{toPersianNum(endItem)}</strong> {t.pagination?.of || 'of'} <strong className="text-slate-700 dark:text-slate-300">{toPersianNum(totalCount)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
{t.pagination?.previous || 'Previous'}
|
||||
</Button>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400 font-medium hidden sm:inline-block">
|
||||
{t.pagination?.page || 'Page'} {toPersianNum(currentPage)} {t.pagination?.of || 'of'} {toPersianNum(totalPages)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{t.pagination?.next || 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const totalPages = Math.ceil(totalCount / limit) || 1;
|
||||
|
||||
if (totalCount === 0) return null;
|
||||
|
||||
const startItem = ((currentPage - 1) * limit) + 1;
|
||||
const endItem = Math.min(currentPage * limit, totalCount);
|
||||
const pageItems = useMemo(() => {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
const pages: Array<number | "ellipsis-left" | "ellipsis-right"> = [1];
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
if (start > 2) {
|
||||
pages.push("ellipsis-left");
|
||||
}
|
||||
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
if (end < totalPages - 1) {
|
||||
pages.push("ellipsis-right");
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 z-10 mt-auto -mx-4 border-t border-slate-200/80 bg-white/90 px-4 py-4 backdrop-blur-xl dark:border-slate-800/80 dark:bg-slate-950/90 sm:mx-0 sm:rounded-3xl sm:border sm:px-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-start">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={String(limit)}
|
||||
onChange={(val) => {
|
||||
onLimitChange(Number(val));
|
||||
onPageChange(1);
|
||||
}}
|
||||
options={pageSizeOptions.map((option) => ({
|
||||
value: String(option),
|
||||
label: String(toPersianNum(option)),
|
||||
}))}
|
||||
className="w-24 shrink-0"
|
||||
buttonClassName="h-10 rounded-2xl border-slate-200 bg-slate-50 font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
|
||||
/>
|
||||
<span className="inline-flex h-10 items-center rounded-2xl border border-slate-200 bg-slate-50 px-4 text-sm font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 sm:hidden">
|
||||
{toPersianNum(currentPage)} / {toPersianNum(totalPages)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.pagination?.showing || 'Showing'} <strong className="text-slate-700 dark:text-slate-200">{toPersianNum(startItem)}</strong> {t.pagination?.to || '-'} <strong className="text-slate-700 dark:text-slate-200">{toPersianNum(endItem)}</strong> {t.pagination?.of || 'of'} <strong className="text-slate-700 dark:text-slate-200">{toPersianNum(totalCount)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
{pageItems.map((pageItem, index) =>
|
||||
typeof pageItem === 'number' ? (
|
||||
<button
|
||||
key={pageItem}
|
||||
type="button"
|
||||
onClick={() => onPageChange(pageItem)}
|
||||
className={cn(
|
||||
'inline-flex h-10 min-w-10 items-center justify-center rounded-2xl border px-3 text-sm font-semibold transition-colors',
|
||||
pageItem === currentPage
|
||||
? 'border-sky-500 bg-sky-500 text-white shadow-sm'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800',
|
||||
)}
|
||||
>
|
||||
{toPersianNum(pageItem)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
key={`${pageItem}-${index}`}
|
||||
className="inline-flex h-10 min-w-10 items-center justify-center rounded-2xl text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-10 rounded-2xl border-slate-200 bg-slate-50 px-4 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
{t.pagination?.previous || 'Previous'}
|
||||
</Button>
|
||||
|
||||
<div className="hidden text-sm font-medium text-slate-500 dark:text-slate-400 sm:block md:hidden">
|
||||
{t.pagination?.page || 'Page'} {toPersianNum(currentPage)} {t.pagination?.of || 'of'} {toPersianNum(totalPages)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="h-10 rounded-2xl border-slate-200 bg-slate-50 px-4 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t.pagination?.next || 'Next'}
|
||||
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ const sectionBadgeStyles: Record<string, string> = {
|
||||
workspace_members: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
|
||||
clients: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
|
||||
projects: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
project_members: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
|
||||
tags: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
|
||||
time_entries: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
rates: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
|
||||
@@ -78,7 +78,6 @@ export function LogsFilterBar({
|
||||
{ value: "workspace_members", label: t.logs?.sections?.workspace_members || "Workspace members" },
|
||||
{ value: "clients", label: t.logs?.sections?.clients || "Clients" },
|
||||
{ value: "projects", label: t.logs?.sections?.projects || "Projects" },
|
||||
{ value: "project_members", label: t.logs?.sections?.project_members || "Project members" },
|
||||
{ value: "tags", label: t.logs?.sections?.tags || "Tags" },
|
||||
{ value: "time_entries", label: t.logs?.sections?.time_entries || "Time entries" },
|
||||
{ value: "rates", label: t.logs?.sections?.rates || "Rates" },
|
||||
|
||||
@@ -1,105 +1,30 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react"
|
||||
import { useTranslation } from "../../hooks/useTranslation"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { useNotifications } from "../../context/NotificationsContext"
|
||||
import type { NotificationItem } from "../../api/notifications"
|
||||
import { Button } from "../ui/button"
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bell, CheckCheck, Loader2 } from "lucide-react";
|
||||
|
||||
const formatNotificationTimestamp = (value: string, locale: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function NotificationRow({
|
||||
notification,
|
||||
locale,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: {
|
||||
notification: NotificationItem
|
||||
locale: string
|
||||
onClick: (notification: NotificationItem) => void
|
||||
onDelete: (notification: NotificationItem) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
|
||||
notification.is_seen
|
||||
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
||||
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(notification)}
|
||||
className="flex min-w-0 flex-1 items-start gap-3 text-start"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{notification.title || notification.type}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatNotificationTimestamp(notification.created_at, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{notification.message ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{notification.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void onDelete(notification)
|
||||
}}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
|
||||
aria-label="Delete notification"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { NotificationList } from "./NotificationList";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
import { useNotifications } from "../../context/NotificationsContext";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { t, lang } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
totalCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
markAllAsSeen,
|
||||
deleteOne,
|
||||
handleNotificationClick,
|
||||
} = useNotifications()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
} = useNotifications();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const unreadNotifications = useMemo(
|
||||
() => notifications.filter((notification) => !notification.is_seen),
|
||||
[notifications],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -107,12 +32,12 @@ export function NotificationBell() {
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
@@ -136,8 +61,8 @@ export function NotificationBell() {
|
||||
{t.notifications?.title || "Notifications"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t.notifications?.summary?.(totalCount, unreadCount) ||
|
||||
`${totalCount} total, ${unreadCount} unread`}
|
||||
{t.notifications?.summary?.(unreadNotifications.length, unreadCount) ||
|
||||
`${unreadNotifications.length} total, ${unreadCount} unread`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -159,45 +84,31 @@ export function NotificationBell() {
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t.notifications?.loading || "Loading notifications..."}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.notifications?.empty || "No notifications yet."}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
locale={lang}
|
||||
onClick={(item) => void handleNotificationClick(item)}
|
||||
onDelete={(item) => void deleteOne(item)}
|
||||
/>
|
||||
))
|
||||
<NotificationList
|
||||
notifications={unreadNotifications}
|
||||
emptyLabel={t.notifications?.emptyUnread || "No unread notifications."}
|
||||
onClick={(item) => void handleNotificationClick(item)}
|
||||
onDelete={(item) => void deleteOne(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore ? (
|
||||
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t.notifications?.loadingMore || "Loading more..."}
|
||||
</>
|
||||
) : (
|
||||
t.notifications?.loadMore || "Load more"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate("/notifications");
|
||||
}}
|
||||
>
|
||||
{t.notifications?.viewAll || "View all notifications"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/notifications/NotificationList.tsx
Normal file
105
src/components/notifications/NotificationList.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import type { NotificationItem } from "../../api/notifications";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
import { presentNotification } from "../../lib/notificationPresenter";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const formatNotificationTimestamp = (value: string, locale: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export function NotificationList({
|
||||
notifications,
|
||||
emptyLabel,
|
||||
onClick,
|
||||
onDelete,
|
||||
className = "",
|
||||
}: {
|
||||
notifications: NotificationItem[];
|
||||
emptyLabel: string;
|
||||
onClick: (notification: NotificationItem) => void;
|
||||
onDelete: (notification: NotificationItem) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, lang } = useTranslation();
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return (
|
||||
<div className={cn("px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400", className)}>
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{notifications.map((notification) => {
|
||||
const presented = presentNotification(notification, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
|
||||
notification.is_seen
|
||||
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
||||
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(notification)}
|
||||
className="flex min-w-0 flex-1 items-start gap-3 text-start"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{presented.title}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatNotificationTimestamp(notification.created_at, lang)}
|
||||
</span>
|
||||
</div>
|
||||
{presented.message ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{presented.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onDelete(notification);
|
||||
}}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
|
||||
aria-label={t.notifications?.deleteLabel || "Delete notification"}
|
||||
title={t.notifications?.deleteLabel || "Delete notification"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,24 +44,26 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
||||
if (!activeWorkspace || !formData.name) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const newProject = await createProject({
|
||||
workspace: activeWorkspace.id,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
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);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
toast.success(t.projects?.createSuccess || "Project created successfully.");
|
||||
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
||||
onClose();
|
||||
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.projects?.createError || "Failed to create project.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
|
||||
@@ -58,36 +58,44 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
||||
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);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const updated = await updateProject(project.id, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
color: formData.color,
|
||||
client: formData.client || null,
|
||||
});
|
||||
|
||||
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.projects?.updateError || "Failed to update project.");
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const updated = await toggleArchiveProject(project.id);
|
||||
toast.success(
|
||||
project?.is_archived
|
||||
? t.projects?.restoreSuccess || t.projects?.updateSuccess || "Project updated successfully."
|
||||
: t.projects?.archiveSuccess || t.projects?.updateSuccess || "Project updated successfully.",
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.projects?.updateError || "Failed to update project.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="flex justify-between w-full">
|
||||
|
||||
@@ -43,6 +43,14 @@ const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang:
|
||||
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ");
|
||||
};
|
||||
|
||||
const formatHourlyRate = (
|
||||
rate: { currency: string; amount: string } | null,
|
||||
lang: "en" | "fa",
|
||||
) => {
|
||||
if (!rate) return "-";
|
||||
return `${formatAmount(rate.amount, lang)} ${currencyLabel(rate.currency, lang)}`;
|
||||
};
|
||||
|
||||
const formatDisplayDate = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
@@ -209,6 +217,10 @@ export function ReportsTablePanel({
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.hourlyRate}</div>
|
||||
<div className="font-medium">{formatHourlyRate(day.latest_hourly_rate, lang)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(day.income_totals, lang)}</div>
|
||||
@@ -249,9 +261,10 @@ export function ReportsTablePanel({
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="w-[18%] px-3 py-3 text-start font-medium">{labels.date}</th>
|
||||
<th className="w-[16%] px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="w-[20%] px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="w-[36%] px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
<th className="w-[14%] px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="w-[14%] px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="w-[18%] px-3 py-3 text-start font-medium">{labels.hourlyRate}</th>
|
||||
<th className="w-[26%] px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
<th className="w-[10%] px-3 py-3 text-start font-medium">{labels.details}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -264,6 +277,7 @@ export function ReportsTablePanel({
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{formatDisplayDate(day.date, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatHourlyRate(day.latest_hourly_rate, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(day.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
@@ -300,6 +314,7 @@ export function ReportsTablePanel({
|
||||
<td className="px-3 py-3">{labels.total}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">-</td>
|
||||
<td className="px-3 py-3">{formatMoneyTotals(data.summary.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface TimeEntryFilters {
|
||||
interface TimesheetFilterBarProps {
|
||||
searchQuery: string;
|
||||
filters: TimeEntryFilters;
|
||||
onApply: (searchQuery: string, filters: TimeEntryFilters) => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onApply: (filters: TimeEntryFilters) => void;
|
||||
onClearFilters: () => void;
|
||||
projects: Project[];
|
||||
tags: Tag[];
|
||||
@@ -41,6 +42,8 @@ interface TimesheetFilterBarProps {
|
||||
tagPrefix?: string;
|
||||
fromPrefix?: string;
|
||||
toPrefix?: string;
|
||||
searchTags?: string;
|
||||
noTagsFound?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,11 +52,15 @@ function FilterTagMultiSelect({
|
||||
selectedTagIds,
|
||||
onChange,
|
||||
title,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
}: {
|
||||
tags: Tag[];
|
||||
selectedTagIds: string[];
|
||||
onChange: (tagIds: string[]) => void;
|
||||
title: string;
|
||||
searchPlaceholder: string;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -145,7 +152,7 @@ function FilterTagMultiSelect({
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search tags..."
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs 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"
|
||||
/>
|
||||
</div>
|
||||
@@ -181,7 +188,7 @@ function FilterTagMultiSelect({
|
||||
})}
|
||||
{filteredTags.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
No tags found.
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -215,6 +222,7 @@ function MiniFilterBlock({
|
||||
export default function TimesheetFilterBar({
|
||||
searchQuery,
|
||||
filters,
|
||||
onSearchChange,
|
||||
onApply,
|
||||
onClearFilters,
|
||||
projects,
|
||||
@@ -223,13 +231,8 @@ export default function TimesheetFilterBar({
|
||||
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]);
|
||||
@@ -268,15 +271,15 @@ export default function TimesheetFilterBar({
|
||||
);
|
||||
|
||||
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="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-700 dark:bg-slate-900/95">
|
||||
<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)}
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(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"
|
||||
/>
|
||||
@@ -304,7 +307,7 @@ export default function TimesheetFilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraftSearchQuery("");
|
||||
onSearchChange("");
|
||||
setDraftFilters({
|
||||
projectId: "",
|
||||
clientId: "",
|
||||
@@ -329,7 +332,7 @@ export default function TimesheetFilterBar({
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-200 pt-2 dark:border-slate-800">
|
||||
<div className="border-t border-slate-200 pt-2 dark:border-slate-700">
|
||||
<div className="grid gap-2 md:grid-cols-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 date"}>
|
||||
<JalaliDatePicker
|
||||
@@ -389,6 +392,8 @@ export default function TimesheetFilterBar({
|
||||
selectedTagIds={draftFilters.tagIds}
|
||||
onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))}
|
||||
title={labels?.allTags || "All tags"}
|
||||
searchPlaceholder={labels?.searchTags || "Search tags..."}
|
||||
emptyLabel={labels?.noTagsFound || "No tags found."}
|
||||
/>
|
||||
</MiniFilterBlock>
|
||||
</div>
|
||||
@@ -396,7 +401,7 @@ export default function TimesheetFilterBar({
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(draftSearchQuery, draftFilters)}
|
||||
onClick={() => onApply(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"}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SearchableSelectProps {
|
||||
options: SearchableSelectOption[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
@@ -27,6 +28,7 @@ export function SearchableSelect({
|
||||
options,
|
||||
placeholder = "",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyLabel = "No results",
|
||||
disabled = false,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
@@ -136,7 +138,7 @@ export function SearchableSelect({
|
||||
</button>
|
||||
))}
|
||||
{filteredOptions.length === 0 && (
|
||||
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">No results</div>
|
||||
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">{emptyLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type NotificationLevel,
|
||||
} from "../api/notifications"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import { presentNotification } from "../lib/notificationPresenter"
|
||||
import {
|
||||
getAccessToken,
|
||||
SESSION_CHANGED_EVENT,
|
||||
@@ -153,8 +154,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
toastedNotificationIdsRef.current.add(notification.id)
|
||||
const notify = getToastMethod(notification.level)
|
||||
notify(notification.title || (t.notifications?.newTitle || "New notification"), {
|
||||
description: notification.message || undefined,
|
||||
const presented = presentNotification(notification, t)
|
||||
notify(presented.title || (t.notifications?.newTitle || "New notification"), {
|
||||
description: presented.message || undefined,
|
||||
action: notification.action_url
|
||||
? {
|
||||
label: t.notifications?.openAction || "Open",
|
||||
|
||||
78
src/lib/notificationPresenter.ts
Normal file
78
src/lib/notificationPresenter.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { NotificationItem } from "../api/notifications";
|
||||
|
||||
const roleLabel = (role: unknown, dictionary: any) => {
|
||||
if (typeof role !== "string" || !role) return "";
|
||||
return dictionary?.workspace?.roles?.[role] || role;
|
||||
};
|
||||
|
||||
export const presentNotification = (notification: NotificationItem, dictionary: any) => {
|
||||
const notifications = dictionary?.notifications;
|
||||
const meta = notification.meta || {};
|
||||
const workspaceName = typeof meta.workspace_name === "string" ? meta.workspace_name : "";
|
||||
const actorName = typeof meta.actor_name === "string" ? meta.actor_name : "";
|
||||
const exportType = typeof meta.export_type === "string" ? meta.export_type : "report";
|
||||
const fileName = typeof meta.file_name === "string" ? meta.file_name : null;
|
||||
const previousRole = roleLabel(meta.previous_role, dictionary);
|
||||
const newRole = roleLabel(meta.new_role, dictionary);
|
||||
|
||||
switch (notification.type) {
|
||||
case "workspace_membership_added":
|
||||
return {
|
||||
title: notifications?.workspaceMembershipAddedTitle || notification.title || notification.type,
|
||||
message:
|
||||
notifications?.workspaceMembershipAddedMessage?.(actorName, workspaceName, newRole) ||
|
||||
notification.message ||
|
||||
"",
|
||||
};
|
||||
case "workspace_membership_role_changed":
|
||||
return {
|
||||
title: notifications?.workspaceMembershipRoleChangedTitle || notification.title || notification.type,
|
||||
message:
|
||||
notifications?.workspaceMembershipRoleChangedMessage?.(
|
||||
actorName,
|
||||
workspaceName,
|
||||
previousRole,
|
||||
newRole,
|
||||
) ||
|
||||
notification.message ||
|
||||
"",
|
||||
};
|
||||
case "workspace_membership_deactivated":
|
||||
return {
|
||||
title: notifications?.workspaceMembershipDeactivatedTitle || notification.title || notification.type,
|
||||
message:
|
||||
notifications?.workspaceMembershipDeactivatedMessage?.(actorName, workspaceName) ||
|
||||
notification.message ||
|
||||
"",
|
||||
};
|
||||
case "workspace_membership_removed":
|
||||
return {
|
||||
title: notifications?.workspaceMembershipRemovedTitle || notification.title || notification.type,
|
||||
message:
|
||||
notifications?.workspaceMembershipRemovedMessage?.(actorName, workspaceName) ||
|
||||
notification.message ||
|
||||
"",
|
||||
};
|
||||
case "report_export_ready":
|
||||
return {
|
||||
title: notifications?.reportExportReadyTitle || notification.title || notification.type,
|
||||
message:
|
||||
notifications?.reportExportReadyMessage?.(exportType, workspaceName, fileName) ||
|
||||
notification.message ||
|
||||
"",
|
||||
};
|
||||
case "report_export_failed":
|
||||
return {
|
||||
title: notifications?.reportExportFailedTitle || notification.title || notification.type,
|
||||
message:
|
||||
notifications?.reportExportFailedMessage?.(exportType, workspaceName) ||
|
||||
notification.message ||
|
||||
"",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: notification.title || notification.type,
|
||||
message: notification.message || "",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
export type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
||||
export type ProjectRole = "manager" | "member" | string;
|
||||
|
||||
export const WORKSPACE_VIEW = "workspace.view";
|
||||
export const WORKSPACE_EDIT = "workspace.edit";
|
||||
@@ -22,10 +21,6 @@ export const PROJECTS_CREATE = "projects.create";
|
||||
export const PROJECTS_EDIT = "projects.edit";
|
||||
export const PROJECTS_DELETE = "projects.delete";
|
||||
export const PROJECTS_ARCHIVE = "projects.archive";
|
||||
export const PROJECT_MEMBERS_VIEW = "project_members.view";
|
||||
export const PROJECT_MEMBERS_ADD = "project_members.add";
|
||||
export const PROJECT_MEMBERS_REMOVE = "project_members.remove";
|
||||
export const PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role";
|
||||
export const TIME_ENTRIES_VIEW_OWN = "time_entries.view_own";
|
||||
export const TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own";
|
||||
|
||||
@@ -51,10 +46,6 @@ export type WorkspaceCapability =
|
||||
| typeof PROJECTS_EDIT
|
||||
| typeof PROJECTS_DELETE
|
||||
| typeof PROJECTS_ARCHIVE
|
||||
| typeof PROJECT_MEMBERS_VIEW
|
||||
| typeof PROJECT_MEMBERS_ADD
|
||||
| typeof PROJECT_MEMBERS_REMOVE
|
||||
| typeof PROJECT_MEMBERS_CHANGE_ROLE
|
||||
| typeof TIME_ENTRIES_VIEW_OWN
|
||||
| typeof TIME_ENTRIES_MANAGE_OWN;
|
||||
|
||||
@@ -81,10 +72,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
|
||||
PROJECTS_EDIT,
|
||||
PROJECTS_DELETE,
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECT_MEMBERS_VIEW,
|
||||
PROJECT_MEMBERS_ADD,
|
||||
PROJECT_MEMBERS_REMOVE,
|
||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||
TIME_ENTRIES_VIEW_OWN,
|
||||
TIME_ENTRIES_MANAGE_OWN,
|
||||
]),
|
||||
@@ -109,10 +96,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
|
||||
PROJECTS_EDIT,
|
||||
PROJECTS_DELETE,
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECT_MEMBERS_VIEW,
|
||||
PROJECT_MEMBERS_ADD,
|
||||
PROJECT_MEMBERS_REMOVE,
|
||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||
TIME_ENTRIES_VIEW_OWN,
|
||||
TIME_ENTRIES_MANAGE_OWN,
|
||||
]),
|
||||
@@ -134,15 +117,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
|
||||
]),
|
||||
};
|
||||
|
||||
const PROJECT_MANAGER_CAPABILITIES = new Set<WorkspaceCapability>([
|
||||
PROJECTS_EDIT,
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECT_MEMBERS_VIEW,
|
||||
PROJECT_MEMBERS_ADD,
|
||||
PROJECT_MEMBERS_REMOVE,
|
||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||
]);
|
||||
|
||||
export const canWorkspace = (
|
||||
role: WorkspaceRole | null | undefined,
|
||||
capability: WorkspaceCapability,
|
||||
@@ -151,22 +125,6 @@ export const canWorkspace = (
|
||||
return CAPABILITIES_BY_ROLE[role]?.has(capability) ?? false;
|
||||
};
|
||||
|
||||
export const canProject = ({
|
||||
workspaceRole,
|
||||
projectRole,
|
||||
capability,
|
||||
}: {
|
||||
workspaceRole: WorkspaceRole | null | undefined;
|
||||
projectRole?: ProjectRole | null;
|
||||
capability: WorkspaceCapability;
|
||||
}) => {
|
||||
if (canWorkspace(workspaceRole, capability)) return true;
|
||||
if (workspaceRole === "member" || workspaceRole === "guest" || !workspaceRole) {
|
||||
return false;
|
||||
}
|
||||
return projectRole === "manager" && PROJECT_MANAGER_CAPABILITIES.has(capability);
|
||||
};
|
||||
|
||||
export const canDeleteWorkspaceResource = ({
|
||||
workspaceRole,
|
||||
currentUserId,
|
||||
|
||||
@@ -244,10 +244,13 @@ export const en = {
|
||||
editClient: "Edit Client",
|
||||
deleteConfirmTitle: "Delete Client",
|
||||
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
|
||||
delete: "Delete",
|
||||
saveChanges: "Save Changes",
|
||||
errors: {
|
||||
createFailed: "Failed to create client",
|
||||
delete: "Delete",
|
||||
saveChanges: "Save Changes",
|
||||
createSuccess: "Client created successfully.",
|
||||
updateSuccess: "Client updated successfully.",
|
||||
deleteSuccess: "Client deleted successfully.",
|
||||
errors: {
|
||||
createFailed: "Failed to create client",
|
||||
fetchFailed: "Failed to fetch clients",
|
||||
updateFailed: "Failed to update client",
|
||||
deleteFailed: "Failed to delete client",
|
||||
@@ -289,10 +292,11 @@ export const en = {
|
||||
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",
|
||||
createNew: "Create New",
|
||||
searchPlaceholder: "Search projects...",
|
||||
selectWorkspace: "Please select a workspace first.",
|
||||
titlePlaceholder: "Enter title",
|
||||
descriptionPlaceholder: "Enter desription",
|
||||
titleLabel: "Title",
|
||||
clientLabel: "Client",
|
||||
colorLabel: "Color",
|
||||
@@ -310,8 +314,13 @@ export const en = {
|
||||
createProject: "Create New Project",
|
||||
editProject: "Edit Project",
|
||||
restore: "Restore",
|
||||
archive: "Archive",
|
||||
clientFetchError: "Failed to load clients.",
|
||||
archive: "Archive",
|
||||
archiveSuccess: "Project archived successfully.",
|
||||
restoreSuccess: "Project restored successfully.",
|
||||
fetchError: "Failed to fetch projects.",
|
||||
clientFetchError: "Failed to load clients.",
|
||||
filterClients: "Filter by client",
|
||||
clearClientFilters: "Clear filters",
|
||||
namePlaceholder: "Project name...",
|
||||
teamMembers: "Team Members",
|
||||
creator: "Creator",
|
||||
@@ -414,13 +423,14 @@ export const en = {
|
||||
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",
|
||||
projectLabel: "Project",
|
||||
noProject: "No project",
|
||||
startLabel: "Start",
|
||||
endLabel: "End",
|
||||
timeLabel: "Time",
|
||||
billable: "Billable",
|
||||
noTagsHint: "Create tags first from the Tags page.",
|
||||
clearFilters: "Clear filters",
|
||||
customFromLabel: "From date",
|
||||
customToLabel: "To date",
|
||||
allClientsLabel: "All clients",
|
||||
@@ -430,13 +440,21 @@ export const en = {
|
||||
hideFiltersLabel: "Hide filters",
|
||||
applyFiltersLabel: "Apply",
|
||||
clientFilterPrefix: "Client",
|
||||
projectFilterPrefix: "Project",
|
||||
tagFilterPrefix: "Tag",
|
||||
fromFilterPrefix: "From",
|
||||
toFilterPrefix: "To",
|
||||
deletedProjectLabel: "Deleted project",
|
||||
deletedTagLabel: "Deleted tag",
|
||||
},
|
||||
projectFilterPrefix: "Project",
|
||||
tagFilterPrefix: "Tag",
|
||||
fromFilterPrefix: "From",
|
||||
toFilterPrefix: "To",
|
||||
deleteTitle: "Delete Time Entry",
|
||||
deleteConfirmMessage: "Are you sure you want to delete this time entry?",
|
||||
restartConfirmMessage: "Start a new running timer from this entry?",
|
||||
discardConfirmMessage: "Are you sure you want to discard this running timer?",
|
||||
searchTagsLabel: "Search tags...",
|
||||
noTagsFoundLabel: "No tags found.",
|
||||
searchProjectsLabel: "Search projects...",
|
||||
noProjectsFoundLabel: "No projects found.",
|
||||
deletedProjectLabel: "Deleted project",
|
||||
deletedTagLabel: "Deleted tag",
|
||||
},
|
||||
|
||||
reports: {
|
||||
title: "Reports",
|
||||
@@ -468,10 +486,11 @@ export const en = {
|
||||
name: "Name",
|
||||
clear: "Clear",
|
||||
apply: "Apply",
|
||||
totalHours: "Total hours",
|
||||
billableHours: "Billable hours",
|
||||
nonBillableHours: "Non-billable hours",
|
||||
totalIncome: "Total income",
|
||||
totalHours: "Total hours",
|
||||
billableHours: "Billable hours",
|
||||
nonBillableHours: "Non-billable hours",
|
||||
hourlyRate: "Hourly rate",
|
||||
totalIncome: "Total income",
|
||||
chartTitle: "Activity chart",
|
||||
totalSeconds: "Total seconds",
|
||||
exportExcel: "Export Excel",
|
||||
@@ -543,7 +562,6 @@ export const en = {
|
||||
workspace_members: "Workspace members",
|
||||
clients: "Clients",
|
||||
projects: "Projects",
|
||||
project_members: "Project members",
|
||||
tags: "Tags",
|
||||
time_entries: "Time entries",
|
||||
rates: "Rates",
|
||||
@@ -562,20 +580,44 @@ export const en = {
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
open: "Open notifications",
|
||||
empty: "No notifications yet.",
|
||||
loading: "Loading notifications...",
|
||||
loadingMore: "Loading more...",
|
||||
loadMore: "Load more",
|
||||
markAllRead: "Mark all as read",
|
||||
markSeenError: "Failed to update notification",
|
||||
markAllError: "Failed to update notifications",
|
||||
deleteError: "Failed to delete notification",
|
||||
loadError: "Failed to load notifications",
|
||||
openError: "Failed to open notification",
|
||||
newTitle: "New notification",
|
||||
openAction: "Open",
|
||||
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
|
||||
},
|
||||
}
|
||||
title: "Notifications",
|
||||
pageDescription: "Review all notifications and export updates.",
|
||||
open: "Open notifications",
|
||||
empty: "No notifications yet.",
|
||||
emptyUnread: "No unread notifications.",
|
||||
loading: "Loading notifications...",
|
||||
loadingMore: "Loading more...",
|
||||
loadMore: "Load more",
|
||||
markAllRead: "Mark all as read",
|
||||
viewAll: "View all notifications",
|
||||
totalLabel: "Total notifications",
|
||||
unreadLabel: "Unread notifications",
|
||||
deleteLabel: "Delete notification",
|
||||
markSeenError: "Failed to update notification",
|
||||
markAllError: "Failed to update notifications",
|
||||
deleteError: "Failed to delete notification",
|
||||
loadError: "Failed to load notifications",
|
||||
openError: "Failed to open notification",
|
||||
newTitle: "New notification",
|
||||
openAction: "Open",
|
||||
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
|
||||
workspaceMembershipAddedTitle: "Added to workspace",
|
||||
workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) =>
|
||||
`${actor} added you to ${workspace} as ${role}.`,
|
||||
workspaceMembershipRoleChangedTitle: "Workspace role changed",
|
||||
workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) =>
|
||||
`${actor} changed your role in ${workspace} from ${previousRole} to ${newRole}.`,
|
||||
workspaceMembershipDeactivatedTitle: "Workspace access deactivated",
|
||||
workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) =>
|
||||
`${actor} deactivated your access to ${workspace}.`,
|
||||
workspaceMembershipRemovedTitle: "Removed from workspace",
|
||||
workspaceMembershipRemovedMessage: (actor: string, workspace: string) =>
|
||||
`${actor} removed you from ${workspace}.`,
|
||||
reportExportReadyTitle: "Report export is ready",
|
||||
reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) =>
|
||||
`Your ${exportType.toUpperCase()} report for ${workspace} is ready${fileName ? `: ${fileName}` : ""}.`,
|
||||
reportExportFailedTitle: "Report export failed",
|
||||
reportExportFailedMessage: (exportType: string, workspace: string) =>
|
||||
`Your ${exportType.toUpperCase()} report for ${workspace} could not be generated.`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -241,10 +241,13 @@ export const fa = {
|
||||
editClient: "ویرایش مشتری",
|
||||
deleteConfirmTitle: "حذف مشتری",
|
||||
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
|
||||
delete: "حذف",
|
||||
saveChanges: "ذخیره تغییرات",
|
||||
errors: {
|
||||
createFailed: "خطا در ایجاد مشتری",
|
||||
delete: "حذف",
|
||||
saveChanges: "ذخیره تغییرات",
|
||||
createSuccess: "مشتری با موفقیت ایجاد شد.",
|
||||
updateSuccess: "مشتری با موفقیت بهروزرسانی شد.",
|
||||
deleteSuccess: "مشتری با موفقیت حذف شد.",
|
||||
errors: {
|
||||
createFailed: "خطا در ایجاد مشتری",
|
||||
fetchFailed: "خطا در دریافت لیست مشتریها",
|
||||
updateFailed: "خطا در ویرایش مشتری",
|
||||
deleteFailed: "خطا در حذف مشتری",
|
||||
@@ -285,10 +288,11 @@ export const fa = {
|
||||
title: "پروژهها",
|
||||
description: (workspaceName: string) => `مدیریت پروژهها برای ${workspaceName}`,
|
||||
active: "پروژههای فعال",
|
||||
archived: "پروژههای بایگانی شده",
|
||||
createNew: "ایجاد پروژه جدید",
|
||||
searchPlaceholder: "جستجوی پروژهها...",
|
||||
titlePlaceholder: "عنوان پروژه",
|
||||
archived: "پروژههای بایگانی شده",
|
||||
createNew: "ایجاد پروژه جدید",
|
||||
searchPlaceholder: "جستجوی پروژهها...",
|
||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||
titlePlaceholder: "عنوان پروژه",
|
||||
descriptionPlaceholder: "توضیحات پروژه",
|
||||
titleLabel: "عنوان",
|
||||
descriptionLabel: "توضیحات",
|
||||
@@ -305,10 +309,15 @@ export const fa = {
|
||||
create: "ایجاد",
|
||||
cancel: "انصراف",
|
||||
createProject: "ایجاد پروژه",
|
||||
editProject: "ویرایش پروژه",
|
||||
restore: "بازیابی",
|
||||
archive: "بایگانی",
|
||||
clientFetchError: "خطا در دریافت لیست مشتریها.",
|
||||
editProject: "ویرایش پروژه",
|
||||
restore: "بازیابی",
|
||||
archive: "بایگانی",
|
||||
archiveSuccess: "پروژه با موفقیت بایگانی شد.",
|
||||
restoreSuccess: "پروژه با موفقیت بازیابی شد.",
|
||||
fetchError: "خطا در دریافت پروژهها.",
|
||||
clientFetchError: "خطا در دریافت لیست مشتریها.",
|
||||
filterClients: "فیلتر بر اساس مشتری",
|
||||
clearClientFilters: "پاک کردن فیلترها",
|
||||
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
||||
creator: "سازنده",
|
||||
addUser: "افزودن کاربر",
|
||||
@@ -411,13 +420,14 @@ export const fa = {
|
||||
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
||||
descriptionLabel: "توضیحات",
|
||||
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
||||
projectLabel: "پروژه",
|
||||
noProject: "بدون پروژه",
|
||||
startLabel: "شروع",
|
||||
endLabel: "پایان",
|
||||
billable: "قابل صورتحساب",
|
||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||
clearFilters: "پاک کردن فیلترها",
|
||||
projectLabel: "پروژه",
|
||||
noProject: "بدون پروژه",
|
||||
startLabel: "شروع",
|
||||
endLabel: "پایان",
|
||||
timeLabel: "زمان",
|
||||
billable: "قابل صورتحساب",
|
||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||
clearFilters: "پاک کردن فیلترها",
|
||||
customFromLabel: "از تاریخ",
|
||||
customToLabel: "تا تاریخ",
|
||||
allClientsLabel: "همه مشتریها",
|
||||
@@ -427,13 +437,21 @@ export const fa = {
|
||||
hideFiltersLabel: "مخفی کردن فیلترها",
|
||||
applyFiltersLabel: "اعمال",
|
||||
clientFilterPrefix: "مشتری",
|
||||
projectFilterPrefix: "پروژه",
|
||||
tagFilterPrefix: "تگ",
|
||||
fromFilterPrefix: "از",
|
||||
toFilterPrefix: "تا",
|
||||
deletedProjectLabel: "پروژه حذفشده",
|
||||
deletedTagLabel: "تگ حذفشده",
|
||||
},
|
||||
projectFilterPrefix: "پروژه",
|
||||
tagFilterPrefix: "تگ",
|
||||
fromFilterPrefix: "از",
|
||||
toFilterPrefix: "تا",
|
||||
deleteTitle: "حذف ورودی زمان",
|
||||
deleteConfirmMessage: "آیا از حذف این ورودی زمان اطمینان دارید؟",
|
||||
restartConfirmMessage: "میخواهید یک تایمر جدید را از روی این ورودی شروع کنید؟",
|
||||
discardConfirmMessage: "آیا از دور انداختن این تایمر در حال اجرا اطمینان دارید؟",
|
||||
searchTagsLabel: "جستوجوی تگها...",
|
||||
noTagsFoundLabel: "تگی پیدا نشد.",
|
||||
searchProjectsLabel: "جستوجوی پروژهها...",
|
||||
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
||||
deletedProjectLabel: "پروژه حذفشده",
|
||||
deletedTagLabel: "تگ حذفشده",
|
||||
},
|
||||
reports: {
|
||||
title: "گزارشها",
|
||||
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
||||
@@ -464,10 +482,11 @@ export const fa = {
|
||||
name: "نام",
|
||||
clear: "پاک کردن",
|
||||
apply: "اعمال",
|
||||
totalHours: "مجموع ساعت",
|
||||
billableHours: "ساعات کاری",
|
||||
nonBillableHours: "ساعات غیر کاری",
|
||||
totalIncome: "مجموع درآمد",
|
||||
totalHours: "مجموع ساعت",
|
||||
billableHours: "ساعات کاری",
|
||||
nonBillableHours: "ساعات غیر کاری",
|
||||
hourlyRate: "نرخ ساعتی",
|
||||
totalIncome: "مجموع درآمد",
|
||||
chartTitle: "نمودار فعالیت",
|
||||
totalSeconds: "مجموع ثانیه",
|
||||
exportExcel: "خروجی Excel",
|
||||
@@ -538,7 +557,6 @@ export const fa = {
|
||||
workspace_members: "اعضای ورکاسپیس",
|
||||
clients: "مشتریها",
|
||||
projects: "پروژهها",
|
||||
project_members: "اعضای پروژه",
|
||||
tags: "تگها",
|
||||
time_entries: "ورودیهای زمان",
|
||||
rates: "نرخها",
|
||||
@@ -556,20 +574,44 @@ export const fa = {
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
title: "اعلانها",
|
||||
open: "باز کردن اعلانها",
|
||||
empty: "هنوز اعلانی وجود ندارد.",
|
||||
loading: "در حال بارگذاری اعلانها...",
|
||||
loadingMore: "در حال بارگذاری بیشتر...",
|
||||
loadMore: "بارگذاری بیشتر",
|
||||
markAllRead: "خواندن همه",
|
||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||
openError: "باز کردن اعلان با خطا مواجه شد.",
|
||||
newTitle: "اعلان جدید",
|
||||
openAction: "باز کردن",
|
||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||
},
|
||||
}
|
||||
title: "اعلانها",
|
||||
pageDescription: "مرور همه اعلانها و وضعیت خروجیهای گزارش.",
|
||||
open: "باز کردن اعلانها",
|
||||
empty: "هنوز اعلانی وجود ندارد.",
|
||||
emptyUnread: "اعلان خواندهنشدهای وجود ندارد.",
|
||||
loading: "در حال بارگذاری اعلانها...",
|
||||
loadingMore: "در حال بارگذاری بیشتر...",
|
||||
loadMore: "بارگذاری بیشتر",
|
||||
markAllRead: "خواندن همه",
|
||||
viewAll: "نمایش همه اعلانها",
|
||||
totalLabel: "مجموع اعلانها",
|
||||
unreadLabel: "اعلانهای خواندهنشده",
|
||||
deleteLabel: "حذف اعلان",
|
||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||
openError: "باز کردن اعلان با خطا مواجه شد.",
|
||||
newTitle: "اعلان جدید",
|
||||
openAction: "باز کردن",
|
||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||
workspaceMembershipAddedTitle: "به ورکاسپیس اضافه شدید",
|
||||
workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) =>
|
||||
`${actor} شما را با نقش ${role} به ${workspace} اضافه کرد.`,
|
||||
workspaceMembershipRoleChangedTitle: "نقش شما در ورکاسپیس تغییر کرد",
|
||||
workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) =>
|
||||
`${actor} نقش شما را در ${workspace} از ${previousRole} به ${newRole} تغییر داد.`,
|
||||
workspaceMembershipDeactivatedTitle: "دسترسی ورکاسپیس غیرفعال شد",
|
||||
workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) =>
|
||||
`${actor} دسترسی شما به ${workspace} را غیرفعال کرد.`,
|
||||
workspaceMembershipRemovedTitle: "از ورکاسپیس حذف شدید",
|
||||
workspaceMembershipRemovedMessage: (actor: string, workspace: string) =>
|
||||
`${actor} شما را از ${workspace} حذف کرد.`,
|
||||
reportExportReadyTitle: "خروجی گزارش آماده است",
|
||||
reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) =>
|
||||
`خروجی ${exportType.toUpperCase()} گزارش ${workspace}${fileName ? ` با نام ${fileName}` : ""} آماده دانلود است.`,
|
||||
reportExportFailedTitle: "خروجی گزارش ناموفق بود",
|
||||
reportExportFailedMessage: (exportType: string, workspace: string) =>
|
||||
`تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useWorkspace } from "../context/WorkspaceContext"
|
||||
import { useAppContext } from "../context/AppContext"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
@@ -13,11 +14,12 @@ import { type Client } from "../types/client"
|
||||
import { getClients } from "../api/clients"
|
||||
import CreateClientModal from "../components/CreateClientModal"
|
||||
import EditClientModal from "../components/EditClientModal"
|
||||
import DeleteClientModal from "../components/DeleteClientModal"
|
||||
import FilterBar from "../components/FilterBar"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { Card } from "../components/ui/card"
|
||||
import { Pagination } from "../components/Pagination"
|
||||
import DeleteClientModal from "../components/DeleteClientModal"
|
||||
import FilterBar from "../components/FilterBar"
|
||||
import { ListPageSkeleton } from "../components/ListPageSkeleton"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { Card, CardContent, CardTitle } from "../components/ui/card"
|
||||
import { Pagination } from "../components/Pagination"
|
||||
|
||||
export default function Clients() {
|
||||
const { activeWorkspace } = useWorkspace()
|
||||
@@ -82,11 +84,12 @@ export default function Clients() {
|
||||
|
||||
setClients(items)
|
||||
setTotalItems(count)
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.fetchFailed, error)
|
||||
setClients([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.fetchFailed, error)
|
||||
toast.error(t.clients.errors.fetchFailed)
|
||||
setClients([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,122 +110,144 @@ export default function Clients() {
|
||||
fetchClientsList()
|
||||
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return (
|
||||
<div className="p-6 text-center text-slate-500">
|
||||
{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 justify-between items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
{t.clients.description(activeWorkspace.name)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreateClient && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
size="icon"
|
||||
className="shadow-sm shrink-0"
|
||||
title={t.clients.addClient}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.clients.searchPlaceholder}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
|
||||
<div className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center p-12 text-slate-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="text-center p-12">
|
||||
<Building2 className="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||
{searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
if (!activeWorkspace) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
{t.clients.selectWorkspace}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.clients.description(activeWorkspace.name)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreateClient && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
size="icon"
|
||||
className="shrink-0 shadow-sm"
|
||||
title={t.clients.addClient}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<FilterBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.clients.searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ListPageSkeleton variant="standard-grid" />
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{clients.length === 0 ? (
|
||||
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
{searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{clients.map((client) => {
|
||||
const canDeleteClient = canDeleteWorkspaceResource({
|
||||
workspaceRole,
|
||||
currentUserId: user?.id,
|
||||
createdById: client.created_by?.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<li key={client.id} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-slate-900 dark:text-white truncate">{client.name}</h4>
|
||||
{client.notes && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
||||
{client.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(canEditClient || canDeleteClient) && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{canEditClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditClient(client)}
|
||||
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"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteClient(client)}
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
<Card key={client.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||
{client.name.trim().charAt(0).toUpperCase() || "C"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(canEditClient || canDeleteClient) && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canEditClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditClient(client)}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
title={t.actions?.edit || "Edit"}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteClient(client)}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title={t.actions?.delete || "Delete"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="min-h-[3.75rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300">
|
||||
{client.notes || t.workspace?.noDescription || "No description"}
|
||||
</p>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.clients.addedOn}: {formatDate(client.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!isLoading && clients.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canCreateClient && (
|
||||
<CreateClientModal
|
||||
|
||||
{clients.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreateClient && (
|
||||
<CreateClientModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={fetchClientsList}
|
||||
|
||||
101
src/pages/Notifications.tsx
Normal file
101
src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { CheckCheck, Loader2 } from "lucide-react";
|
||||
|
||||
import { NotificationList } from "../components/notifications/NotificationList";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
totalCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
markAllAsSeen,
|
||||
deleteOne,
|
||||
handleNotificationClick,
|
||||
} = useNotifications();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t.notifications?.title || "Notifications"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.notifications?.pageDescription || "Review all notifications and export updates."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void markAllAsSeen()}
|
||||
disabled={unreadCount === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
{t.notifications?.markAllRead || "Mark all as read"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.notifications?.totalLabel || "Total notifications"}
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{totalCount}</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.notifications?.unreadLabel || "Unread notifications"}
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{unreadCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center px-4 py-12 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t.notifications?.loading || "Loading notifications..."}
|
||||
</div>
|
||||
) : (
|
||||
<NotificationList
|
||||
notifications={notifications}
|
||||
emptyLabel={t.notifications?.empty || "No notifications yet."}
|
||||
onClick={(item) => void handleNotificationClick(item)}
|
||||
onDelete={(item) => void deleteOne(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t.notifications?.loadingMore || "Loading more..."}
|
||||
</>
|
||||
) : (
|
||||
t.notifications?.loadMore || "Load more"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,44 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useBlocker, useNavigate } from "react-router-dom";
|
||||
import { Briefcase, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getClients } from "../api/clients";
|
||||
import { fetchWorkspaceMemberships } from "../api/workspaces";
|
||||
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { createProject } from "../api/projects";
|
||||
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 { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
|
||||
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 COLORS = [
|
||||
"#3B82F6",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#EC4899",
|
||||
"#14B8A6",
|
||||
"#64748B",
|
||||
];
|
||||
|
||||
export default function ProjectCreate() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAppContext();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const currentUserId = user?.id || "";
|
||||
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
||||
|
||||
// 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;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [client, setClient] = useState("");
|
||||
const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || client !== "" || color !== COLORS[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkspace && !canCreateProject) {
|
||||
@@ -99,544 +46,144 @@ export default function ProjectCreate() {
|
||||
navigate("/projects");
|
||||
}
|
||||
}, [activeWorkspace, canCreateProject, navigate]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!activeWorkspace?.id) return;
|
||||
|
||||
setName("");
|
||||
setDescription("");
|
||||
setColor(COLORS[0]);
|
||||
setClient("");
|
||||
setClientsList([]);
|
||||
setIsLoadingData(true);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const clientsRes = await getClients(activeWorkspace.id);
|
||||
setClientsList(clientsRes.results || []);
|
||||
} catch {
|
||||
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialData();
|
||||
}, [activeWorkspace?.id, t.projects?.clientFetchError]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !activeWorkspace) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const newProject = await createProject({
|
||||
workspace: activeWorkspace.id,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
client: client || null,
|
||||
is_archived: false,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t.projects?.createNew || "Create New Project"}
|
||||
</h1>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{COLORS.map((paletteColor) => (
|
||||
<button
|
||||
key={paletteColor}
|
||||
type="button"
|
||||
onClick={() => setColor(paletteColor)}
|
||||
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
|
||||
color === paletteColor
|
||||
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
|
||||
: "shadow-sm hover:scale-110"
|
||||
}`}
|
||||
style={{ backgroundColor: paletteColor }}
|
||||
aria-label={`Select color ${paletteColor}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<Briefcase size={16} />
|
||||
{t.projects?.client || "Client"}
|
||||
</label>
|
||||
<Select
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={[
|
||||
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||
...clientsList.map((item) => ({ value: item.id, label: item.name })),
|
||||
]}
|
||||
isLoading={isLoadingData}
|
||||
className="w-full"
|
||||
buttonClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-slate-700 dark:text-slate-300">
|
||||
{t.projects?.descriptionLabel || "Description"}
|
||||
</label>
|
||||
<TextAreaInput
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
|
||||
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||
{t.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||
{isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t.create || "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,45 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
import { Briefcase, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getClients } from "../api/clients";
|
||||
import { fetchWorkspaceMemberships } from "../api/workspaces";
|
||||
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { getProject, updateProject } from "../api/projects";
|
||||
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 { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
|
||||
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 COLORS = [
|
||||
"#3B82F6",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#EC4899",
|
||||
"#14B8A6",
|
||||
"#64748B",
|
||||
];
|
||||
|
||||
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 canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
|
||||
|
||||
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 [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [client, setClient] = useState("");
|
||||
const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
|
||||
const hasUnsavedChanges = name.trim() !== "";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,532 +48,153 @@ export default function ProjectEdit() {
|
||||
navigate("/projects");
|
||||
}
|
||||
}, [activeWorkspace, canEditProject, navigate]);
|
||||
|
||||
useBlocker(({ currentLocation, nextLocation }) => {
|
||||
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkspace?.id && id) {
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const clientsRes = await getClients(activeWorkspace.id);
|
||||
setClientsList(clientsRes.results || []);
|
||||
|
||||
const projectRes = await getProject(id);
|
||||
setName(projectRes.name || "");
|
||||
setDescription(projectRes.description || "");
|
||||
setColor(projectRes.color || COLORS[0]);
|
||||
setClient(projectRes.client?.id || projectRes.client || "");
|
||||
|
||||
if (projectRes.members) {
|
||||
const mappedMembers = projectRes.members.map((m: any) => ({
|
||||
localId: m.id,
|
||||
user: {
|
||||
id: m.user_details?.id || m.user,
|
||||
first_name: m.user_details?.first_name || "",
|
||||
last_name: m.user_details?.last_name || "",
|
||||
mobile: m.user_details?.phone_number || "",
|
||||
profile_picture: m.user_details?.avatar || "",
|
||||
},
|
||||
role: m.role as ProjectRole,
|
||||
isCreator: m.user === currentUserId && m.role === "manager",
|
||||
}));
|
||||
setMembers(mappedMembers);
|
||||
}
|
||||
const res = await fetchWorkspaceMemberships({
|
||||
workspace: activeWorkspace.id,
|
||||
limit: LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
const results = res.results || (Array.isArray(res) ? res : []);
|
||||
|
||||
setWorkspaceMembers(results);
|
||||
setOffset(LIMIT);
|
||||
setHasMore(res.next ? true : results.length >= LIMIT);
|
||||
} catch (err) {
|
||||
toast.error("Failed to load project data.");
|
||||
navigate("/projects");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
setIsProjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
}
|
||||
}, [activeWorkspace?.id, id, currentUserId, navigate]);
|
||||
|
||||
const loadMoreMembers = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore || !activeWorkspace?.id) return;
|
||||
try {
|
||||
setIsLoadingMore(true);
|
||||
const res = await fetchWorkspaceMemberships({
|
||||
workspace: activeWorkspace.id,
|
||||
limit: LIMIT,
|
||||
offset: offset
|
||||
});
|
||||
const results = res.results || (Array.isArray(res) ? res : []);
|
||||
|
||||
setWorkspaceMembers((prev) => {
|
||||
const existingIds = new Set(prev.map(m => m.id));
|
||||
const newItems = results.filter((item: any) => !existingIds.has(item.id));
|
||||
return [...prev, ...newItems];
|
||||
});
|
||||
|
||||
setOffset(prev => prev + LIMIT);
|
||||
setHasMore(res.next ? true : results.length >= LIMIT);
|
||||
} catch (error) {
|
||||
console.error("Failed to load more members", error);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [activeWorkspace?.id, isLoadingMore, hasMore, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
const cleanQuery = toEnglishDigits(searchQuery.trim());
|
||||
setSearchError(false);
|
||||
|
||||
if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) {
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const foundUser = await searchUserByExactMobile(cleanQuery);
|
||||
if (foundUser && foundUser.id) {
|
||||
setSearchResult(foundUser);
|
||||
setSearchError(false);
|
||||
} else {
|
||||
setSearchResult(null);
|
||||
setSearchError(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setSearchResult(null);
|
||||
setSearchError(true);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
setSearchResult(null);
|
||||
}
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleAddMember = (userToAdd: any) => {
|
||||
if (members.some((m) => m.user.id === userToAdd.id)) return;
|
||||
const newMember: LocalMember = {
|
||||
localId: Math.random().toString(36).substr(2, 9),
|
||||
user: userToAdd,
|
||||
role: "member",
|
||||
};
|
||||
setMembers((prev) => [newMember, ...prev]);
|
||||
setSearchQuery("");
|
||||
setSearchResult(null);
|
||||
};
|
||||
|
||||
const handleToggleAddAllMembers = async () => {
|
||||
if (addAllMembers) {
|
||||
setMembers((prev) => prev.filter(m => m.isCreator || m.role === "manager"));
|
||||
setAddAllMembers(false);
|
||||
} else {
|
||||
if (!activeWorkspace?.id) return;
|
||||
setIsAddingAll(true);
|
||||
try {
|
||||
let currentOffset = 0;
|
||||
let continueFetching = true;
|
||||
const allWsMembers: any[] = [];
|
||||
|
||||
while (continueFetching) {
|
||||
const res = await fetchWorkspaceMemberships({
|
||||
workspace: activeWorkspace.id,
|
||||
limit: 50,
|
||||
offset: currentOffset,
|
||||
});
|
||||
const fetchedResults = res.results || (Array.isArray(res) ? res : []);
|
||||
allWsMembers.push(...fetchedResults);
|
||||
if (res.next) {
|
||||
currentOffset += 50;
|
||||
} else {
|
||||
continueFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
const newMembersToAdd = allWsMembers
|
||||
.map((wm) => wm.user)
|
||||
.filter((u) => u && !members.some((m) => m.user.id === u.id));
|
||||
|
||||
const localMembers: LocalMember[] = newMembersToAdd.map((u) => ({
|
||||
localId: Math.random().toString(36).substr(2, 9),
|
||||
user: u,
|
||||
role: "member",
|
||||
}));
|
||||
|
||||
setMembers((prev) => [...prev, ...localMembers]);
|
||||
setAddAllMembers(true);
|
||||
} catch (error) {
|
||||
toast.error("Could not add all workspace members.");
|
||||
} finally {
|
||||
setIsAddingAll(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteModal = (userId: string) => {
|
||||
setMemberIdToDelete(userId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteMember = () => {
|
||||
if (!memberIdToDelete) return;
|
||||
setMembers(members.filter((m) => m.user.id !== memberIdToDelete));
|
||||
setIsDeleteDialogOpen(false);
|
||||
setMemberIdToDelete(null);
|
||||
};
|
||||
|
||||
const handleChangeRole = (userId: string, newRole: string) => {
|
||||
setMembers(
|
||||
members.map((m) => (m.user.id === userId ? { ...m, role: newRole as ProjectRole } : m))
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !activeWorkspace || !id) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const membersPayload = members.map((m) => ({ user_id: m.user.id, role: m.role }));
|
||||
|
||||
const projectPayload: any = {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspace: activeWorkspace.id,
|
||||
members: membersPayload,
|
||||
client: client || null,
|
||||
};
|
||||
|
||||
const updatedProject = await updateProject(id, projectPayload);
|
||||
|
||||
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
|
||||
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||
navigate("/projects");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t.projects?.updateError || "Failed to update project.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredWorkspaceMembers = workspaceMembers.filter((m) => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
|
||||
const phone = toEnglishDigits(m.user.mobile || "");
|
||||
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
|
||||
});
|
||||
|
||||
const workspaceMemberUserIds = new Set(filteredWorkspaceMembers.map((m) => m.user.id));
|
||||
|
||||
const externalAddedMembers = members.filter((m) => {
|
||||
if (workspaceMemberUserIds.has(m.user.id)) return false;
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
|
||||
const phone = toEnglishDigits(m.user.mobile || "");
|
||||
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
|
||||
});
|
||||
|
||||
const displayList = [
|
||||
...externalAddedMembers.map((m) => ({ listId: m.localId, user: m.user })),
|
||||
...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user }))
|
||||
];
|
||||
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
if (isProjectLoading) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
|
||||
{t.projects?.edit || "Edit Project"}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
|
||||
<form id="edit-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
|
||||
color === c
|
||||
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
|
||||
: "hover:scale-110 shadow-sm"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={`Select color ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||
<Briefcase size={16} />
|
||||
{t.projects?.client || "Client"}
|
||||
</label>
|
||||
<Select
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={[
|
||||
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||
...clientsList.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
className="w-full"
|
||||
buttonClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-700 dark:text-slate-300 mb-2">
|
||||
{t.projects?.descriptionLabel || "Description (Optional)"}
|
||||
</label>
|
||||
<TextAreaInput
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
|
||||
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||
{t.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t.save || "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||
<Users size={18} />
|
||||
{t.projects?.projectMembers || "Project Members"}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant={addAllMembers ? "destructive" : "outline"}
|
||||
disabled={isAddingAll || isLoadingData}
|
||||
onClick={handleToggleAddAllMembers}
|
||||
>
|
||||
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{addAllMembers
|
||||
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
|
||||
: (t.projects?.addAllWorkspaceMembers || "Add All")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
|
||||
className="ps-10"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searchError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
|
||||
{t.projects?.userNotFound || "No user found with this mobile number."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{searchResult && !searchError && (
|
||||
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{searchResult.profile_picture ? (
|
||||
<img
|
||||
src={searchResult.profile_picture}
|
||||
alt={searchResult.first_name}
|
||||
className="w-10 h-10 rounded-full object-cover shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||
{searchResult.first_name?.[0] || "U"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||
{searchResult.first_name} {searchResult.last_name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{searchResult.mobile}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={members.some((m) => m.user.id === searchResult.id)}
|
||||
onClick={() => handleAddMember(searchResult)}
|
||||
>
|
||||
{members.some((m) => m.user.id === searchResult.id)
|
||||
? (t.projects?.alreadyInProject || "Already Added")
|
||||
: (t.projects?.addToProject || "Add to Project")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoadingData ? (
|
||||
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t.loading || "Loading..."}
|
||||
</div>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
onLoadMore={loadMoreMembers}
|
||||
hasMore={hasMore && searchQuery.trim().length === 0}
|
||||
isLoading={isLoadingMore}
|
||||
>
|
||||
{displayList.length === 0 ? (
|
||||
<div className="p-4 text-sm text-slate-500 text-center">
|
||||
{t.projects?.noWorkspaceMembers || "No members found."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
{displayList.map((item) => {
|
||||
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
|
||||
const isAdded = !!addedMemberData;
|
||||
|
||||
return (
|
||||
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.user.profile_picture ? (
|
||||
<img
|
||||
src={item.user.profile_picture}
|
||||
alt={item.user.first_name}
|
||||
className="w-9 h-9 rounded-full object-cover shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
|
||||
{item.user.first_name?.[0] || "U"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
|
||||
{item.user.first_name} {item.user.last_name}
|
||||
{addedMemberData?.isCreator && (
|
||||
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
|
||||
{t.projects?.creator || "Creator"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{item.user.mobile}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isAdded ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={addedMemberData.role}
|
||||
onChange={(val) => handleChangeRole(item.user.id, val)}
|
||||
options={[
|
||||
{ value: "member", label: t.projects?.roles?.member || "Member" },
|
||||
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
|
||||
]}
|
||||
buttonClassName="text-xs h-8 w-28"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
onClick={() => openDeleteModal(item.user.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleAddMember(item.user)}
|
||||
>
|
||||
{t.projects?.addToProject || "Add to Project"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
useBlocker(({ currentLocation, nextLocation }) => {
|
||||
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspace?.id || !id) return;
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [clientsRes, projectRes] = await Promise.all([
|
||||
getClients(activeWorkspace.id),
|
||||
getProject(id),
|
||||
]);
|
||||
|
||||
setClientsList(clientsRes.results || []);
|
||||
setName(projectRes.name || "");
|
||||
setDescription(projectRes.description || "");
|
||||
setColor(projectRes.color || COLORS[0]);
|
||||
setClient(projectRes.client?.id || projectRes.client || "");
|
||||
} catch {
|
||||
toast.error("Failed to load project data.");
|
||||
navigate("/projects");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
setIsProjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialData();
|
||||
}, [activeWorkspace?.id, id, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !activeWorkspace || !id) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updatedProject = await updateProject(id, {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
client: client || null,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
|
||||
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||
navigate("/projects");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t.projects?.updateError || "Failed to update project.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
if (isProjectLoading) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t.projects?.edit || "Edit Project"}
|
||||
</h1>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.projects?.namePlaceholder || "Project name..."}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{COLORS.map((paletteColor) => (
|
||||
<button
|
||||
key={paletteColor}
|
||||
type="button"
|
||||
onClick={() => setColor(paletteColor)}
|
||||
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
|
||||
color === paletteColor
|
||||
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
|
||||
: "shadow-sm hover:scale-110"
|
||||
}`}
|
||||
style={{ backgroundColor: paletteColor }}
|
||||
aria-label={`Select color ${paletteColor}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<Briefcase size={16} />
|
||||
{t.projects?.client || "Client"}
|
||||
</label>
|
||||
<Select
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={[
|
||||
{ value: "", label: t.projects?.noClient || "No Client" },
|
||||
...clientsList.map((item) => ({ value: item.id, label: item.name })),
|
||||
]}
|
||||
isLoading={isLoadingData}
|
||||
className="w-full"
|
||||
buttonClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-slate-700 dark:text-slate-300">
|
||||
{t.projects?.descriptionLabel || "Description"}
|
||||
</label>
|
||||
<TextAreaInput
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
|
||||
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
|
||||
{t.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||
{isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t.save || "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { getProjects, deleteProject, type Project } from "../api/projects";
|
||||
import { getClients } from "../api/clients";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
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 } from "../components/ui/card";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
|
||||
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react";
|
||||
|
||||
import FilterBar from "../components/FilterBar";
|
||||
import { ListPageSkeleton } from "../components/ListPageSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../components/ui/input";
|
||||
import {
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECTS_CREATE,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
canWorkspace,
|
||||
} from "../lib/permissions";
|
||||
|
||||
export const Projects: React.FC = () => {
|
||||
export const Projects: React.FC = () => {
|
||||
const { t, lang } = useTranslation();
|
||||
const { user } = useAppContext();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
@@ -31,62 +33,84 @@ export const Projects: React.FC = () => {
|
||||
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
||||
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
||||
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<any | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [isArchived, setIsArchived] = useState(false);
|
||||
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
|
||||
const [deleteInput, setDeleteInput] = useState('');
|
||||
|
||||
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 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)' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, ordering, isArchived, selectedClientIds]);
|
||||
|
||||
const fetchProjectList = async () => {
|
||||
if (!activeWorkspace) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const data = await getProjects(activeWorkspace.id, {
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
clients: selectedClientIds,
|
||||
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();
|
||||
setProjects(items);
|
||||
setTotalItems(count)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects", error);
|
||||
toast.error(t.projects?.fetchError || "Failed to fetch projects.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspace?.id) return;
|
||||
|
||||
getClients(activeWorkspace.id, "", "name", 300, 0)
|
||||
.then((data: any) => {
|
||||
const items = data?.results || (Array.isArray(data) ? data : []);
|
||||
setClients(items.map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
|
||||
setClients([]);
|
||||
});
|
||||
}, [activeWorkspace?.id, t.projects?.clientFetchError]);
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
void fetchProjectList();
|
||||
}, 300);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering, selectedClientIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreated = () => void fetchProjectList();
|
||||
const handleUpdated = () => void fetchProjectList();
|
||||
|
||||
window.addEventListener("project_created", handleCreated);
|
||||
window.addEventListener("project_updated", handleUpdated);
|
||||
@@ -97,13 +121,9 @@ export const Projects: React.FC = () => {
|
||||
};
|
||||
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
|
||||
|
||||
const handleDeleteClick = (project: Project) => {
|
||||
setProjectToDelete(project);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteModal.project) return;
|
||||
try {
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteModal.project) return;
|
||||
try {
|
||||
const deletedId = deleteModal.project.id;
|
||||
await deleteProject(deletedId);
|
||||
|
||||
@@ -121,7 +141,7 @@ export const Projects: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | undefined) => {
|
||||
const formatDate = (dateStr: string | undefined) => {
|
||||
if (!dateStr) return "-"
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
@@ -132,134 +152,238 @@ export const Projects: React.FC = () => {
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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">
|
||||
{canArchiveProject && (
|
||||
<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>
|
||||
)}
|
||||
{canCreateProject && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
size="icon"
|
||||
className="shadow-sm"
|
||||
title={t.projects?.createNew || 'Create New'}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</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">
|
||||
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
|
||||
<div className="p-0">
|
||||
{projects.length === 0 ? (
|
||||
<div className="py-16 flex flex-col items-center justify-center">
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{projects.map((project) => {
|
||||
const canDeleteProject = canDeleteWorkspaceResource({
|
||||
workspaceRole,
|
||||
currentUserId: user?.id,
|
||||
createdById: project.created_by?.id,
|
||||
});
|
||||
return (
|
||||
<li
|
||||
key={project.id}
|
||||
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
||||
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
|
||||
</p>
|
||||
{project.description && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(canEditProject || canDeleteProject) && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{canEditProject && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{canDeleteProject && (
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{canCreateProject && isCreateModalOpen && (
|
||||
}
|
||||
|
||||
const sortedClients = useMemo(() => {
|
||||
if (!selectedClientIds.length) return clients;
|
||||
const selected = clients.filter((client) => selectedClientIds.includes(client.id));
|
||||
const unselected = clients.filter((client) => !selectedClientIds.includes(client.id));
|
||||
return [...selected, ...unselected];
|
||||
}, [clients, selectedClientIds]);
|
||||
|
||||
const toggleClientFilter = (clientId: string) => {
|
||||
setCurrentPage(1);
|
||||
setSelectedClientIds((current) =>
|
||||
current.includes(clientId)
|
||||
? current.filter((id) => id !== clientId)
|
||||
: [...current, clientId],
|
||||
);
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
{t.projects?.selectWorkspace || t.clients.selectWorkspace}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-3 sm:w-auto">
|
||||
{canArchiveProject && (
|
||||
<Button
|
||||
variant={isArchived ? "default" : "secondary"}
|
||||
onClick={() => setIsArchived(!isArchived)}
|
||||
className="flex-1 gap-2 shadow-sm sm:flex-none"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||
</Button>
|
||||
)}
|
||||
{canCreateProject && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
size="icon"
|
||||
className="shrink-0 shadow-sm"
|
||||
title={t.projects?.createNew || 'Create New'}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<FilterBar
|
||||
searchQuery={search}
|
||||
setSearchQuery={setSearch}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.projects?.filterClients || "Filter by client"}
|
||||
</div>
|
||||
{selectedClientIds.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedClientIds([]);
|
||||
}}
|
||||
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||
>
|
||||
{t.projects?.clearClientFilters || "Clear filters"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-x-auto pb-2">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
{sortedClients.map((client) => {
|
||||
const isSelected = selectedClientIds.includes(client.id);
|
||||
return (
|
||||
<button
|
||||
key={client.id}
|
||||
type="button"
|
||||
onClick={() => toggleClientFilter(client.id)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition ${
|
||||
isSelected
|
||||
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<span className="whitespace-nowrap">{client.name}</span>
|
||||
{isSelected ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleClientFilter(client.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleClientFilter(client.id);
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<ListPageSkeleton variant="standard-grid" />
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<p className="font-medium text-slate-500 dark:text-slate-400">{t.projects?.emptyState || 'No projects found'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{projects.map((project) => {
|
||||
const canDeleteProject = canDeleteWorkspaceResource({
|
||||
workspaceRole,
|
||||
currentUserId: user?.id,
|
||||
createdById: project.created_by?.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card key={project.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="h-10 w-10 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
|
||||
style={{ backgroundColor: project.color || "#3B82F6" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
|
||||
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(canEditProject || canDeleteProject) && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canEditProject && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingProject(project)}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
title={t.actions?.edit || "Edit"}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDeleteProject && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title={t.actions?.delete || "Delete"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="min-h-[3.75rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300">
|
||||
{project.description || t.workspace?.noDescription || "No description"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
<span>{formatDate(project.created_at)}</span>
|
||||
{project.is_archived ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold tracking-[0.1em] text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
|
||||
{t.projects?.archived || "Archived Projects"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{canCreateProject && isCreateModalOpen && (
|
||||
<ProjectCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
|
||||
@@ -381,6 +381,7 @@ export default function Reports() {
|
||||
billableHours: t.reports?.billableHours || "Billable hours",
|
||||
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
|
||||
totalHours: t.reports?.totalHours || "Total hours",
|
||||
hourlyRate: t.reports?.hourlyRate || "Hourly rate",
|
||||
totalIncome: t.reports?.totalIncome || "Total income",
|
||||
details: t.reports?.details || "Details",
|
||||
total: t.reports?.total || "Total",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { TAGS_CREATE, TAGS_EDIT, canDeleteWorkspaceResource, canWorkspace } from "../lib/permissions";
|
||||
import FilterBar from "../components/FilterBar";
|
||||
import { ListPageSkeleton } from "../components/ListPageSkeleton";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -140,39 +141,50 @@ export default function Tags() {
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return <div className="p-6 text-center text-slate-500">{t.tags?.selectWorkspace || t.clients.selectWorkspace}</div>;
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
{t.tags?.selectWorkspace || t.clients.selectWorkspace}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
||||
<div className="flex justify-between 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 className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTag && (
|
||||
<Button onClick={openCreateModal} size="icon" className="shrink-0 shadow-sm" title={t.tags?.create || "Create Tag"}>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canCreateTag && (
|
||||
<Button onClick={openCreateModal} size="icon" className="shadow-sm shrink-0" title={t.tags?.create || "Create Tag"}>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
|
||||
/>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<FilterBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{isLoading ? (
|
||||
<ListPageSkeleton variant="dense-grid" />
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{tags.map((tag) => {
|
||||
const canDeleteTag = canDeleteWorkspaceResource({
|
||||
workspaceRole,
|
||||
@@ -196,7 +208,13 @@ export default function Tags() {
|
||||
{(canEditTag || canDeleteTag) && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canEditTag && (
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditModal(tag)}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
title={t.actions?.edit || "Edit"}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -205,9 +223,10 @@ export default function Tags() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, tag })}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title={t.actions?.delete || "Delete"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,9 +238,9 @@ export default function Tags() {
|
||||
})}
|
||||
|
||||
{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">
|
||||
<div className="col-span-full flex flex-1 flex-col items-center justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 text-slate-500 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
|
||||
<TagIcon className="w-10 h-10 mb-3" />
|
||||
<p>{t.tags?.emptyState || "No tags found"}</p>
|
||||
<p className="font-medium">{t.tags?.emptyState || "No tags found"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -235,6 +254,7 @@ export default function Tags() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -16,15 +16,15 @@ import {
|
||||
updateTimeEntry,
|
||||
} from "../api/timeEntries";
|
||||
import { getTags, type Tag } from "../api/tags";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/Select";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { SearchableSelect } from "../components/ui/SearchableSelect";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
|
||||
type EntryModalMode = "manual" | "edit" | null;
|
||||
|
||||
@@ -858,30 +858,35 @@ function TagMultiSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInlineSelect({
|
||||
projects,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
portalOwnerId,
|
||||
className = "",
|
||||
dropdownClassName = "",
|
||||
disabled = false,
|
||||
}: {
|
||||
projects: Project[];
|
||||
value: string;
|
||||
onChange: (projectId: string) => void;
|
||||
placeholder: string;
|
||||
portalOwnerId?: string;
|
||||
className?: string;
|
||||
dropdownClassName?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
function ProjectInlineSelect({
|
||||
projects,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
portalOwnerId,
|
||||
className = "",
|
||||
dropdownClassName = "",
|
||||
disabled = false,
|
||||
}: {
|
||||
projects: Project[];
|
||||
value: string;
|
||||
onChange: (projectId: string) => void;
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyLabel: string;
|
||||
portalOwnerId?: string;
|
||||
className?: string;
|
||||
dropdownClassName?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -896,9 +901,9 @@ function ProjectInlineSelect({
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !buttonRef.current) return;
|
||||
@@ -928,13 +933,27 @@ function ProjectInlineSelect({
|
||||
return () => {
|
||||
window.removeEventListener("resize", closeOnViewportChange);
|
||||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedProject = projects.find((project) => project.id === value);
|
||||
const label = selectedProject?.name || placeholder;
|
||||
|
||||
return (
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedProject = projects.find((project) => project.id === value);
|
||||
const label = selectedProject?.name || placeholder;
|
||||
const filteredProjects = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return projects;
|
||||
return projects.filter((project) => {
|
||||
const clientName = project.client?.name || "";
|
||||
return `${project.name} ${clientName}`.toLowerCase().includes(needle);
|
||||
});
|
||||
}, [projects, query]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
@@ -958,11 +977,23 @@ function ProjectInlineSelect({
|
||||
style={dropdownStyle}
|
||||
data-entry-editor-owner={portalOwnerId}
|
||||
className={`rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-800 ${dropdownClassName}`}
|
||||
>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setIsOpen(false);
|
||||
@@ -976,10 +1007,10 @@ function ProjectInlineSelect({
|
||||
{placeholder}
|
||||
</button>
|
||||
|
||||
{projects.map((project) => {
|
||||
const selected = project.id === value;
|
||||
const unavailable = Boolean(project.is_deleted) && !selected;
|
||||
return (
|
||||
{filteredProjects.map((project) => {
|
||||
const selected = project.id === value;
|
||||
const unavailable = Boolean(project.is_deleted) && !selected;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
@@ -989,22 +1020,25 @@ function ProjectInlineSelect({
|
||||
onChange(project.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
||||
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
||||
selected
|
||||
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||||
: unavailable
|
||||
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
||||
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
||||
}`}
|
||||
title={project.name}
|
||||
>
|
||||
<span className={`truncate ${project.is_deleted ? "italic" : ""}`}>{project.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
title={project.name}
|
||||
>
|
||||
<span className={`truncate ${project.is_deleted ? "italic" : ""}`}>{project.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredProjects.length === 0 && (
|
||||
<div className="px-3 py-3 text-xs text-slate-500 dark:text-slate-400">{emptyLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1229,14 +1263,16 @@ function EntryEditorFields({
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<ProjectInlineSelect
|
||||
projects={projects}
|
||||
value={state.projectId}
|
||||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||
portalOwnerId={portalOwnerId}
|
||||
className="min-w-0 max-w-fit flex-1"
|
||||
/>
|
||||
<ProjectInlineSelect
|
||||
projects={projects}
|
||||
value={state.projectId}
|
||||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
|
||||
portalOwnerId={portalOwnerId}
|
||||
className="min-w-0 max-w-fit flex-1"
|
||||
/>
|
||||
|
||||
{selectedProject?.client?.name && (
|
||||
<span className="min-w-0 max-w-[120px] 2xl:max-w-fit shrink truncate text-sm text-slate-400 dark:text-slate-500" title={selectedProject.client.name}>
|
||||
@@ -1307,21 +1343,28 @@ function EntryEditorFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}>
|
||||
{t.timesheet?.projectLabel || "Project"}
|
||||
</label>
|
||||
<Select
|
||||
value={state.projectId}
|
||||
onChange={(value) => onChange({ projectId: String(value) })}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.noProject || "No project" },
|
||||
...projects.map((project) => ({ value: project.id, label: project.name })),
|
||||
]}
|
||||
className="w-full"
|
||||
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}>
|
||||
{t.timesheet?.projectLabel || "Project"}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={state.projectId}
|
||||
onChange={(value) => onChange({ projectId: String(value) })}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.noProject || "No project" },
|
||||
...projects.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
searchText: project.client?.name || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={t.timesheet?.noProject || "No project"}
|
||||
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
|
||||
className="w-full"
|
||||
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className={compact ? "" : "space-y-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"}>
|
||||
@@ -1540,7 +1583,7 @@ function RecordedEntryCard({
|
||||
<div
|
||||
ref={rowRef}
|
||||
onBlurCapture={handleBlurCapture}
|
||||
className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-950"
|
||||
className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-700 dark:bg-slate-900/95"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<EntryEditorFields
|
||||
@@ -1555,7 +1598,7 @@ function RecordedEntryCard({
|
||||
portalOwnerId={editorOwnerId}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
<div className="flex items-center justify-between gap-3 border-t border-slate-200 pt-3 dark:border-slate-700">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{formatDateTime(entry.start_time, lang)}
|
||||
</div>
|
||||
@@ -1591,7 +1634,7 @@ function RecordedEntryCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rowRef} onBlurCapture={handleBlurCapture} className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div ref={rowRef} onBlurCapture={handleBlurCapture} className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-700 dark:bg-slate-900/95">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<EntryEditorFields
|
||||
state={draft}
|
||||
@@ -1606,20 +1649,20 @@ function RecordedEntryCard({
|
||||
portalOwnerId={editorOwnerId}
|
||||
/>
|
||||
|
||||
<div className="flex h-12 shrink-0 items-center border-s border-slate-200 px-5 text-sm font-semibold text-slate-700 dark:border-slate-800 dark:text-slate-200">
|
||||
<div className="flex h-12 shrink-0 items-center border-s border-slate-200 px-5 text-sm font-semibold text-slate-700 dark:border-slate-700 dark:text-slate-200">
|
||||
{formatDuration(entry)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRestart(entry)}
|
||||
className="inline-flex h-12 w-10 shrink-0 items-center justify-center border-s border-slate-200 bg-transparent text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:border-slate-800 dark:text-slate-500 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
className="inline-flex h-12 w-10 shrink-0 items-center justify-center border-s border-slate-200 bg-transparent text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:border-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
title="Start from this entry"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="border-s border-slate-200 dark:border-slate-800">
|
||||
<div className="border-s border-slate-200 dark:border-slate-700">
|
||||
<DeleteEntryButton onDelete={() => onDelete(entry)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1633,7 +1676,7 @@ function RecordedEntryCard({
|
||||
);
|
||||
}
|
||||
|
||||
function MobileRecordedEntryCard({
|
||||
function MobileRecordedEntryCard({
|
||||
entry,
|
||||
t,
|
||||
projects,
|
||||
@@ -1748,7 +1791,7 @@ function MobileRecordedEntryCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative overflow-hidden border-b border-slate-200 bg-slate-100/70 dark:border-slate-800 dark:bg-slate-900/70 xl:hidden">
|
||||
<div ref={wrapperRef} className="relative overflow-hidden border-b border-slate-200 bg-slate-100/70 dark:border-slate-700 dark:bg-slate-800/70 xl:hidden">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-24 items-center justify-start bg-emerald-500/12 ps-4 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
|
||||
<Play className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -1757,7 +1800,7 @@ function MobileRecordedEntryCard({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-950"
|
||||
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95"
|
||||
style={{ transform: `translateX(${swipeOffset}px)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
@@ -1871,10 +1914,101 @@ function MobileRecordedEntryCard({
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Timesheet() {
|
||||
);
|
||||
}
|
||||
|
||||
function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 animate-pulse">
|
||||
<div className="hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900/95 xl:block">
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<div className="h-12 flex-1 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-12 w-52 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-12 w-48 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-12 w-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-12 w-28 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-12 w-12 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/95 xl:hidden">
|
||||
<div className="space-y-3">
|
||||
<div className="h-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<div className="h-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-10 w-28 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="h-9 w-28 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-9 w-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-10 w-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-700 dark:bg-slate-900/95">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-9 flex-1 rounded-md bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-9 w-9 rounded-md bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-9 w-9 rounded-md bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="h-6 w-24 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-6 w-28 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-6 w-20 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center gap-3 text-slate-500 dark:text-slate-400">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-sky-500" />
|
||||
<span className="text-sm font-medium">{loadingLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[0, 1].map((weekIndex) => (
|
||||
<div key={weekIndex} className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="h-4 w-40 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="h-4 w-28 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
{[0, 1].map((dayIndex) => (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className="overflow-hidden border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900/95"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100/80 px-4 py-2 dark:border-slate-700 dark:bg-slate-800/85">
|
||||
<div className="h-3 w-24 rounded-full bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-3 w-20 rounded-full bg-slate-200 dark:bg-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-px bg-slate-200/80 dark:bg-slate-700/70">
|
||||
{[0, 1].map((entryIndex) => (
|
||||
<div key={entryIndex} className="bg-white px-4 py-4 dark:bg-slate-900/95">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 flex-1 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="hidden h-10 w-28 rounded-lg bg-slate-200 dark:bg-slate-800 md:block" />
|
||||
<div className="h-10 w-10 rounded-lg bg-slate-200 dark:bg-slate-800" />
|
||||
<div className="hidden h-10 w-20 rounded-lg bg-slate-200 dark:bg-slate-800 lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Timesheet() {
|
||||
const { t, lang } = useTranslation();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const isRtl = lang === "fa";
|
||||
@@ -1893,23 +2027,29 @@ export default function Timesheet() {
|
||||
hideFiltersLabel?: string;
|
||||
applyFiltersLabel?: string;
|
||||
clientFilterPrefix?: string;
|
||||
projectFilterPrefix?: string;
|
||||
tagFilterPrefix?: string;
|
||||
fromFilterPrefix?: string;
|
||||
toFilterPrefix?: string;
|
||||
restartConfirmMessage?: string;
|
||||
deletedProjectLabel?: string;
|
||||
deletedTagLabel?: string;
|
||||
}) || {};
|
||||
projectFilterPrefix?: string;
|
||||
tagFilterPrefix?: string;
|
||||
fromFilterPrefix?: string;
|
||||
toFilterPrefix?: string;
|
||||
restartConfirmMessage?: string;
|
||||
discardConfirmMessage?: string;
|
||||
deletedProjectLabel?: string;
|
||||
deletedTagLabel?: string;
|
||||
searchTagsLabel?: string;
|
||||
noTagsFoundLabel?: string;
|
||||
searchProjectsLabel?: string;
|
||||
noProjectsFoundLabel?: string;
|
||||
}) || {};
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [groupedHistory, setGroupedHistory] = useState<TimeEntryGroupWeek[]>([]);
|
||||
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
|
||||
const [groupedHistory, setGroupedHistory] = useState<TimeEntryGroupWeek[]>([]);
|
||||
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
|
||||
const [hasMoreHistory, setHasMoreHistory] = useState(false);
|
||||
const [nextOffset, setNextOffset] = useState<number | null>(0);
|
||||
const [limit] = useState(20);
|
||||
@@ -1977,35 +2117,44 @@ export default function Timesheet() {
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [projectsData, tagsData] = await Promise.all([
|
||||
getProjects(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||||
getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||||
]);
|
||||
|
||||
setProjects(projectsData.results || []);
|
||||
setTags(tagsData.results || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.timesheet?.optionsError || "Failed to load projects and tags");
|
||||
}
|
||||
const [projectsData, tagsData] = await Promise.all([
|
||||
getProjects(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||||
getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
|
||||
]);
|
||||
|
||||
setProjects((projectsData.results || []).filter((project: Project) => !project.is_archived));
|
||||
setTags(tagsData.results || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.timesheet?.optionsError || "Failed to load projects and tags");
|
||||
}
|
||||
};
|
||||
|
||||
void loadOptions();
|
||||
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery("");
|
||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
||||
setGroupedHistory([]);
|
||||
setNextOffset(0);
|
||||
setHasMoreHistory(false);
|
||||
}, [activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setGroupedHistory([]);
|
||||
setNextOffset(0);
|
||||
setHasMoreHistory(false);
|
||||
}, [filters, searchQuery]);
|
||||
useEffect(() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedSearchQuery("");
|
||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
||||
setGroupedHistory([]);
|
||||
setNextOffset(0);
|
||||
setHasMoreHistory(false);
|
||||
}, [activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 350);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setGroupedHistory([]);
|
||||
setNextOffset(0);
|
||||
setHasMoreHistory(false);
|
||||
}, [debouncedSearchQuery, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filters.clientId || !filters.projectId) return;
|
||||
@@ -2028,11 +2177,11 @@ export default function Timesheet() {
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const params: TimeEntryListParams = {
|
||||
limit,
|
||||
offset,
|
||||
search: searchQuery,
|
||||
status: "ended",
|
||||
const params: TimeEntryListParams = {
|
||||
limit,
|
||||
offset,
|
||||
search: debouncedSearchQuery,
|
||||
status: "ended",
|
||||
project: filters.projectId || undefined,
|
||||
client: filters.clientId || undefined,
|
||||
tags: filters.tagIds,
|
||||
@@ -2053,7 +2202,7 @@ export default function Timesheet() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeWorkspace?.id, filters, limit, searchQuery, t.timesheet?.fetchError]);
|
||||
}, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]);
|
||||
|
||||
const loadRunningEntry = useCallback(async () => {
|
||||
if (!activeWorkspace?.id) {
|
||||
@@ -2074,15 +2223,15 @@ export default function Timesheet() {
|
||||
}
|
||||
}, [activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspace?.id) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadHistory();
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [activeWorkspace?.id, limit, loadHistory, filters, searchQuery]);
|
||||
useEffect(() => {
|
||||
if (!activeWorkspace?.id) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadHistory();
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [activeWorkspace?.id, debouncedSearchQuery, limit, loadHistory, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRunningEntry();
|
||||
@@ -2373,15 +2522,15 @@ export default function Timesheet() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = useCallback((nextSearchQuery: string, nextFilters: TimeEntryFilters) => {
|
||||
setSearchQuery(nextSearchQuery);
|
||||
setFilters(nextFilters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
||||
}, []);
|
||||
const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => {
|
||||
setFilters(nextFilters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedSearchQuery("");
|
||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
||||
@@ -2415,7 +2564,7 @@ export default function Timesheet() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
|
||||
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 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.timesheet?.title || 'Timesheets'}</h1>
|
||||
@@ -2424,9 +2573,9 @@ export default function Timesheet() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={desktopTimerRef}
|
||||
onBlurCapture={handleTimerBlurCapture}
|
||||
className="mb-4 hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 xl:block"
|
||||
ref={desktopTimerRef}
|
||||
onBlurCapture={handleTimerBlurCapture}
|
||||
className="mb-4 hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900/95 xl:block"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 px-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -2439,20 +2588,26 @@ export default function Timesheet() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Select
|
||||
value={timerDraft.projectId}
|
||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
||||
]}
|
||||
className="min-w-[190px] max-w-[220px]"
|
||||
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isStartingTimer}
|
||||
portalOwnerId={timerEditorOwnerId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<SearchableSelect
|
||||
value={timerDraft.projectId}
|
||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||
...runningTimerProjects.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
searchText: project.client?.name || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||
searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||
emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."}
|
||||
className="min-w-[190px] max-w-[220px]"
|
||||
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isStartingTimer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink items-center">
|
||||
<TagMultiSelect
|
||||
@@ -2527,9 +2682,9 @@ export default function Timesheet() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={mobileTimerRef}
|
||||
onBlurCapture={handleTimerBlurCapture}
|
||||
className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950 xl:hidden"
|
||||
ref={mobileTimerRef}
|
||||
onBlurCapture={handleTimerBlurCapture}
|
||||
className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/95 xl:hidden"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
@@ -2540,19 +2695,25 @@ export default function Timesheet() {
|
||||
className="h-10 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<Select
|
||||
value={timerDraft.projectId}
|
||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
||||
]}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isStartingTimer}
|
||||
portalOwnerId={timerEditorOwnerId}
|
||||
/>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<SearchableSelect
|
||||
value={timerDraft.projectId}
|
||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||
...runningTimerProjects.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
searchText: project.client?.name || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||
searchPlaceholder={extendedTimesheet.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||
emptyLabel={extendedTimesheet.noProjectsFoundLabel || "No projects found."}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isStartingTimer}
|
||||
/>
|
||||
|
||||
<div className="flex h-10 min-w-[110px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
|
||||
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
|
||||
@@ -2625,13 +2786,14 @@ export default function Timesheet() {
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<TimesheetFilterBar
|
||||
searchQuery={searchQuery}
|
||||
filters={filters}
|
||||
onApply={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
projects={projects}
|
||||
tags={tags}
|
||||
<TimesheetFilterBar
|
||||
searchQuery={searchQuery}
|
||||
filters={filters}
|
||||
onSearchChange={setSearchQuery}
|
||||
onApply={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
projects={projects}
|
||||
tags={tags}
|
||||
searchPlaceholder={t.timesheet?.searchPlaceholder || "Search time entries..."}
|
||||
labels={{
|
||||
project: t.timesheet?.projectLabel || "Project",
|
||||
@@ -2648,18 +2810,20 @@ export default function Timesheet() {
|
||||
apply: extendedTimesheet.applyFiltersLabel || "Apply",
|
||||
clientPrefix: extendedTimesheet.clientFilterPrefix || "Client",
|
||||
projectPrefix: extendedTimesheet.projectFilterPrefix || "Project",
|
||||
tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag",
|
||||
fromPrefix: extendedTimesheet.fromFilterPrefix || "From",
|
||||
toPrefix: extendedTimesheet.toFilterPrefix || "To",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag",
|
||||
fromPrefix: extendedTimesheet.fromFilterPrefix || "From",
|
||||
toPrefix: extendedTimesheet.toFilterPrefix || "To",
|
||||
searchTags: extendedTimesheet.searchTagsLabel || t.tags?.searchPlaceholder || "Search tags...",
|
||||
noTagsFound: extendedTimesheet.noTagsFoundLabel || "No tags found.",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-12 text-slate-500">{t.loading || "Loading..."}</div>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
className="flex flex-1 flex-col"
|
||||
{isLoading ? (
|
||||
<TimesheetSkeleton loadingLabel={t.loading || "Loading..."} />
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
className="flex flex-1 flex-col"
|
||||
onLoadMore={handleLoadMore}
|
||||
hasMore={hasMoreHistory}
|
||||
isLoading={isLoadingMore}
|
||||
@@ -2677,14 +2841,14 @@ export default function Timesheet() {
|
||||
</div>
|
||||
|
||||
{week.days.map((day) => (
|
||||
<div key={day.key} className="border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100/80 px-4 py-2 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div key={day.key} className="border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900/95">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100/80 px-4 py-2 dark:border-slate-700 dark:bg-slate-800/85">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||
{formatDayLabel(new Date(`${day.date}T00:00:00`), lang)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Total: <span className="font-semibold text-slate-700 dark:text-slate-200">{formatDurationMs(day.total_ms)}</span>
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t.reports?.total || "Total"}: <span className="font-semibold text-slate-700 dark:text-slate-200">{formatDurationMs(day.total_ms)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -2722,11 +2886,11 @@ export default function Timesheet() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{groupedHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center border-2 border-dashed border-slate-200 py-16 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<Clock3 className="mb-3 h-10 w-10" />
|
||||
<p>{t.timesheet?.emptyState || "No time entries found"}</p>
|
||||
</div>
|
||||
{groupedHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center border-2 border-dashed border-slate-200 bg-white/60 py-16 text-slate-500 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-400">
|
||||
<Clock3 className="mb-3 h-10 w-10" />
|
||||
<p>{t.timesheet?.emptyState || "No time entries found"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
@@ -2760,10 +2924,10 @@ export default function Timesheet() {
|
||||
</Modal>
|
||||
|
||||
{deleteModal.entry && (
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={closeDeleteModal}
|
||||
title={extendedTimesheet.deleteTitle || "Delete Time Entry"}
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={closeDeleteModal}
|
||||
title={extendedTimesheet.deleteTitle || t.timesheet?.deleteTitle || "Delete Time Entry"}
|
||||
maxWidth="max-w-md"
|
||||
footer={
|
||||
<>
|
||||
@@ -2777,10 +2941,10 @@ export default function Timesheet() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<p className="font-medium text-slate-900 dark:text-white">
|
||||
{deleteModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||||
</p>
|
||||
@@ -2811,10 +2975,10 @@ export default function Timesheet() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{(extendedTimesheet.restartConfirmMessage || "Start a new running timer from this entry?")}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<p className="font-medium text-slate-900 dark:text-white">
|
||||
{restartModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||||
</p>
|
||||
@@ -2845,10 +3009,10 @@ export default function Timesheet() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<p className="font-medium text-slate-900 dark:text-white">
|
||||
{discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"}
|
||||
</p>
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
canWorkspace,
|
||||
type WorkspaceRole,
|
||||
} from '../lib/permissions';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import { ListPageSkeleton } from '../components/ListPageSkeleton';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Card, CardContent, CardTitle } from '../components/ui/card';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
@@ -115,42 +116,45 @@ export default function Workspaces() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate('/workspaces/create')}
|
||||
size="icon"
|
||||
className="shadow-sm"
|
||||
title={t.workspace?.createNew || 'Create New'}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-12 flex justify-center text-slate-500">
|
||||
<div className="animate-pulse">{t.workspace?.loading || 'Loading...'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
{workspaces.map((workspace) => {
|
||||
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
||||
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate('/workspaces/create')}
|
||||
size="icon"
|
||||
className="shrink-0 shadow-sm"
|
||||
title={t.workspace?.createNew || 'Create New'}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<FilterBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ListPageSkeleton variant="list" />
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
{workspaces.map((workspace) => {
|
||||
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
||||
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
||||
|
||||
return (
|
||||
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||
@@ -211,28 +215,31 @@ export default function Workspaces() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{workspaces.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.workspace?.emptyState || 'No workspaces found'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
})}
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteModal.workspace && (
|
||||
<Modal
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteModal.workspace && (
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => {
|
||||
setDeleteModal({ isOpen: false, workspace: null });
|
||||
|
||||
Reference in New Issue
Block a user