Compare commits

..

11 Commits

33 changed files with 2251 additions and 2423 deletions

View File

@@ -22,6 +22,7 @@ import Tags from "./pages/Tags"
import Reports from "./pages/Reports" import Reports from "./pages/Reports"
import Timesheet from "./pages/Timesheet" import Timesheet from "./pages/Timesheet"
import Logs from "./pages/Logs" import Logs from "./pages/Logs"
import NotificationsPage from "./pages/Notifications"
const MainLayout = () => { const MainLayout = () => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
@@ -66,6 +67,7 @@ const router = createBrowserRouter([
{ path: "/profile", element: <Profile /> }, { path: "/profile", element: <Profile /> },
{ path: "/timesheet", element: <Timesheet /> }, { path: "/timesheet", element: <Timesheet /> },
{ path: "/reports", element: <Reports /> }, { path: "/reports", element: <Reports /> },
{ path: "/notifications", element: <NotificationsPage /> },
{ path: "/logs", element: <Logs /> }, { path: "/logs", element: <Logs /> },
{ path: "/tags", element: <Tags /> }, { path: "/tags", element: <Tags /> },
{ path: "/workspaces", element: <Workspaces /> }, { path: "/workspaces", element: <Workspaces /> },

View File

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

View File

@@ -12,38 +12,17 @@ export interface ProjectClient {
name: string; name: string;
} }
export interface ProjectMemberPayload {
user_id: string;
role: "manager" | "member" | string;
}
export interface ProjectMembership {
id: string;
project: string;
user: string;
user_details: {
id: string;
first_name: string;
last_name: string;
phone_number: string;
avatar?: string;
};
role: "manager" | "member" | string;
is_active: boolean;
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
description: string; description: string;
color: string; color: string;
created_at?: string;
is_archived: boolean; is_archived: boolean;
is_deleted?: boolean; is_deleted?: boolean;
workspace: string; workspace: string;
created_by?: AuditUser | null; created_by?: AuditUser | null;
client: ProjectClient | null; client: ProjectClient | null;
my_role?: string;
members?: ProjectMembership[];
} }
export interface ProjectPayload { export interface ProjectPayload {
@@ -57,7 +36,15 @@ export interface ProjectPayload {
export const getProjects = async ( export const getProjects = async (
workspaceId: string, workspaceId: string,
params: { limit?: number; offset?: number; search?: string; client?: string; is_archived?: boolean, ordering?: string } = {} params: {
limit?: number;
offset?: number;
search?: string;
client?: string;
clients?: string[];
is_archived?: boolean;
ordering?: string;
} = {}
) => { ) => {
const queryParams = new URLSearchParams({ workspace: workspaceId }); const queryParams = new URLSearchParams({ workspace: workspaceId });
@@ -65,13 +52,23 @@ export const getProjects = async (
if (params.offset !== undefined) queryParams.append("offset", params.offset.toString()); if (params.offset !== undefined) queryParams.append("offset", params.offset.toString());
if (params.search) queryParams.append("search", params.search); if (params.search) queryParams.append("search", params.search);
if (params.client) queryParams.append("client", params.client); 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.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString());
if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString()); if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString());
const response = await authFetch(`/api/projects/?${queryParams.toString()}`); const response = await authFetch(`/api/projects/?${queryParams.toString()}`);
if (!response.ok) throw new Error("Failed to fetch projects"); if (!response.ok) throw new Error("Failed to fetch projects");
return response.json(); 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) => { export const getProject = async (id: string) => {
@@ -85,11 +82,7 @@ export const getProject = async (id: string) => {
}; };
export const createProject = async ( export const createProject = async (
data: Partial<ProjectPayload> & { data: Partial<ProjectPayload> & { workspace: string; name: string }
workspace: string;
name: string;
members?: ProjectMemberPayload[];
}
) => { ) => {
const response = await authFetch("/api/projects/", { const response = await authFetch("/api/projects/", {
method: "POST", method: "POST",
@@ -105,7 +98,7 @@ export const createProject = async (
export const updateProject = async ( export const updateProject = async (
id: string, id: string,
data: Partial<ProjectPayload> & { members?: ProjectMemberPayload[] } data: Partial<ProjectPayload>
) => { ) => {
const response = await authFetch(`/api/projects/${id}/`, { const response = await authFetch(`/api/projects/${id}/`, {
method: "PATCH", method: "PATCH",
@@ -144,49 +137,3 @@ export const toggleArchiveProject = async (id: string) => {
} }
return response.json(); return response.json();
}; };
export const getProjectMemberships = async (projectId: string) => {
const response = await authFetch(`/api/memberships/?project=${projectId}`);
if (!response.ok) throw new Error("Failed to fetch project memberships");
return response.json();
};
export const addProjectMembership = async (projectId: string, userId: string, role: string) => {
const response = await authFetch(`/api/memberships/`, {
method: "POST",
body: JSON.stringify({ project_id: projectId, user_id: userId, role }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to add project member");
}
return response.json();
};
export const updateProjectMembership = async (membershipId: string, role: string, isActive: boolean = true) => {
const response = await authFetch(`/api/memberships/${membershipId}/`, {
method: "PATCH",
body: JSON.stringify({ role, is_active: isActive }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update project member");
}
return response.json();
};
export const removeProjectMembership = async (membershipId: string) => {
const response = await authFetch(`/api/memberships/${membershipId}/`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to remove member");
}
if (response.status === 204) return { success: true };
return response.json().catch(() => ({ success: true }));
};

View File

@@ -58,6 +58,7 @@ export interface DailyReportRow {
billable_duration: string; billable_duration: string;
non_billable_duration: string; non_billable_duration: string;
total_duration: string; total_duration: string;
latest_hourly_rate: CurrencyTotal | null;
income_totals: CurrencyTotal[]; income_totals: CurrencyTotal[];
} }

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { createClient } from "../api/clients"; import { createClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -24,12 +25,14 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
setIsLoading(true); setIsLoading(true);
try { try {
await createClient(workspaceId, { name, notes }); await createClient(workspaceId, { name, notes });
toast.success(t.clients.createSuccess);
onSuccess(); onSuccess();
setName(""); setName("");
setNotes(""); setNotes("");
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(t.clients.errors.createFailed, error); console.error(t.clients.errors.createFailed, error);
toast.error(t.clients.errors.createFailed);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { type Client } from "../types/client"; import { type Client } from "../types/client";
import { deleteClient } from "../api/clients"; import { deleteClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
@@ -21,10 +22,12 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
setIsLoading(true); setIsLoading(true);
try { try {
await deleteClient(client.id); await deleteClient(client.id);
toast.success(t.clients.deleteSuccess);
onSuccess(); onSuccess();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(t.clients.errors.deleteFailed, error); console.error(t.clients.errors.deleteFailed, error);
toast.error(t.clients.errors.deleteFailed);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner";
import { type Client } from "../types/client"; import { type Client } from "../types/client";
import { updateClient } from "../api/clients"; import { updateClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
@@ -32,10 +33,12 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
setIsLoading(true); setIsLoading(true);
try { try {
await updateClient(client.id, { name, notes }); await updateClient(client.id, { name, notes });
toast.success(t.clients.updateSuccess);
onSuccess(); onSuccess();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(t.clients.errors.updateFailed, error); console.error(t.clients.errors.updateFailed, error);
toast.error(t.clients.errors.updateFailed);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View 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>
);
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "../hooks/useTranslation"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { SettingsMenu } from "./SettingsMenu" import { SettingsMenu } from "./SettingsMenu"
import { LogOut, User, Moon, Sun, Globe, Command, Menu } from "lucide-react" import { LogOut, User, Moon, Sun, Globe, Command, Menu } from "lucide-react"
import { useTheme } from "./ThemeProvider"
import { logoutUser, getUserProfile } from "../api/users" import { logoutUser, getUserProfile } from "../api/users"
import { WorkspaceSelector } from "./WorkspaceSelector" import { WorkspaceSelector } from "./WorkspaceSelector"
import { toast } from "sonner" import { toast } from "sonner"
@@ -16,18 +17,16 @@ type NavbarProps = {
export function Navbar({ onOpenSidebar }: NavbarProps) { export function Navbar({ onOpenSidebar }: NavbarProps) {
const { t, lang, setLanguage } = useTranslation() const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme()
const navigate = useNavigate() const navigate = useNavigate()
const [showLogoutModal, setShowLogoutModal] = useState(false) const [showLogoutModal, setShowLogoutModal] = useState(false)
const [isDropdownOpen, setIsDropdownOpen] = useState(false) const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [user, setUser] = useState<any>(null) const [user, setUser] = useState<any>(null)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const isFa = lang === "fa" const isFa = lang === "fa"
const isDarkMode =
const [isDarkMode, setIsDarkMode] = useState(() => { theme === "dark" ||
const savedTheme = localStorage.getItem("theme") (theme === "system" && document.documentElement.classList.contains("dark"))
if (savedTheme) return savedTheme === "dark"
return document.documentElement.classList.contains("dark")
})
useEffect(() => { useEffect(() => {
const handleProfileUpdated = ((e: CustomEvent) => { const handleProfileUpdated = ((e: CustomEvent) => {
@@ -40,14 +39,6 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
return () => window.removeEventListener("profile_updated", handleProfileUpdated) return () => window.removeEventListener("profile_updated", handleProfileUpdated)
}, []) }, [])
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
}, [isDarkMode])
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
const token = getAccessToken() const token = getAccessToken()
@@ -92,9 +83,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
} }
const toggleTheme = () => { const toggleTheme = () => {
const newThemeState = !isDarkMode setTheme(isDarkMode ? "light" : "dark")
setIsDarkMode(newThemeState)
localStorage.setItem("theme", newThemeState ? "dark" : "light")
} }
const toggleLanguage = () => { const toggleLanguage = () => {

View File

@@ -1,5 +1,7 @@
import React from 'react'; import React, { useMemo } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { cn } from '../lib/utils';
import { Select } from './ui/Select'; import { Select } from './ui/Select';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -35,50 +37,115 @@ export const Pagination: React.FC<PaginationProps> = ({
const startItem = ((currentPage - 1) * limit) + 1; const startItem = ((currentPage - 1) * limit) + 1;
const endItem = Math.min(currentPage * limit, totalCount); 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 ( 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="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 items-center gap-4"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<Select <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-start">
value={String(limit)} <div className="flex items-center gap-3">
onChange={(val) => { <Select
onLimitChange(Number(val)); value={String(limit)}
onPageChange(1); onChange={(val) => {
}} onLimitChange(Number(val));
options={pageSizeOptions.map((option) => ({ onPageChange(1);
value: String(option), }}
label: String(toPersianNum(option)), options={pageSizeOptions.map((option) => ({
}))} value: String(option),
className="w-20 shrink-0" label: String(toPersianNum(option)),
buttonClassName="" }))}
/> className="w-24 shrink-0"
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline-block"> 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"
{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> <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">
</div> {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 items-center gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
<Button <div className="hidden items-center gap-2 md:flex">
variant="outline" {pageItems.map((pageItem, index) =>
size="sm" typeof pageItem === 'number' ? (
onClick={() => onPageChange(currentPage - 1)} <button
disabled={currentPage === 1} key={pageItem}
> type="button"
{t.pagination?.previous || 'Previous'} onClick={() => onPageChange(pageItem)}
</Button> className={cn(
<span className="text-sm text-slate-600 dark:text-slate-400 font-medium hidden sm:inline-block"> 'inline-flex h-10 min-w-10 items-center justify-center rounded-2xl border px-3 text-sm font-semibold transition-colors',
{t.pagination?.page || 'Page'} {toPersianNum(currentPage)} {t.pagination?.of || 'of'} {toPersianNum(totalPages)} pageItem === currentPage
</span> ? 'border-sky-500 bg-sky-500 text-white shadow-sm'
<Button : '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',
variant="outline" )}
size="sm" >
onClick={() => onPageChange(currentPage + 1)} {toPersianNum(pageItem)}
disabled={currentPage >= totalPages} </button>
> ) : (
{t.pagination?.next || 'Next'} <span
</Button> key={`${pageItem}-${index}`}
</div> 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> </div>
); );
}; };

View File

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

View File

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

View File

@@ -1,105 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react" import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../hooks/useTranslation" import { Bell, CheckCheck, Loader2 } from "lucide-react";
import { cn } from "../../lib/utils"
import { useNotifications } from "../../context/NotificationsContext"
import type { NotificationItem } from "../../api/notifications"
import { Button } from "../ui/button"
const formatNotificationTimestamp = (value: string, locale: string) => { import { NotificationList } from "./NotificationList";
const date = new Date(value) import { useTranslation } from "../../hooks/useTranslation";
if (Number.isNaN(date.getTime())) { import { useNotifications } from "../../context/NotificationsContext";
return value import { Button } from "../ui/button";
}
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>
)
}
export function NotificationBell() { export function NotificationBell() {
const { t, lang } = useTranslation() const { t } = useTranslation();
const navigate = useNavigate();
const { const {
notifications, notifications,
unreadCount, unreadCount,
totalCount,
hasMore,
isLoading, isLoading,
isLoadingMore,
loadMore,
markAllAsSeen, markAllAsSeen,
deleteOne, deleteOne,
handleNotificationClick, handleNotificationClick,
} = useNotifications() } = useNotifications();
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null);
const unreadNotifications = useMemo(
() => notifications.filter((notification) => !notification.is_seen),
[notifications],
);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -107,12 +32,12 @@ export function NotificationBell() {
containerRef.current && containerRef.current &&
!containerRef.current.contains(event.target as Node) !containerRef.current.contains(event.target as Node)
) { ) {
setIsOpen(false) setIsOpen(false);
} }
} };
document.addEventListener("mousedown", handleClickOutside) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("mousedown", handleClickOutside);
}, []) }, []);
return ( return (
<div className="relative" ref={containerRef}> <div className="relative" ref={containerRef}>
@@ -136,8 +61,8 @@ export function NotificationBell() {
{t.notifications?.title || "Notifications"} {t.notifications?.title || "Notifications"}
</p> </p>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
{t.notifications?.summary?.(totalCount, unreadCount) || {t.notifications?.summary?.(unreadNotifications.length, unreadCount) ||
`${totalCount} total, ${unreadCount} unread`} `${unreadNotifications.length} total, ${unreadCount} unread`}
</p> </p>
</div> </div>
<Button <Button
@@ -159,45 +84,31 @@ export function NotificationBell() {
<Loader2 className="me-2 h-4 w-4 animate-spin" /> <Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loading || "Loading notifications..."} {t.notifications?.loading || "Loading notifications..."}
</div> </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) => ( <NotificationList
<NotificationRow notifications={unreadNotifications}
key={notification.id} emptyLabel={t.notifications?.emptyUnread || "No unread notifications."}
notification={notification} onClick={(item) => void handleNotificationClick(item)}
locale={lang} onDelete={(item) => void deleteOne(item)}
onClick={(item) => void handleNotificationClick(item)} />
onDelete={(item) => void deleteOne(item)}
/>
))
)} )}
</div> </div>
{hasMore ? ( <div className="border-t border-slate-100 p-3 dark:border-slate-800">
<div className="border-t border-slate-100 p-3 dark:border-slate-800"> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" className="w-full"
className="w-full" onClick={() => {
onClick={() => void loadMore()} setIsOpen(false);
disabled={isLoadingMore} navigate("/notifications");
> }}
{isLoadingMore ? ( >
<> {t.notifications?.viewAll || "View all notifications"}
<Loader2 className="me-2 h-4 w-4 animate-spin" /> </Button>
{t.notifications?.loadingMore || "Loading more..."} </div>
</>
) : (
t.notifications?.loadMore || "Load more"
)}
</Button>
</div>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
) );
} }

View 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>
);
}

View File

@@ -53,11 +53,13 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
client: formData.client || null, client: formData.client || null,
}); });
toast.success(t.projects?.createSuccess || "Project created successfully.");
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
onClose(); onClose();
setFormData({ name: "", description: "", color: "#3B82F6", client: "" }); setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.projects?.createError || "Failed to create project.");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -66,10 +66,12 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
client: formData.client || null, client: formData.client || null,
}); });
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated })); window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.projects?.updateError || "Failed to update project.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -80,10 +82,16 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
setLoading(true); setLoading(true);
try { try {
const updated = await toggleArchiveProject(project.id); 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 })); window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.projects?.updateError || "Failed to update project.");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -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(" | "); 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 formatDisplayDate = (value: string, lang: "en" | "fa") => {
const parsed = new Date(`${value}T00:00:00`); const parsed = new Date(`${value}T00:00:00`);
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", { 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="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 className="font-medium">{localizeDigits(day.non_billable_duration, lang)}</div>
</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="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="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> <div className="font-medium">{formatMoneyTotals(day.income_totals, lang)}</div>
@@ -249,9 +261,10 @@ export function ReportsTablePanel({
<thead> <thead>
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400"> <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-[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-[14%] 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-[14%] 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-[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> <th className="w-[10%] px-3 py-3 text-start font-medium">{labels.details}</th>
</tr> </tr>
</thead> </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 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.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">{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 text-slate-700 dark:text-slate-300">{formatMoneyTotals(day.income_totals, lang)}</td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<button <button
@@ -300,6 +314,7 @@ export function ReportsTablePanel({
<td className="px-3 py-3">{labels.total}</td> <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.billable_duration, lang)}</td>
<td className="px-3 py-3">{localizeDigits(data.summary.non_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">{formatMoneyTotals(data.summary.income_totals, lang)}</td>
<td className="px-3 py-3" /> <td className="px-3 py-3" />
</tr> </tr>

View File

@@ -18,7 +18,8 @@ export interface TimeEntryFilters {
interface TimesheetFilterBarProps { interface TimesheetFilterBarProps {
searchQuery: string; searchQuery: string;
filters: TimeEntryFilters; filters: TimeEntryFilters;
onApply: (searchQuery: string, filters: TimeEntryFilters) => void; onSearchChange: (value: string) => void;
onApply: (filters: TimeEntryFilters) => void;
onClearFilters: () => void; onClearFilters: () => void;
projects: Project[]; projects: Project[];
tags: Tag[]; tags: Tag[];
@@ -41,6 +42,8 @@ interface TimesheetFilterBarProps {
tagPrefix?: string; tagPrefix?: string;
fromPrefix?: string; fromPrefix?: string;
toPrefix?: string; toPrefix?: string;
searchTags?: string;
noTagsFound?: string;
}; };
} }
@@ -49,11 +52,15 @@ function FilterTagMultiSelect({
selectedTagIds, selectedTagIds,
onChange, onChange,
title, title,
searchPlaceholder,
emptyLabel,
}: { }: {
tags: Tag[]; tags: Tag[];
selectedTagIds: string[]; selectedTagIds: string[];
onChange: (tagIds: string[]) => void; onChange: (tagIds: string[]) => void;
title: string; title: string;
searchPlaceholder: string;
emptyLabel: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -145,7 +152,7 @@ function FilterTagMultiSelect({
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} 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" 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>
@@ -181,7 +188,7 @@ function FilterTagMultiSelect({
})} })}
{filteredTags.length === 0 && ( {filteredTags.length === 0 && (
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400"> <div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
No tags found. {emptyLabel}
</div> </div>
)} )}
</div> </div>
@@ -215,6 +222,7 @@ function MiniFilterBlock({
export default function TimesheetFilterBar({ export default function TimesheetFilterBar({
searchQuery, searchQuery,
filters, filters,
onSearchChange,
onApply, onApply,
onClearFilters, onClearFilters,
projects, projects,
@@ -223,13 +231,8 @@ export default function TimesheetFilterBar({
labels, labels,
}: TimesheetFilterBarProps) { }: TimesheetFilterBarProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [draftSearchQuery, setDraftSearchQuery] = useState(searchQuery);
const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters); const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters);
useEffect(() => {
setDraftSearchQuery(searchQuery);
}, [searchQuery]);
useEffect(() => { useEffect(() => {
setDraftFilters(filters); setDraftFilters(filters);
}, [filters]); }, [filters]);
@@ -268,15 +271,15 @@ export default function TimesheetFilterBar({
); );
return ( 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 flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1"> <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" /> <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 <input
type="text" type="text"
value={draftSearchQuery} value={searchQuery}
onChange={(event) => setDraftSearchQuery(event.target.value)} onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder} 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" 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 <button
type="button" type="button"
onClick={() => { onClick={() => {
setDraftSearchQuery(""); onSearchChange("");
setDraftFilters({ setDraftFilters({
projectId: "", projectId: "",
clientId: "", clientId: "",
@@ -329,7 +332,7 @@ export default function TimesheetFilterBar({
</div> </div>
{isExpanded && ( {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)]"> <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"}> <MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From date"}>
<JalaliDatePicker <JalaliDatePicker
@@ -389,6 +392,8 @@ export default function TimesheetFilterBar({
selectedTagIds={draftFilters.tagIds} selectedTagIds={draftFilters.tagIds}
onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))} onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))}
title={labels?.allTags || "All tags"} title={labels?.allTags || "All tags"}
searchPlaceholder={labels?.searchTags || "Search tags..."}
emptyLabel={labels?.noTagsFound || "No tags found."}
/> />
</MiniFilterBlock> </MiniFilterBlock>
</div> </div>
@@ -396,7 +401,7 @@ export default function TimesheetFilterBar({
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<button <button
type="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" 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"} {labels?.apply || "Apply"}

View File

@@ -16,6 +16,7 @@ interface SearchableSelectProps {
options: SearchableSelectOption[]; options: SearchableSelectOption[];
placeholder?: string; placeholder?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
buttonClassName?: string; buttonClassName?: string;
@@ -27,6 +28,7 @@ export function SearchableSelect({
options, options,
placeholder = "", placeholder = "",
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
emptyLabel = "No results",
disabled = false, disabled = false,
className = "", className = "",
buttonClassName = "", buttonClassName = "",
@@ -136,7 +138,7 @@ export function SearchableSelect({
</button> </button>
))} ))}
{filteredOptions.length === 0 && ( {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>
</div>, </div>,

View File

@@ -22,6 +22,7 @@ import {
type NotificationLevel, type NotificationLevel,
} from "../api/notifications" } from "../api/notifications"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { presentNotification } from "../lib/notificationPresenter"
import { import {
getAccessToken, getAccessToken,
SESSION_CHANGED_EVENT, SESSION_CHANGED_EVENT,
@@ -153,8 +154,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
toastedNotificationIdsRef.current.add(notification.id) toastedNotificationIdsRef.current.add(notification.id)
const notify = getToastMethod(notification.level) const notify = getToastMethod(notification.level)
notify(notification.title || (t.notifications?.newTitle || "New notification"), { const presented = presentNotification(notification, t)
description: notification.message || undefined, notify(presented.title || (t.notifications?.newTitle || "New notification"), {
description: presented.message || undefined,
action: notification.action_url action: notification.action_url
? { ? {
label: t.notifications?.openAction || "Open", label: t.notifications?.openAction || "Open",

View 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 || "",
};
}
};

View File

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

View File

@@ -246,6 +246,9 @@ export const en = {
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`, deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
delete: "Delete", delete: "Delete",
saveChanges: "Save Changes", saveChanges: "Save Changes",
createSuccess: "Client created successfully.",
updateSuccess: "Client updated successfully.",
deleteSuccess: "Client deleted successfully.",
errors: { errors: {
createFailed: "Failed to create client", createFailed: "Failed to create client",
fetchFailed: "Failed to fetch clients", fetchFailed: "Failed to fetch clients",
@@ -291,6 +294,7 @@ export const en = {
archived: "Archived Projects", archived: "Archived Projects",
createNew: "Create New", createNew: "Create New",
searchPlaceholder: "Search projects...", searchPlaceholder: "Search projects...",
selectWorkspace: "Please select a workspace first.",
titlePlaceholder: "Enter title", titlePlaceholder: "Enter title",
descriptionPlaceholder: "Enter desription", descriptionPlaceholder: "Enter desription",
titleLabel: "Title", titleLabel: "Title",
@@ -311,7 +315,12 @@ export const en = {
editProject: "Edit Project", editProject: "Edit Project",
restore: "Restore", restore: "Restore",
archive: "Archive", archive: "Archive",
archiveSuccess: "Project archived successfully.",
restoreSuccess: "Project restored successfully.",
fetchError: "Failed to fetch projects.",
clientFetchError: "Failed to load clients.", clientFetchError: "Failed to load clients.",
filterClients: "Filter by client",
clearClientFilters: "Clear filters",
namePlaceholder: "Project name...", namePlaceholder: "Project name...",
teamMembers: "Team Members", teamMembers: "Team Members",
creator: "Creator", creator: "Creator",
@@ -418,6 +427,7 @@ export const en = {
noProject: "No project", noProject: "No project",
startLabel: "Start", startLabel: "Start",
endLabel: "End", endLabel: "End",
timeLabel: "Time",
billable: "Billable", billable: "Billable",
noTagsHint: "Create tags first from the Tags page.", noTagsHint: "Create tags first from the Tags page.",
clearFilters: "Clear filters", clearFilters: "Clear filters",
@@ -434,6 +444,14 @@ export const en = {
tagFilterPrefix: "Tag", tagFilterPrefix: "Tag",
fromFilterPrefix: "From", fromFilterPrefix: "From",
toFilterPrefix: "To", 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", deletedProjectLabel: "Deleted project",
deletedTagLabel: "Deleted tag", deletedTagLabel: "Deleted tag",
}, },
@@ -471,6 +489,7 @@ export const en = {
totalHours: "Total hours", totalHours: "Total hours",
billableHours: "Billable hours", billableHours: "Billable hours",
nonBillableHours: "Non-billable hours", nonBillableHours: "Non-billable hours",
hourlyRate: "Hourly rate",
totalIncome: "Total income", totalIncome: "Total income",
chartTitle: "Activity chart", chartTitle: "Activity chart",
totalSeconds: "Total seconds", totalSeconds: "Total seconds",
@@ -543,7 +562,6 @@ export const en = {
workspace_members: "Workspace members", workspace_members: "Workspace members",
clients: "Clients", clients: "Clients",
projects: "Projects", projects: "Projects",
project_members: "Project members",
tags: "Tags", tags: "Tags",
time_entries: "Time entries", time_entries: "Time entries",
rates: "Rates", rates: "Rates",
@@ -563,12 +581,18 @@ export const en = {
notifications: { notifications: {
title: "Notifications", title: "Notifications",
pageDescription: "Review all notifications and export updates.",
open: "Open notifications", open: "Open notifications",
empty: "No notifications yet.", empty: "No notifications yet.",
emptyUnread: "No unread notifications.",
loading: "Loading notifications...", loading: "Loading notifications...",
loadingMore: "Loading more...", loadingMore: "Loading more...",
loadMore: "Load more", loadMore: "Load more",
markAllRead: "Mark all as read", markAllRead: "Mark all as read",
viewAll: "View all notifications",
totalLabel: "Total notifications",
unreadLabel: "Unread notifications",
deleteLabel: "Delete notification",
markSeenError: "Failed to update notification", markSeenError: "Failed to update notification",
markAllError: "Failed to update notifications", markAllError: "Failed to update notifications",
deleteError: "Failed to delete notification", deleteError: "Failed to delete notification",
@@ -577,5 +601,23 @@ export const en = {
newTitle: "New notification", newTitle: "New notification",
openAction: "Open", openAction: "Open",
summary: (total: number, unread: number) => `${total} total, ${unread} unread`, 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.`,
}, },
} }

View File

@@ -243,6 +243,9 @@ export const fa = {
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`, deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
delete: "حذف", delete: "حذف",
saveChanges: "ذخیره تغییرات", saveChanges: "ذخیره تغییرات",
createSuccess: "مشتری با موفقیت ایجاد شد.",
updateSuccess: "مشتری با موفقیت به‌روزرسانی شد.",
deleteSuccess: "مشتری با موفقیت حذف شد.",
errors: { errors: {
createFailed: "خطا در ایجاد مشتری", createFailed: "خطا در ایجاد مشتری",
fetchFailed: "خطا در دریافت لیست مشتری‌ها", fetchFailed: "خطا در دریافت لیست مشتری‌ها",
@@ -288,6 +291,7 @@ export const fa = {
archived: "پروژه‌های بایگانی شده", archived: "پروژه‌های بایگانی شده",
createNew: "ایجاد پروژه جدید", createNew: "ایجاد پروژه جدید",
searchPlaceholder: "جستجوی پروژه‌ها...", searchPlaceholder: "جستجوی پروژه‌ها...",
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
titlePlaceholder: "عنوان پروژه", titlePlaceholder: "عنوان پروژه",
descriptionPlaceholder: "توضیحات پروژه", descriptionPlaceholder: "توضیحات پروژه",
titleLabel: "عنوان", titleLabel: "عنوان",
@@ -308,7 +312,12 @@ export const fa = {
editProject: "ویرایش پروژه", editProject: "ویرایش پروژه",
restore: "بازیابی", restore: "بازیابی",
archive: "بایگانی", archive: "بایگانی",
archiveSuccess: "پروژه با موفقیت بایگانی شد.",
restoreSuccess: "پروژه با موفقیت بازیابی شد.",
fetchError: "خطا در دریافت پروژه‌ها.",
clientFetchError: "خطا در دریافت لیست مشتری‌ها.", clientFetchError: "خطا در دریافت لیست مشتری‌ها.",
filterClients: "فیلتر بر اساس مشتری",
clearClientFilters: "پاک کردن فیلترها",
memberAlreadyAdded: "این کاربر قبلا اضافه شده است", memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
creator: "سازنده", creator: "سازنده",
addUser: "افزودن کاربر", addUser: "افزودن کاربر",
@@ -415,6 +424,7 @@ export const fa = {
noProject: "بدون پروژه", noProject: "بدون پروژه",
startLabel: "شروع", startLabel: "شروع",
endLabel: "پایان", endLabel: "پایان",
timeLabel: "زمان",
billable: "قابل صورتحساب", billable: "قابل صورتحساب",
noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.", noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.",
clearFilters: "پاک کردن فیلترها", clearFilters: "پاک کردن فیلترها",
@@ -431,6 +441,14 @@ export const fa = {
tagFilterPrefix: "تگ", tagFilterPrefix: "تگ",
fromFilterPrefix: "از", fromFilterPrefix: "از",
toFilterPrefix: "تا", toFilterPrefix: "تا",
deleteTitle: "حذف ورودی زمان",
deleteConfirmMessage: "آیا از حذف این ورودی زمان اطمینان دارید؟",
restartConfirmMessage: "می‌خواهید یک تایمر جدید را از روی این ورودی شروع کنید؟",
discardConfirmMessage: "آیا از دور انداختن این تایمر در حال اجرا اطمینان دارید؟",
searchTagsLabel: "جست‌وجوی تگ‌ها...",
noTagsFoundLabel: "تگی پیدا نشد.",
searchProjectsLabel: "جست‌وجوی پروژه‌ها...",
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده", deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده", deletedTagLabel: "تگ حذف‌شده",
}, },
@@ -467,6 +485,7 @@ export const fa = {
totalHours: "مجموع ساعت", totalHours: "مجموع ساعت",
billableHours: "ساعات کاری", billableHours: "ساعات کاری",
nonBillableHours: "ساعات غیر کاری", nonBillableHours: "ساعات غیر کاری",
hourlyRate: "نرخ ساعتی",
totalIncome: "مجموع درآمد", totalIncome: "مجموع درآمد",
chartTitle: "نمودار فعالیت", chartTitle: "نمودار فعالیت",
totalSeconds: "مجموع ثانیه", totalSeconds: "مجموع ثانیه",
@@ -538,7 +557,6 @@ export const fa = {
workspace_members: "اعضای ورک‌اسپیس", workspace_members: "اعضای ورک‌اسپیس",
clients: "مشتری‌ها", clients: "مشتری‌ها",
projects: "پروژه‌ها", projects: "پروژه‌ها",
project_members: "اعضای پروژه",
tags: "تگ‌ها", tags: "تگ‌ها",
time_entries: "ورودی‌های زمان", time_entries: "ورودی‌های زمان",
rates: "نرخ‌ها", rates: "نرخ‌ها",
@@ -557,12 +575,18 @@ export const fa = {
}, },
notifications: { notifications: {
title: "اعلان‌ها", title: "اعلان‌ها",
pageDescription: "مرور همه اعلان‌ها و وضعیت خروجی‌های گزارش.",
open: "باز کردن اعلان‌ها", open: "باز کردن اعلان‌ها",
empty: "هنوز اعلانی وجود ندارد.", empty: "هنوز اعلانی وجود ندارد.",
emptyUnread: "اعلان خوانده‌نشده‌ای وجود ندارد.",
loading: "در حال بارگذاری اعلان‌ها...", loading: "در حال بارگذاری اعلان‌ها...",
loadingMore: "در حال بارگذاری بیشتر...", loadingMore: "در حال بارگذاری بیشتر...",
loadMore: "بارگذاری بیشتر", loadMore: "بارگذاری بیشتر",
markAllRead: "خواندن همه", markAllRead: "خواندن همه",
viewAll: "نمایش همه اعلان‌ها",
totalLabel: "مجموع اعلان‌ها",
unreadLabel: "اعلان‌های خوانده‌نشده",
deleteLabel: "حذف اعلان",
markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.", markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.",
markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.",
deleteError: "حذف اعلان با خطا مواجه شد.", deleteError: "حذف اعلان با خطا مواجه شد.",
@@ -571,5 +595,23 @@ export const fa = {
newTitle: "اعلان جدید", newTitle: "اعلان جدید",
openAction: "باز کردن", openAction: "باز کردن",
summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`, 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} با خطا مواجه شد.`,
}, },
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react" import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useWorkspace } from "../context/WorkspaceContext" import { useWorkspace } from "../context/WorkspaceContext"
import { useAppContext } from "../context/AppContext" import { useAppContext } from "../context/AppContext"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
@@ -15,8 +16,9 @@ import CreateClientModal from "../components/CreateClientModal"
import EditClientModal from "../components/EditClientModal" import EditClientModal from "../components/EditClientModal"
import DeleteClientModal from "../components/DeleteClientModal" import DeleteClientModal from "../components/DeleteClientModal"
import FilterBar from "../components/FilterBar" import FilterBar from "../components/FilterBar"
import { ListPageSkeleton } from "../components/ListPageSkeleton"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Card } from "../components/ui/card" import { Card, CardContent, CardTitle } from "../components/ui/card"
import { Pagination } from "../components/Pagination" import { Pagination } from "../components/Pagination"
export default function Clients() { export default function Clients() {
@@ -84,6 +86,7 @@ export default function Clients() {
setTotalItems(count) setTotalItems(count)
} catch (error) { } catch (error) {
console.error(t.clients.errors.fetchFailed, error) console.error(t.clients.errors.fetchFailed, error)
toast.error(t.clients.errors.fetchFailed)
setClients([]) setClients([])
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -109,117 +112,139 @@ export default function Clients() {
if (!activeWorkspace) { if (!activeWorkspace) {
return ( return (
<div className="p-6 text-center text-slate-500"> <div className="mx-auto max-w-7xl p-4 md:p-6">
{t.clients.selectWorkspace} <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> </div>
) )
} }
return ( return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900"> <div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex justify-between items-center mb-8 gap-4"> <div className="flex flex-1 flex-col gap-5">
<div> <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">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1> <div className="flex items-start justify-between gap-4">
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1"> <div>
{t.clients.description(activeWorkspace.name)} <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
</p> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
</div> {t.clients.description(activeWorkspace.name)}
</p>
</div>
{canCreateClient && ( {canCreateClient && (
<Button <Button
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
size="icon" size="icon"
className="shadow-sm shrink-0" className="shrink-0 shadow-sm"
title={t.clients.addClient} title={t.clients.addClient}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
</div>
</div> </div>
<FilterBar <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">
searchQuery={searchQuery} <FilterBar
setSearchQuery={setSearchQuery} searchQuery={searchQuery}
ordering={ordering} setSearchQuery={setSearchQuery}
setOrdering={setOrdering} ordering={ordering}
orderingOptions={orderingOptions} setOrdering={setOrdering}
searchPlaceholder={t.clients.searchPlaceholder} orderingOptions={orderingOptions}
/> searchPlaceholder={t.clients.searchPlaceholder}
/>
</div>
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6"> {isLoading ? (
<div className="p-0"> <ListPageSkeleton variant="standard-grid" />
{isLoading ? ( ) : (
<div className="flex justify-center items-center p-12 text-slate-500"> <div className="flex flex-1 flex-col gap-6">
<Loader2 className="w-8 h-8 animate-spin" /> {clients.length === 0 ? (
</div> <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">
) : clients.length === 0 ? ( <Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<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> <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"> <p className="mt-1 text-slate-500 dark:text-slate-400">
{searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd} {searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
</p> </p>
</div> </div>
) : ( ) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{clients.map((client) => { {clients.map((client) => {
const canDeleteClient = canDeleteWorkspaceResource({ const canDeleteClient = canDeleteWorkspaceResource({
workspaceRole, workspaceRole,
currentUserId: user?.id, currentUserId: user?.id,
createdById: client.created_by?.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) && ( return (
<div className="flex items-center gap-1 shrink-0"> <Card key={client.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
{canEditClient && ( <CardContent className="flex h-full flex-col gap-4 p-5">
<Button <div className="flex items-start justify-between gap-3">
variant="ghost" <div className="flex min-w-0 items-center gap-3">
size="icon" <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">
onClick={() => setEditClient(client)} {client.name.trim().charAt(0).toUpperCase() || "C"}
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" </div>
> <div className="min-w-0">
<Pencil className="w-4 h-4" /> <CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
</Button> </div>
)} </div>
{canDeleteClient && (
<Button {(canEditClient || canDeleteClient) && (
variant="ghost" <div className="flex shrink-0 items-center gap-1">
size="icon" {canEditClient && (
onClick={() => setDeleteClient(client)} <Button
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" variant="ghost"
> size="icon"
<Trash2 className="w-4 h-4" /> onClick={() => setEditClient(client)}
</Button> 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"}
</div> >
)} <Pencil className="h-4 w-4" />
</li> </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>
)}
{clients.length > 0 && (
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
/>
)} )}
</div> </div>
</Card>
{!isLoading && clients.length > 0 && (
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
/>
)} )}
</div>
{canCreateClient && ( {canCreateClient && (
<CreateClientModal <CreateClientModal

101
src/pages/Notifications.tsx Normal file
View 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>
);
}

View File

@@ -1,37 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useBlocker } from "react-router-dom"; import { useBlocker, useNavigate } from "react-router-dom";
import { import { Briefcase, Loader2 } from "lucide-react";
Users,
Briefcase,
Trash2,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createProject } from "../api/projects";
import { getClients } from "../api/clients"; import { getClients } from "../api/clients";
import { fetchWorkspaceMemberships } from "../api/workspaces"; import { createProject } from "../api/projects";
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput"; import { TextAreaInput } from "../components/ui/TextAreaInput";
import { InfiniteScroll } from "../components/InfiniteScroll"; import { useWorkspace } from "../context/WorkspaceContext";
import { Modal } from "../components/Modal"; import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
type ProjectRole = "manager" | "member";
interface LocalMember {
localId: string;
user: any;
role: ProjectRole;
isCreator?: boolean;
}
const COLORS = [ const COLORS = [
"#3B82F6", "#3B82F6",
@@ -44,54 +24,21 @@ const COLORS = [
"#64748B", "#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() { export default function ProjectCreate() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || "";
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE); const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
// Project Detail States
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]); const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState(""); const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<any[]>([]); const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
// 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); 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 [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 hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || client !== "" || color !== COLORS[0];
useEffect(() => { useEffect(() => {
if (activeWorkspace && !canCreateProject) { if (activeWorkspace && !canCreateProject) {
@@ -107,203 +54,29 @@ export default function ProjectCreate() {
return false; return false;
}); });
// EXACT same pagination structure as EditWorkspace.tsx
useEffect(() => { useEffect(() => {
if (activeWorkspace?.id) { if (!activeWorkspace?.id) return;
const workspaceId = activeWorkspace.id;
setName(""); setName("");
setDescription(""); setDescription("");
setColor(COLORS[0]); setColor(COLORS[0]);
setClient(""); setClient("");
setClientsList([]); setClientsList([]);
setWorkspaceMembers([]); setIsLoadingData(true);
setSearchQuery("");
setSearchResult(null);
setSearchError(false);
setAddAllMembers(false);
// Reset pagination state const loadInitialData = async () => {
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 { try {
let currentOffset = 0; const clientsRes = await getClients(activeWorkspace.id);
let continueFetching = true; setClientsList(clientsRes.results || []);
const allWsMembers: any[] = []; } catch {
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
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 { } finally {
setIsAddingAll(false); setIsLoadingData(false);
} }
} };
};
const openDeleteModal = (userId: string) => { void loadInitialData();
setMemberIdToDelete(userId); }, [activeWorkspace?.id, t.projects?.clientFetchError]);
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -311,20 +84,14 @@ export default function ProjectCreate() {
try { try {
setIsSaving(true); setIsSaving(true);
const membersPayload = members const newProject = await createProject({
.filter((m) => !m.isCreator) workspace: activeWorkspace.id,
.map((m) => ({ user_id: m.user.id, role: m.role }));
const projectPayload: any = {
name, name,
description, description,
color, color,
workspace: activeWorkspace.id, client: client || null,
members: membersPayload, is_archived: false,
}; });
if (client) projectPayload.client = client;
const newProject = await createProject(projectPayload);
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
toast.success(t.projects?.createSuccess || "Project created successfully."); toast.success(t.projects?.createSuccess || "Project created successfully.");
@@ -336,307 +103,87 @@ export default function ProjectCreate() {
} }
}; };
// Prepare unified display list if (!activeWorkspace) return null;
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 ( return (
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden"> <div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0"> <div className="mx-auto max-w-3xl">
{t.projects?.createNew || "Create New Project"} <h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
</h1> {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="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<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 onSubmit={handleSubmit} className="space-y-6 p-6">
<form id="create-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6"> <div className="flex flex-col gap-3">
<div className="flex-1 space-y-6"> <div className="flex items-center gap-4">
<div className="flex flex-col gap-3"> <div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<div className="flex items-center gap-4"> <Input
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} /> value={name}
<Input onChange={(e) => setName(e.target.value)}
value={name} placeholder={t.projects?.namePlaceholder || "Project name..."}
onChange={(e) => setName(e.target.value)} required
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 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> </div>
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0"> <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")}> <Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"} {t.cancel || "Cancel"}
</Button> </Button>
<Button type="submit" disabled={isSaving || !name.trim()}> <Button type="submit" disabled={isSaving || !name.trim()}>
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t.create || "Create"} {t.create || "Create"}
</Button> </Button>
</div> </div>
</form> </form>
</div> </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> </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>
); );
} }

View File

@@ -1,37 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams, useBlocker } from "react-router-dom"; import { useBlocker, useNavigate, useParams } from "react-router-dom";
import { import { Briefcase, Loader2 } from "lucide-react";
Users,
Briefcase,
Trash2,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getProject, updateProject } from "../api/projects";
import { getClients } from "../api/clients"; import { getClients } from "../api/clients";
import { fetchWorkspaceMemberships } from "../api/workspaces"; import { getProject, updateProject } from "../api/projects";
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput"; import { TextAreaInput } from "../components/ui/TextAreaInput";
import { InfiniteScroll } from "../components/InfiniteScroll"; import { useWorkspace } from "../context/WorkspaceContext";
import { Modal } from "../components/Modal"; import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
type ProjectRole = "manager" | "member";
interface LocalMember {
localId: string;
user: any;
role: ProjectRole;
isCreator?: boolean;
}
const COLORS = [ const COLORS = [
"#3B82F6", "#3B82F6",
@@ -44,50 +24,21 @@ const COLORS = [
"#64748B", "#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() { export default function ProjectEdit() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || "";
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT); const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]); const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState(""); const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<any[]>([]); const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [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 [isLoadingData, setIsLoadingData] = useState(true);
const [isProjectLoading, setIsProjectLoading] = useState(true); const [isProjectLoading, setIsProjectLoading] = useState(true);
const [members, setMembers] = useState<LocalMember[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [addAllMembers, setAddAllMembers] = useState(false);
const [isAddingAll, setIsAddingAll] = useState(false);
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const hasUnsavedChanges = name.trim() !== ""; const hasUnsavedChanges = name.trim() !== "";
@@ -106,190 +57,31 @@ export default function ProjectEdit() {
}); });
useEffect(() => { useEffect(() => {
if (activeWorkspace?.id && id) { if (!activeWorkspace?.id || !id) return;
const loadInitialData = async () => {
try {
const clientsRes = await getClients(activeWorkspace.id);
setClientsList(clientsRes.results || []);
const projectRes = await getProject(id); const loadInitialData = async () => {
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 { try {
let currentOffset = 0; const [clientsRes, projectRes] = await Promise.all([
let continueFetching = true; getClients(activeWorkspace.id),
const allWsMembers: any[] = []; getProject(id),
]);
while (continueFetching) { setClientsList(clientsRes.results || []);
const res = await fetchWorkspaceMemberships({ setName(projectRes.name || "");
workspace: activeWorkspace.id, setDescription(projectRes.description || "");
limit: 50, setColor(projectRes.color || COLORS[0]);
offset: currentOffset, setClient(projectRes.client?.id || projectRes.client || "");
}); } catch {
const fetchedResults = res.results || (Array.isArray(res) ? res : []); toast.error("Failed to load project data.");
allWsMembers.push(...fetchedResults); navigate("/projects");
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 { } finally {
setIsAddingAll(false); setIsLoadingData(false);
setIsProjectLoading(false);
} }
} };
};
const openDeleteModal = (userId: string) => { void loadInitialData();
setMemberIdToDelete(userId); }, [activeWorkspace?.id, id, navigate]);
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -297,18 +89,12 @@ export default function ProjectEdit() {
try { try {
setIsSaving(true); setIsSaving(true);
const membersPayload = members.map((m) => ({ user_id: m.user.id, role: m.role })); const updatedProject = await updateProject(id, {
const projectPayload: any = {
name, name,
description, description,
color, color,
workspace: activeWorkspace.id,
members: membersPayload,
client: client || null, client: client || null,
}; });
const updatedProject = await updateProject(id, projectPayload);
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject })); window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
toast.success(t.projects?.updateSuccess || "Project updated successfully."); toast.success(t.projects?.updateSuccess || "Project updated successfully.");
@@ -320,30 +106,6 @@ export default function ProjectEdit() {
} }
}; };
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 (!activeWorkspace) return null;
if (isProjectLoading) { if (isProjectLoading) {
@@ -355,274 +117,84 @@ export default function ProjectEdit() {
} }
return ( return (
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden"> <div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0"> <div className="mx-auto max-w-3xl">
{t.projects?.edit || "Edit Project"} <h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
</h1> {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="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<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 onSubmit={handleSubmit} className="space-y-6 p-6">
<form id="edit-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6"> <div className="flex flex-col gap-3">
<div className="flex-1 space-y-6"> <div className="flex items-center gap-4">
<div className="flex flex-col gap-3"> <div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<div className="flex items-center gap-4"> <Input
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} /> value={name}
<Input onChange={(e) => setName(e.target.value)}
value={name} placeholder={t.projects?.namePlaceholder || "Project name..."}
onChange={(e) => setName(e.target.value)} required
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 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> </div>
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0"> <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")}> <Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"} {t.cancel || "Cancel"}
</Button> </Button>
<Button type="submit" disabled={isSaving || !name.trim()}> <Button type="submit" disabled={isSaving || !name.trim()}>
{isSaving && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t.save || "Save Changes"} {t.save || "Save"}
</Button> </Button>
</div> </div>
</form> </form>
</div> </div>
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
<Users size={18} />
{t.projects?.projectMembers || "Project Members"}
</h3>
<Button
type="button"
variant={addAllMembers ? "destructive" : "outline"}
disabled={isAddingAll || isLoadingData}
onClick={handleToggleAddAllMembers}
>
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{addAllMembers
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
: (t.projects?.addAllWorkspaceMembers || "Add All")}
</Button>
</div>
<div className="relative">
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
className="ps-10"
/>
{isSearching && (
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
)}
</div>
{searchError && (
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
{t.projects?.userNotFound || "No user found with this mobile number."}
</p>
)}
{searchResult && !searchError && (
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
{searchResult.profile_picture ? (
<img
src={searchResult.profile_picture}
alt={searchResult.first_name}
className="w-10 h-10 rounded-full object-cover shadow-sm"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
{searchResult.first_name?.[0] || "U"}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{searchResult.first_name} {searchResult.last_name}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{searchResult.mobile}
</span>
</div>
</div>
<Button
type="button"
variant="default"
size="sm"
disabled={members.some((m) => m.user.id === searchResult.id)}
onClick={() => handleAddMember(searchResult)}
>
{members.some((m) => m.user.id === searchResult.id)
? (t.projects?.alreadyInProject || "Already Added")
: (t.projects?.addToProject || "Add to Project")}
</Button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoadingData ? (
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
{t.loading || "Loading..."}
</div>
) : (
<InfiniteScroll
onLoadMore={loadMoreMembers}
hasMore={hasMore && searchQuery.trim().length === 0}
isLoading={isLoadingMore}
>
{displayList.length === 0 ? (
<div className="p-4 text-sm text-slate-500 text-center">
{t.projects?.noWorkspaceMembers || "No members found."}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
{displayList.map((item) => {
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
const isAdded = !!addedMemberData;
return (
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
<div className="flex items-center gap-3">
{item.user.profile_picture ? (
<img
src={item.user.profile_picture}
alt={item.user.first_name}
className="w-9 h-9 rounded-full object-cover shadow-sm"
/>
) : (
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
{item.user.first_name?.[0] || "U"}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
{item.user.first_name} {item.user.last_name}
{addedMemberData?.isCreator && (
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
{t.projects?.creator || "Creator"}
</span>
)}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{item.user.mobile}
</span>
</div>
</div>
<div>
{isAdded ? (
<div className="flex items-center gap-2">
<Select
value={addedMemberData.role}
onChange={(val) => handleChangeRole(item.user.id, val)}
options={[
{ value: "member", label: t.projects?.roles?.member || "Member" },
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
]}
buttonClassName="text-xs h-8 w-28"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
onClick={() => openDeleteModal(item.user.id)}
>
<Trash2 size={16} />
</Button>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={() => handleAddMember(item.user)}
>
{t.projects?.addToProject || "Add to Project"}
</Button>
)}
</div>
</li>
);
})}
</ul>
)}
</InfiniteScroll>
)}
</div>
</div>
</div> </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>
); );
} }

View File

@@ -1,16 +1,18 @@
import React, { useState, useEffect } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects"; import { getProjects, deleteProject, type Project } from "../api/projects";
import { getClients } from "../api/clients";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal"; import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal"; import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
import { Plus, Archive, Trash2, Pencil } from "lucide-react"; import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react";
import FilterBar from "../components/FilterBar"; import FilterBar from "../components/FilterBar";
import { ListPageSkeleton } from "../components/ListPageSkeleton";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card"; import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
@@ -31,19 +33,20 @@ export const Projects: React.FC = () => {
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT); const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE); const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<any | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [ordering, setOrdering] = useState("-created_at"); const [ordering, setOrdering] = useState("-created_at");
const [isArchived, setIsArchived] = useState(false); const [isArchived, setIsArchived] = useState(false);
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10); const [limit, setLimit] = useState(10);
const [totalItems, setTotalItems] = useState(0); 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 [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
const [deleteInput, setDeleteInput] = useState(''); const [deleteInput, setDeleteInput] = useState('');
@@ -54,6 +57,10 @@ export const Projects: React.FC = () => {
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' }, { value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
]; ];
useEffect(() => {
setCurrentPage(1);
}, [search, ordering, isArchived, selectedClientIds]);
const fetchProjectList = async () => { const fetchProjectList = async () => {
if (!activeWorkspace) return; if (!activeWorkspace) return;
setLoading(true); setLoading(true);
@@ -63,6 +70,7 @@ export const Projects: React.FC = () => {
limit, limit,
offset, offset,
search, search,
clients: selectedClientIds,
is_archived: isArchived, is_archived: isArchived,
ordering ordering
}); });
@@ -72,21 +80,37 @@ export const Projects: React.FC = () => {
setTotalItems(count) setTotalItems(count)
} catch (error) { } catch (error) {
console.error("Failed to fetch projects", error); console.error("Failed to fetch projects", error);
toast.error(t.projects?.fetchError || "Failed to fetch projects.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
const delayDebounceFn = setTimeout(() => { if (!activeWorkspace?.id) return;
fetchProjectList();
}, 300); getClients(activeWorkspace.id, "", "name", 300, 0)
return () => clearTimeout(delayDebounceFn); .then((data: any) => {
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); 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(() => { useEffect(() => {
const handleCreated = () => fetchProjectList(); const delayDebounceFn = setTimeout(() => {
const handleUpdated = () => fetchProjectList(); 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_created", handleCreated);
window.addEventListener("project_updated", handleUpdated); window.addEventListener("project_updated", handleUpdated);
@@ -97,10 +121,6 @@ export const Projects: React.FC = () => {
}; };
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); }, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
const handleDeleteClick = (project: Project) => {
setProjectToDelete(project);
};
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteModal.project) return; if (!deleteModal.project) return;
try { try {
@@ -134,118 +154,221 @@ export const Projects: React.FC = () => {
} }
} }
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 ( return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900"> <div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4"> <div className="flex flex-1 flex-col gap-5">
<div> <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">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p> <div>
</div> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
<div className="flex items-center gap-3 w-full sm:w-auto"> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p>
{canArchiveProject && ( </div>
<Button <div className="flex w-full items-center gap-3 sm:w-auto">
variant={isArchived ? "default" : "secondary"} {canArchiveProject && (
onClick={() => setIsArchived(!isArchived)} <Button
className="gap-2 shadow-sm flex-1 sm:flex-none" variant={isArchived ? "default" : "secondary"}
> onClick={() => setIsArchived(!isArchived)}
<Archive className="h-4 w-4" /> className="flex-1 gap-2 shadow-sm sm:flex-none"
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')} >
</Button> <Archive className="h-4 w-4" />
)} {isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
{canCreateProject && ( </Button>
<Button )}
onClick={() => setIsCreateModalOpen(true)} {canCreateProject && (
size="icon" <Button
className="shadow-sm" onClick={() => setIsCreateModalOpen(true)}
title={t.projects?.createNew || 'Create New'} size="icon"
> className="shrink-0 shadow-sm"
<Plus className="h-5 w-5" /> title={t.projects?.createNew || 'Create New'}
</Button> >
)} <Plus className="h-5 w-5" />
</Button>
)}
</div>
</div> </div>
</div> </div>
<FilterBar <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">
searchQuery={search} <FilterBar
setSearchQuery={setSearch} searchQuery={search}
ordering={ordering} setSearchQuery={setSearch}
setOrdering={setOrdering} ordering={ordering}
orderingOptions={orderingOptions} setOrdering={setOrdering}
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'} 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 ? ( {loading ? (
<div className="p-12 flex justify-center text-slate-500"> <ListPageSkeleton variant="standard-grid" />
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
</div>
) : ( ) : (
<div className="flex flex-col flex-1"> <div className="flex flex-1 flex-col gap-6">
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6"> {projects.length === 0 ? (
<div className="p-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">
{projects.length === 0 ? ( <Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<div className="py-16 flex flex-col items-center justify-center"> <p className="font-medium text-slate-500 dark:text-slate-400">{t.projects?.emptyState || 'No projects found'}</p>
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p> </div>
</div> ) : (
) : ( <div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
<ul className="divide-y divide-slate-200 dark:divide-slate-800"> {projects.map((project) => {
{projects.map((project) => { const canDeleteProject = canDeleteWorkspaceResource({
const canDeleteProject = canDeleteWorkspaceResource({ workspaceRole,
workspaceRole, currentUserId: user?.id,
currentUserId: user?.id, createdById: project.created_by?.id,
createdById: project.created_by?.id, });
});
return ( return (
<li <Card key={project.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
key={project.id} <CardContent className="flex h-full flex-col gap-4 p-5">
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 items-start justify-between gap-3">
> <div className="flex min-w-0 items-center gap-3">
<div className="flex-1 min-w-0"> <div
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4> className="h-10 w-10 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate"> style={{ backgroundColor: project.color || "#3B82F6" }}
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"} />
</p> <div className="min-w-0">
{project.description && ( <CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate"> <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{project.description} {project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
</p> </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>
{(canEditProject || canDeleteProject) && ( <div className="space-y-3">
<div className="flex items-center gap-1 shrink-0"> <p className="min-h-[3.75rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300">
{canEditProject && ( {project.description || t.workspace?.noDescription || "No description"}
<Button </p>
variant="ghost" <div className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
size="icon" <span>{formatDate(project.created_at)}</span>
onClick={() => setEditingProject(project)} {project.is_archived ? (
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" <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">
title={t.actions?.edit || "Edit"} {t.projects?.archived || "Archived Projects"}
> </span>
<Pencil className="w-4 h-4" /> ) : null}
</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> </div>
)} </div>
</li> </CardContent>
); </Card>
})} );
</ul> })}
)}
</div> </div>
</Card> )}
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
@@ -257,6 +380,7 @@ export const Projects: React.FC = () => {
/> />
</div> </div>
)} )}
</div>
{/* Modals */} {/* Modals */}
{canCreateProject && isCreateModalOpen && ( {canCreateProject && isCreateModalOpen && (

View File

@@ -381,6 +381,7 @@ export default function Reports() {
billableHours: t.reports?.billableHours || "Billable hours", billableHours: t.reports?.billableHours || "Billable hours",
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours", nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
totalHours: t.reports?.totalHours || "Total hours", totalHours: t.reports?.totalHours || "Total hours",
hourlyRate: t.reports?.hourlyRate || "Hourly rate",
totalIncome: t.reports?.totalIncome || "Total income", totalIncome: t.reports?.totalIncome || "Total income",
details: t.reports?.details || "Details", details: t.reports?.details || "Details",
total: t.reports?.total || "Total", total: t.reports?.total || "Total",

View File

@@ -8,6 +8,7 @@ import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { TAGS_CREATE, TAGS_EDIT, canDeleteWorkspaceResource, canWorkspace } from "../lib/permissions"; import { TAGS_CREATE, TAGS_EDIT, canDeleteWorkspaceResource, canWorkspace } from "../lib/permissions";
import FilterBar from "../components/FilterBar"; import FilterBar from "../components/FilterBar";
import { ListPageSkeleton } from "../components/ListPageSkeleton";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
@@ -140,39 +141,50 @@ export default function Tags() {
}; };
if (!activeWorkspace) { 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 ( return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900"> <div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex justify-between items-center mb-8 gap-4"> <div className="flex flex-1 flex-col gap-5">
<div> <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">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1> <div className="flex items-start justify-between gap-4">
<p className="text-slate-500 dark:text-slate-400 mt-1"> <div>
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`} <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
</p> <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> </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 <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">
searchQuery={searchQuery} <FilterBar
setSearchQuery={setSearchQuery} searchQuery={searchQuery}
ordering={ordering} setSearchQuery={setSearchQuery}
setOrdering={setOrdering} ordering={ordering}
orderingOptions={orderingOptions} setOrdering={setOrdering}
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."} orderingOptions={orderingOptions}
/> searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
/>
</div>
{isLoading ? ( {isLoading ? (
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div> <ListPageSkeleton variant="dense-grid" />
) : ( ) : (
<div className="flex flex-col flex-1"> <div className="flex flex-1 flex-col gap-6">
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{tags.map((tag) => { {tags.map((tag) => {
const canDeleteTag = canDeleteWorkspaceResource({ const canDeleteTag = canDeleteWorkspaceResource({
workspaceRole, workspaceRole,
@@ -196,7 +208,13 @@ export default function Tags() {
{(canEditTag || canDeleteTag) && ( {(canEditTag || canDeleteTag) && (
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{canEditTag && ( {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" /> <Edit2 className="w-4 h-4" />
</Button> </Button>
)} )}
@@ -205,9 +223,10 @@ export default function Tags() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setDeleteModal({ isOpen: true, tag })} 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"} title={t.actions?.delete || "Delete"}
> >
<Trash2 className="w-4 h-4 text-red-500" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
)} )}
</div> </div>
@@ -219,9 +238,9 @@ export default function Tags() {
})} })}
{tags.length === 0 && ( {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" /> <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>
)} )}
</div> </div>
@@ -235,6 +254,7 @@ export default function Tags() {
/> />
</div> </div>
)} )}
</div>
<Modal <Modal
isOpen={isModalOpen} isOpen={isModalOpen}

View File

@@ -22,7 +22,7 @@ import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timeshe
import JalaliDatePicker from "../components/ui/JalaliDatePicker"; import JalaliDatePicker from "../components/ui/JalaliDatePicker";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select"; import { SearchableSelect } from "../components/ui/SearchableSelect";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
@@ -863,6 +863,8 @@ function ProjectInlineSelect({
value, value,
onChange, onChange,
placeholder, placeholder,
searchPlaceholder,
emptyLabel,
portalOwnerId, portalOwnerId,
className = "", className = "",
dropdownClassName = "", dropdownClassName = "",
@@ -872,12 +874,15 @@ function ProjectInlineSelect({
value: string; value: string;
onChange: (projectId: string) => void; onChange: (projectId: string) => void;
placeholder: string; placeholder: string;
searchPlaceholder: string;
emptyLabel: string;
portalOwnerId?: string; portalOwnerId?: string;
className?: string; className?: string;
dropdownClassName?: string; dropdownClassName?: string;
disabled?: boolean; disabled?: boolean;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -931,8 +936,22 @@ function ProjectInlineSelect({
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery("");
}
}, [isOpen]);
const selectedProject = projects.find((project) => project.id === value); const selectedProject = projects.find((project) => project.id === value);
const label = selectedProject?.name || placeholder; 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 ( return (
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}> <div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
@@ -959,7 +978,19 @@ function ProjectInlineSelect({
data-entry-editor-owner={portalOwnerId} 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}`} 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"> <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 <button
type="button" type="button"
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
@@ -976,7 +1007,7 @@ function ProjectInlineSelect({
{placeholder} {placeholder}
</button> </button>
{projects.map((project) => { {filteredProjects.map((project) => {
const selected = project.id === value; const selected = project.id === value;
const unavailable = Boolean(project.is_deleted) && !selected; const unavailable = Boolean(project.is_deleted) && !selected;
return ( return (
@@ -1002,6 +1033,9 @@ function ProjectInlineSelect({
</button> </button>
); );
})} })}
{filteredProjects.length === 0 && (
<div className="px-3 py-3 text-xs text-slate-500 dark:text-slate-400">{emptyLabel}</div>
)}
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -1234,6 +1268,8 @@ function EntryEditorFields({
value={state.projectId} value={state.projectId}
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
placeholder={t.timesheet?.projectLabel || "Project"} placeholder={t.timesheet?.projectLabel || "Project"}
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
portalOwnerId={portalOwnerId} portalOwnerId={portalOwnerId}
className="min-w-0 max-w-fit flex-1" className="min-w-0 max-w-fit flex-1"
/> />
@@ -1311,13 +1347,20 @@ function EntryEditorFields({
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}> <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"} {t.timesheet?.projectLabel || "Project"}
</label> </label>
<Select <SearchableSelect
value={state.projectId} value={state.projectId}
onChange={(value) => onChange({ projectId: String(value) })} onChange={(value) => onChange({ projectId: String(value) })}
options={[ options={[
{ value: "", label: t.timesheet?.noProject || "No project" }, { value: "", label: t.timesheet?.noProject || "No project" },
...projects.map((project) => ({ value: project.id, label: project.name })), ...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" className="w-full"
buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"} buttonClassName={compact ? "w-full h-9 px-2 text-xs" : "w-full"}
/> />
@@ -1540,7 +1583,7 @@ function RecordedEntryCard({
<div <div
ref={rowRef} ref={rowRef}
onBlurCapture={handleBlurCapture} 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"> <div className="space-y-4">
<EntryEditorFields <EntryEditorFields
@@ -1555,7 +1598,7 @@ function RecordedEntryCard({
portalOwnerId={editorOwnerId} 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"> <div className="text-sm text-slate-500 dark:text-slate-400">
{formatDateTime(entry.start_time, lang)} {formatDateTime(entry.start_time, lang)}
</div> </div>
@@ -1591,7 +1634,7 @@ function RecordedEntryCard({
} }
return ( 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"> <div className="flex min-w-0 items-center">
<EntryEditorFields <EntryEditorFields
state={draft} state={draft}
@@ -1606,20 +1649,20 @@ function RecordedEntryCard({
portalOwnerId={editorOwnerId} 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)} {formatDuration(entry)}
</div> </div>
<button <button
type="button" type="button"
onClick={() => onRestart(entry)} 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" title="Start from this entry"
> >
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
</button> </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)} /> <DeleteEntryButton onDelete={() => onDelete(entry)} />
</div> </div>
</div> </div>
@@ -1748,7 +1791,7 @@ function MobileRecordedEntryCard({
}; };
return ( 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"> <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" /> <Play className="h-4 w-4" />
</div> </div>
@@ -1757,7 +1800,7 @@ function MobileRecordedEntryCard({
</div> </div>
<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)` }} style={{ transform: `translateX(${swipeOffset}px)` }}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
@@ -1874,6 +1917,97 @@ function MobileRecordedEntryCard({
); );
} }
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() { export default function Timesheet() {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
@@ -1898,8 +2032,13 @@ export default function Timesheet() {
fromFilterPrefix?: string; fromFilterPrefix?: string;
toFilterPrefix?: string; toFilterPrefix?: string;
restartConfirmMessage?: string; restartConfirmMessage?: string;
discardConfirmMessage?: string;
deletedProjectLabel?: string; deletedProjectLabel?: string;
deletedTagLabel?: string; deletedTagLabel?: string;
searchTagsLabel?: string;
noTagsFoundLabel?: string;
searchProjectsLabel?: string;
noProjectsFoundLabel?: string;
}) || {}; }) || {};
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@@ -1909,6 +2048,7 @@ export default function Timesheet() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS); const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
const [hasMoreHistory, setHasMoreHistory] = useState(false); const [hasMoreHistory, setHasMoreHistory] = useState(false);
const [nextOffset, setNextOffset] = useState<number | null>(0); const [nextOffset, setNextOffset] = useState<number | null>(0);
@@ -1982,7 +2122,7 @@ export default function Timesheet() {
getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }), getTags(activeWorkspace.id, { limit: 100, offset: 0, ordering: "name" }),
]); ]);
setProjects(projectsData.results || []); setProjects((projectsData.results || []).filter((project: Project) => !project.is_archived));
setTags(tagsData.results || []); setTags(tagsData.results || []);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -1995,17 +2135,26 @@ export default function Timesheet() {
useEffect(() => { useEffect(() => {
setSearchQuery(""); setSearchQuery("");
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS); setFilters(DEFAULT_ENTRY_FILTERS);
setGroupedHistory([]); setGroupedHistory([]);
setNextOffset(0); setNextOffset(0);
setHasMoreHistory(false); setHasMoreHistory(false);
}, [activeWorkspace?.id]); }, [activeWorkspace?.id]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 350);
return () => window.clearTimeout(timeoutId);
}, [searchQuery]);
useEffect(() => { useEffect(() => {
setGroupedHistory([]); setGroupedHistory([]);
setNextOffset(0); setNextOffset(0);
setHasMoreHistory(false); setHasMoreHistory(false);
}, [filters, searchQuery]); }, [debouncedSearchQuery, filters]);
useEffect(() => { useEffect(() => {
if (!filters.clientId || !filters.projectId) return; if (!filters.clientId || !filters.projectId) return;
@@ -2031,7 +2180,7 @@ export default function Timesheet() {
const params: TimeEntryListParams = { const params: TimeEntryListParams = {
limit, limit,
offset, offset,
search: searchQuery, search: debouncedSearchQuery,
status: "ended", status: "ended",
project: filters.projectId || undefined, project: filters.projectId || undefined,
client: filters.clientId || undefined, client: filters.clientId || undefined,
@@ -2053,7 +2202,7 @@ export default function Timesheet() {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [activeWorkspace?.id, filters, limit, searchQuery, t.timesheet?.fetchError]); }, [activeWorkspace?.id, debouncedSearchQuery, filters, limit, t.timesheet?.fetchError]);
const loadRunningEntry = useCallback(async () => { const loadRunningEntry = useCallback(async () => {
if (!activeWorkspace?.id) { if (!activeWorkspace?.id) {
@@ -2082,7 +2231,7 @@ export default function Timesheet() {
}, 250); }, 250);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [activeWorkspace?.id, limit, loadHistory, filters, searchQuery]); }, [activeWorkspace?.id, debouncedSearchQuery, limit, loadHistory, filters]);
useEffect(() => { useEffect(() => {
void loadRunningEntry(); void loadRunningEntry();
@@ -2373,13 +2522,13 @@ export default function Timesheet() {
} }
}, []); }, []);
const handleApplyFilters = useCallback((nextSearchQuery: string, nextFilters: TimeEntryFilters) => { const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => {
setSearchQuery(nextSearchQuery);
setFilters(nextFilters); setFilters(nextFilters);
}, []); }, []);
const handleClearFilters = useCallback(() => { const handleClearFilters = useCallback(() => {
setSearchQuery(""); setSearchQuery("");
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS); setFilters(DEFAULT_ENTRY_FILTERS);
}, []); }, []);
@@ -2426,7 +2575,7 @@ export default function Timesheet() {
<div <div
ref={desktopTimerRef} ref={desktopTimerRef}
onBlurCapture={handleTimerBlurCapture} 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" 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="flex min-w-0 items-center gap-2 px-3 py-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -2440,17 +2589,23 @@ export default function Timesheet() {
</div> </div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Select <SearchableSelect
value={timerDraft.projectId} value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))} onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
options={[ options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" }, { value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })), ...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]" 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" 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} disabled={isStartingTimer}
portalOwnerId={timerEditorOwnerId}
/> />
</div> </div>
@@ -2529,7 +2684,7 @@ export default function Timesheet() {
<div <div
ref={mobileTimerRef} ref={mobileTimerRef}
onBlurCapture={handleTimerBlurCapture} 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" 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"> <div className="space-y-3">
<Input <Input
@@ -2541,17 +2696,23 @@ export default function Timesheet() {
/> />
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Select <SearchableSelect
value={timerDraft.projectId} value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))} onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
options={[ options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" }, { value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })), ...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" 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" 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} disabled={isStartingTimer}
portalOwnerId={timerEditorOwnerId}
/> />
<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"> <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">
@@ -2628,6 +2789,7 @@ export default function Timesheet() {
<TimesheetFilterBar <TimesheetFilterBar
searchQuery={searchQuery} searchQuery={searchQuery}
filters={filters} filters={filters}
onSearchChange={setSearchQuery}
onApply={handleApplyFilters} onApply={handleApplyFilters}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
projects={projects} projects={projects}
@@ -2651,12 +2813,14 @@ export default function Timesheet() {
tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag", tagPrefix: extendedTimesheet.tagFilterPrefix || "Tag",
fromPrefix: extendedTimesheet.fromFilterPrefix || "From", fromPrefix: extendedTimesheet.fromFilterPrefix || "From",
toPrefix: extendedTimesheet.toFilterPrefix || "To", toPrefix: extendedTimesheet.toFilterPrefix || "To",
searchTags: extendedTimesheet.searchTagsLabel || t.tags?.searchPlaceholder || "Search tags...",
noTagsFound: extendedTimesheet.noTagsFoundLabel || "No tags found.",
}} }}
/> />
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex justify-center p-12 text-slate-500">{t.loading || "Loading..."}</div> <TimesheetSkeleton loadingLabel={t.loading || "Loading..."} />
) : ( ) : (
<InfiniteScroll <InfiniteScroll
className="flex flex-1 flex-col" className="flex flex-1 flex-col"
@@ -2677,13 +2841,13 @@ export default function Timesheet() {
</div> </div>
{week.days.map((day) => ( {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 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-800 dark:bg-slate-900"> <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"> <p className="text-xs font-medium text-slate-500 dark:text-slate-400">
{formatDayLabel(new Date(`${day.date}T00:00:00`), lang)} {formatDayLabel(new Date(`${day.date}T00:00:00`), lang)}
</p> </p>
<p className="text-xs text-slate-500 dark:text-slate-400"> <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> {t.reports?.total || "Total"}: <span className="font-semibold text-slate-700 dark:text-slate-200">{formatDurationMs(day.total_ms)}</span>
</p> </p>
</div> </div>
@@ -2723,7 +2887,7 @@ export default function Timesheet() {
))} ))}
{groupedHistory.length === 0 && ( {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"> <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" /> <Clock3 className="mb-3 h-10 w-10" />
<p>{t.timesheet?.emptyState || "No time entries found"}</p> <p>{t.timesheet?.emptyState || "No time entries found"}</p>
</div> </div>
@@ -2763,7 +2927,7 @@ export default function Timesheet() {
<Modal <Modal
isOpen={deleteModal.isOpen} isOpen={deleteModal.isOpen}
onClose={closeDeleteModal} onClose={closeDeleteModal}
title={extendedTimesheet.deleteTitle || "Delete Time Entry"} title={extendedTimesheet.deleteTitle || t.timesheet?.deleteTitle || "Delete Time Entry"}
maxWidth="max-w-md" maxWidth="max-w-md"
footer={ footer={
<> <>
@@ -2778,9 +2942,9 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <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?"} {extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
</p> </p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"> <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"> <p className="font-medium text-slate-900 dark:text-white">
{deleteModal.entry.description || t.timesheet?.emptyDescription || "No description"} {deleteModal.entry.description || t.timesheet?.emptyDescription || "No description"}
</p> </p>
@@ -2812,9 +2976,9 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{(extendedTimesheet.restartConfirmMessage || "Start a new running timer from this entry?")} {extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
</p> </p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"> <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"> <p className="font-medium text-slate-900 dark:text-white">
{restartModal.entry.description || t.timesheet?.emptyDescription || "No description"} {restartModal.entry.description || t.timesheet?.emptyDescription || "No description"}
</p> </p>
@@ -2846,9 +3010,9 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <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?"} {extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
</p> </p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60"> <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"> <p className="font-medium text-slate-900 dark:text-white">
{discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"} {discardTimerModal.entry.description || t.timesheet?.emptyDescription || "No description"}
</p> </p>

View File

@@ -11,6 +11,7 @@ import {
type WorkspaceRole, type WorkspaceRole,
} from '../lib/permissions'; } from '../lib/permissions';
import FilterBar from '../components/FilterBar'; import FilterBar from '../components/FilterBar';
import { ListPageSkeleton } from '../components/ListPageSkeleton';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
import { Card, CardContent, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardTitle } from '../components/ui/card';
@@ -116,38 +117,41 @@ export default function Workspaces() {
}; };
return ( return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900"> <div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex justify-between items-center mb-8"> <div className="flex flex-1 flex-col gap-5">
<div> <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">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1> <div className="flex items-start justify-between gap-4">
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p> <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>
<Button
onClick={() => navigate('/workspaces/create')}
size="icon"
className="shadow-sm"
title={t.workspace?.createNew || 'Create New'}
>
<Plus className="h-5 w-5" />
</Button>
</div> </div>
<FilterBar <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">
searchQuery={searchQuery} <FilterBar
setSearchQuery={setSearchQuery} searchQuery={searchQuery}
ordering={ordering} setSearchQuery={setSearchQuery}
setOrdering={setOrdering} ordering={ordering}
orderingOptions={orderingOptions} setOrdering={setOrdering}
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'} orderingOptions={orderingOptions}
/> searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
/>
</div>
{isLoading ? ( {isLoading ? (
<div className="p-12 flex justify-center text-slate-500"> <ListPageSkeleton variant="list" />
<div className="animate-pulse">{t.workspace?.loading || 'Loading...'}</div>
</div>
) : ( ) : (
<div className="flex flex-col flex-1"> <div className="flex flex-1 flex-col gap-6">
<div className="flex flex-col gap-4 mb-6"> <div className="flex flex-1 flex-col gap-4">
{workspaces.map((workspace) => { {workspaces.map((workspace) => {
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE); const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT); const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
@@ -214,8 +218,10 @@ export default function Workspaces() {
})} })}
{workspaces.length === 0 && ( {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"> <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> <p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p>
</div>
</div> </div>
)} )}
</div> </div>
@@ -230,6 +236,7 @@ export default function Workspaces() {
/> />
</div> </div>
)} )}
</div>
{deleteModal.workspace && ( {deleteModal.workspace && (
<Modal <Modal