Compare commits
15 Commits
2d903de97b
...
8c5b4e258e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c5b4e258e | |||
| ab6fe908d3 | |||
| 2d843046fa | |||
| f9dfd8826e | |||
| 846668add9 | |||
| cf7dd06046 | |||
| 8b16344aef | |||
| d53a8a67d7 | |||
| 29eefdea27 | |||
| 679d1cafb6 | |||
| 204b093937 | |||
| 7f0e00f09d | |||
| c8c689e693 | |||
| 7b8c3d7ff7 | |||
| 056ff31ef8 |
10
src/App.tsx
10
src/App.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { createBrowserRouter, RouterProvider, Navigate, Outlet } from "react-router-dom"
|
import { createBrowserRouter, RouterProvider, Navigate, Outlet } from "react-router-dom"
|
||||||
|
import { useState } from "react"
|
||||||
import { ThemeProvider } from "./components/ThemeProvider"
|
import { ThemeProvider } from "./components/ThemeProvider"
|
||||||
import { LanguageProvider } from "./components/LanguageProvider"
|
import { LanguageProvider } from "./components/LanguageProvider"
|
||||||
import { Toaster } from "./components/ui/toaster"
|
import { Toaster } from "./components/ui/toaster"
|
||||||
@@ -21,12 +22,17 @@ import Tags from "./pages/Tags"
|
|||||||
import Timesheet from "./pages/Timesheet"
|
import Timesheet from "./pages/Timesheet"
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-slate-50 dark:bg-slate-950 overflow-hidden text-slate-900 dark:text-slate-100">
|
<div className="flex h-screen bg-slate-50 dark:bg-slate-950 overflow-hidden text-slate-900 dark:text-slate-100">
|
||||||
<Sidebar />
|
<Sidebar
|
||||||
|
mobileOpen={mobileSidebarOpen}
|
||||||
|
onMobileClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col h-screen overflow-y-auto relative">
|
<div className="flex-1 flex flex-col h-screen overflow-y-auto relative">
|
||||||
<Navbar />
|
<Navbar onOpenSidebar={() => setMobileSidebarOpen(true)} />
|
||||||
|
|
||||||
<main className="flex-1 relative">
|
<main className="flex-1 relative">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
107
src/api/rates.ts
Normal file
107
src/api/rates.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
|
export interface RateUser {
|
||||||
|
id: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
mobile?: string;
|
||||||
|
profile_picture?: string;
|
||||||
|
avatar?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceUnit {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
local_name?: string;
|
||||||
|
symbol?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceUserRate {
|
||||||
|
id: string;
|
||||||
|
workspace: string;
|
||||||
|
user: string;
|
||||||
|
user_details?: RateUser;
|
||||||
|
hourly_rate: string;
|
||||||
|
currency: string;
|
||||||
|
price_unit?: PriceUnit | null;
|
||||||
|
effective_from: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensurePaginated = async <T>(response: Response): Promise<PaginatedResponse<T>> => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Rate request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return { count: data.length, next: null, previous: null, results: data };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
count: data.count || data.results?.length || 0,
|
||||||
|
next: data.next || null,
|
||||||
|
previous: data.previous || null,
|
||||||
|
results: data.results || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPriceUnits = async () => {
|
||||||
|
const response = await authFetch("/api/price-units/");
|
||||||
|
return ensurePaginated<PriceUnit>(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceUserRates = async (workspaceId: string) => {
|
||||||
|
const response = await authFetch(`/api/workspace-user-rates/?workspace=${workspaceId}`);
|
||||||
|
return ensurePaginated<WorkspaceUserRate>(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWorkspaceUserRate = async (data: {
|
||||||
|
workspace_id: string;
|
||||||
|
user_id: string;
|
||||||
|
hourly_rate: string;
|
||||||
|
currency: string;
|
||||||
|
}) => {
|
||||||
|
const response = await authFetch("/api/workspace-user-rates/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to save workspace user rate");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<WorkspaceUserRate>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkspaceUserRate = async (
|
||||||
|
rateId: string,
|
||||||
|
data: Partial<Pick<WorkspaceUserRate, "hourly_rate" | "currency">>,
|
||||||
|
) => {
|
||||||
|
const response = await authFetch(`/api/workspace-user-rates/${rateId}/`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to update workspace user rate");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<WorkspaceUserRate>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWorkspaceUserRate = async (rateId: string) => {
|
||||||
|
const response = await authFetch(`/api/workspace-user-rates/${rateId}/`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.message || "Failed to delete workspace user rate");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,14 +3,18 @@ import { useNavigate } from "react-router-dom"
|
|||||||
import { useTranslation } from "../hooks/useTranslation"
|
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 } from "lucide-react"
|
import { LogOut, User, Moon, Sun, Globe, Command, Menu } from "lucide-react"
|
||||||
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"
|
||||||
import { NotificationBell } from "./notifications/NotificationBell"
|
import { NotificationBell } from "./notifications/NotificationBell"
|
||||||
import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session"
|
import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session"
|
||||||
|
|
||||||
export function Navbar() {
|
type NavbarProps = {
|
||||||
|
onOpenSidebar?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar({ onOpenSidebar }: NavbarProps) {
|
||||||
const { t, lang, setLanguage } = useTranslation()
|
const { t, lang, setLanguage } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||||
@@ -106,12 +110,22 @@ export function Navbar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-8 py-6 backdrop-blur-md transition-colors dark:border-slate-800/80 dark:bg-slate-900/70">
|
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-8 py-6 backdrop-blur-md transition-colors dark:border-slate-800/80 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSidebar}
|
||||||
|
className="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 md:hidden dark:border-slate-800 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
title="Open menu"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
<div className="flex cursor-pointer items-center gap-2" onClick={() => navigate("/")}>
|
<div className="flex cursor-pointer items-center gap-2" onClick={() => navigate("/")}>
|
||||||
<span className="relative z-20 flex items-center gap-2 text-xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
|
<span className="relative z-20 flex items-center gap-2 text-xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
|
||||||
<Command className="h-7 w-7" />
|
<Command className="h-7 w-7" />
|
||||||
{t.title || "Qlockify"}
|
{t.title || "Qlockify"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{user && <WorkspaceSelector />}
|
{user && <WorkspaceSelector />}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
X,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
@@ -13,7 +14,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
export const Sidebar = () => {
|
type SidebarProps = {
|
||||||
|
mobileOpen?: boolean;
|
||||||
|
onMobileClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
@@ -51,7 +57,41 @@ export const Sidebar = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderNavItems = (mobile = false) =>
|
||||||
|
navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={`${mobile ? 'mobile' : 'desktop'}-${item.path}`}
|
||||||
|
to={item.path}
|
||||||
|
title={!mobile && isCollapsed ? item.label : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (mobile) onMobileClose?.();
|
||||||
|
}}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
mobile
|
||||||
|
? 'gap-3 px-4 py-3'
|
||||||
|
: isCollapsed
|
||||||
|
? 'justify-center px-0 py-2.5'
|
||||||
|
: 'gap-3 px-3 py-2.5'
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={mobile ? 20 : isCollapsed ? 22 : 18} className="shrink-0" />
|
||||||
|
{(mobile || !isCollapsed) && (
|
||||||
|
<span className="truncate whitespace-nowrap">{item.label}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<aside
|
<aside
|
||||||
className={`border-e border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 hidden md:flex flex-col h-screen sticky top-0 shrink-0 transition-all duration-300 ease-in-out ${
|
className={`border-e border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 hidden md:flex flex-col h-screen sticky top-0 shrink-0 transition-all duration-300 ease-in-out ${
|
||||||
isCollapsed ? 'w-20' : 'w-64'
|
isCollapsed ? 'w-20' : 'w-64'
|
||||||
@@ -75,31 +115,52 @@ export const Sidebar = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto overflow-x-hidden">
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto overflow-x-hidden">
|
||||||
{navItems.map((item) => {
|
{renderNavItems(false)}
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
title={isCollapsed ? item.label : undefined}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
isCollapsed ? 'justify-center px-0' : 'gap-3 px-3'
|
|
||||||
} ${
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon size={isCollapsed ? 22 : 18} className="shrink-0" />
|
|
||||||
{!isCollapsed && (
|
|
||||||
<span className="truncate whitespace-nowrap">{item.label}</span>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-[70] md:hidden transition-all duration-200 ${
|
||||||
|
mobileOpen ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
|
}`}
|
||||||
|
aria-hidden={!mobileOpen}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`absolute inset-0 bg-slate-950/50 backdrop-blur-[1px] transition-opacity ${
|
||||||
|
mobileOpen ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={onMobileClose}
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
className={`absolute inset-y-0 flex w-[18.5rem] max-w-[86vw] flex-col bg-white shadow-2xl transition-transform dark:bg-slate-950 ${
|
||||||
|
isRtl
|
||||||
|
? `right-0 border-s border-slate-200 dark:border-slate-800 ${
|
||||||
|
mobileOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
}`
|
||||||
|
: `left-0 border-e border-slate-200 dark:border-slate-800 ${
|
||||||
|
mobileOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-5 dark:border-slate-800">
|
||||||
|
<h2 className="truncate text-lg font-bold text-slate-800 dark:text-white">
|
||||||
|
{t.title || "Qlockify"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMobileClose}
|
||||||
|
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
|
||||||
|
{renderNavItems(true)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function NotificationBell() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
className="relative inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition-colors hover:border-sky-300 hover:text-sky-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-sky-500/50 dark:hover:text-sky-300"
|
className="relative inline-flex h-11 w-11 items-center justify-center rounded-lg border-slate-200 text-slate-600 transition-colors hover:border-sky-300 hover:text-sky-600 dark:border-slate-800 dark:text-slate-300 dark:hover:border-sky-500/50 dark:hover:text-sky-300"
|
||||||
aria-label={t.notifications?.open || "Open notifications"}
|
aria-label={t.notifications?.open || "Open notifications"}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Select } from "../ui/Select";
|
|||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { TextAreaInput } from "../ui/TextAreaInput";
|
import { TextAreaInput } from "../ui/TextAreaInput";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { PROJECTS_CREATE, canWorkspace } from "../../lib/permissions";
|
||||||
|
|
||||||
interface ProjectCreateModalProps {
|
interface ProjectCreateModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -17,6 +18,7 @@ interface ProjectCreateModalProps {
|
|||||||
export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen, onClose }) => {
|
export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen, onClose }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [clients, setClients] = useState<any[]>([]);
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -72,6 +74,8 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!canCreateProject) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Select } from "../ui/Select";
|
|||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { TextAreaInput } from "../ui/TextAreaInput";
|
import { TextAreaInput } from "../ui/TextAreaInput";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { PROJECTS_ARCHIVE, PROJECTS_EDIT, canWorkspace } from "../../lib/permissions";
|
||||||
|
|
||||||
interface ProjectEditModalProps {
|
interface ProjectEditModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -19,6 +20,8 @@ interface ProjectEditModalProps {
|
|||||||
export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onClose, project }) => {
|
export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onClose, project }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
|
||||||
|
const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [clients, setClients] = useState<any[]>([]);
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -88,6 +91,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<div className="flex justify-between w-full">
|
<div className="flex justify-between w-full">
|
||||||
|
{canArchiveProject ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleArchiveToggle}
|
onClick={handleArchiveToggle}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,6 +105,9 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
||||||
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
|
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
|
||||||
@@ -113,6 +120,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!canEditProject) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
|
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
|
||||||
|
|||||||
130
src/components/rates/WorkspaceMemberRateFields.tsx
Normal file
130
src/components/rates/WorkspaceMemberRateFields.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import type { PriceUnit, WorkspaceUserRate } from "../../api/rates";
|
||||||
|
import {
|
||||||
|
createWorkspaceUserRate,
|
||||||
|
deleteWorkspaceUserRate,
|
||||||
|
updateWorkspaceUserRate,
|
||||||
|
} from "../../api/rates";
|
||||||
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { SearchableSelect } from "../ui/SearchableSelect";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
rate?: WorkspaceUserRate;
|
||||||
|
priceUnits: PriceUnit[];
|
||||||
|
onRatesChanged: (updater: (rates: WorkspaceUserRate[]) => WorkspaceUserRate[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkspaceMemberRateFields({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
rate,
|
||||||
|
priceUnits,
|
||||||
|
onRatesChanged,
|
||||||
|
}: Props) {
|
||||||
|
const { t, lang } = useTranslation();
|
||||||
|
const [hourlyRate, setHourlyRate] = useState(rate?.hourly_rate || "");
|
||||||
|
const [currency, setCurrency] = useState(rate?.currency || "USD");
|
||||||
|
const [isPersisting, setIsPersisting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHourlyRate(rate?.hourly_rate || "");
|
||||||
|
setCurrency(rate?.currency || "USD");
|
||||||
|
}, [rate?.hourly_rate, rate?.currency, rate?.id]);
|
||||||
|
|
||||||
|
const unitOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
priceUnits.map((unit) => ({
|
||||||
|
value: unit.code,
|
||||||
|
label:
|
||||||
|
lang === "fa"
|
||||||
|
? `${unit.local_name || unit.name} (${unit.code})`
|
||||||
|
: `${unit.code} · ${unit.name}`,
|
||||||
|
searchText: `${unit.code} ${unit.name} ${unit.local_name || ""} ${unit.symbol || ""}`,
|
||||||
|
})),
|
||||||
|
[lang, priceUnits],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persist = async (nextRate: string, nextCurrency: string) => {
|
||||||
|
const trimmedRate = nextRate.trim();
|
||||||
|
const normalizedCurrency = nextCurrency.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!trimmedRate) {
|
||||||
|
if (!rate?.id) return;
|
||||||
|
setIsPersisting(true);
|
||||||
|
try {
|
||||||
|
await deleteWorkspaceUserRate(rate.id);
|
||||||
|
onRatesChanged((rates) => rates.filter((item) => item.id !== rate.id));
|
||||||
|
toast.success(t.rates?.workspaceRemoveSuccess || "Workspace user rate removed.");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : (t.rates?.workspaceRemoveError || "Failed to remove workspace user rate."));
|
||||||
|
setHourlyRate(rate.hourly_rate || "");
|
||||||
|
setCurrency(rate.currency || "USD");
|
||||||
|
} finally {
|
||||||
|
setIsPersisting(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate?.hourly_rate === trimmedRate && rate?.currency === normalizedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPersisting(true);
|
||||||
|
try {
|
||||||
|
const saved = rate?.id
|
||||||
|
? await updateWorkspaceUserRate(rate.id, { hourly_rate: trimmedRate, currency: normalizedCurrency })
|
||||||
|
: await createWorkspaceUserRate({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
user_id: userId,
|
||||||
|
hourly_rate: trimmedRate,
|
||||||
|
currency: normalizedCurrency,
|
||||||
|
});
|
||||||
|
onRatesChanged((rates) => [
|
||||||
|
...rates.filter((item) => item.user !== userId),
|
||||||
|
saved,
|
||||||
|
]);
|
||||||
|
setHourlyRate(saved.hourly_rate || "");
|
||||||
|
setCurrency(saved.currency || normalizedCurrency);
|
||||||
|
toast.success(t.rates?.workspaceSaveSuccess || "Workspace user rate saved.");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : (t.rates?.workspaceSaveError || "Failed to save workspace user rate."));
|
||||||
|
setHourlyRate(rate?.hourly_rate || "");
|
||||||
|
setCurrency(rate?.currency || "USD");
|
||||||
|
} finally {
|
||||||
|
setIsPersisting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid w-full gap-2 sm:w-auto sm:grid-cols-[120px_180px]">
|
||||||
|
<Input
|
||||||
|
value={hourlyRate}
|
||||||
|
onChange={(event) => setHourlyRate(event.target.value)}
|
||||||
|
onBlur={() => void persist(hourlyRate, currency)}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder={t.rates?.hourlyRatePlaceholder || "0.00"}
|
||||||
|
disabled={isPersisting}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<SearchableSelect
|
||||||
|
value={currency}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCurrency(value);
|
||||||
|
if (hourlyRate.trim()) {
|
||||||
|
void persist(hourlyRate, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={unitOptions}
|
||||||
|
placeholder={t.rates?.currencyPlaceholder || "USD"}
|
||||||
|
searchPlaceholder={t.rates?.searchUnitPlaceholder || "Search unit..."}
|
||||||
|
disabled={isPersisting}
|
||||||
|
buttonClassName="h-9 dark:bg-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { BriefcaseBusiness, CalendarRange, Check, ChevronDown, FolderKanban, Search, SlidersHorizontal, Tag as TagIcon, X } from "lucide-react";
|
import { BriefcaseBusiness, CalendarRange, Check, FolderKanban, Search, SlidersHorizontal, Tag as TagIcon, X } from "lucide-react";
|
||||||
|
|
||||||
import type { Project } from "../../api/projects";
|
import type { Project } from "../../api/projects";
|
||||||
import type { Tag } from "../../api/tags";
|
import type { Tag } from "../../api/tags";
|
||||||
@@ -56,6 +56,7 @@ function FilterTagMultiSelect({
|
|||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -105,7 +106,17 @@ function FilterTagMultiSelect({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
||||||
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||||
|
const filteredTags = normalizedSearch
|
||||||
|
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||||||
|
: tags;
|
||||||
const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
|
const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,8 +138,20 @@ function FilterTagMultiSelect({
|
|||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||||
{tags.map((tag) => {
|
<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={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search tags..."
|
||||||
|
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-72 space-y-1 overflow-y-auto p-2">
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
const selected = selectedTagIds.includes(tag.id);
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -156,6 +179,11 @@ function FilterTagMultiSelect({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{filteredTags.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
No tags found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
@@ -259,20 +287,18 @@ export default function TimesheetFilterBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded((current) => !current)}
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
aria-label={isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}
|
aria-label={isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}
|
||||||
className={`relative inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition-colors sm:w-auto sm:gap-2 sm:px-3 ${
|
className={`relative inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition-colors ${
|
||||||
isExpanded || hasActiveFilters
|
isExpanded || hasActiveFilters
|
||||||
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300"
|
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white"
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">{isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}</span>
|
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="absolute -right-1 -top-1 z-10 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-600 px-1 text-[10px] font-semibold leading-none text-white dark:bg-sky-500 sm:static sm:z-auto sm:h-auto sm:min-w-5 sm:px-1.5 sm:text-[11px] sm:leading-normal">
|
<span className="absolute -right-1 -top-1 z-10 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-600 px-1 text-[10px] font-semibold leading-none text-white dark:bg-sky-500">
|
||||||
{activeChips.length}
|
{activeChips.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className={`hidden h-4 w-4 transition-transform sm:inline ${isExpanded ? "rotate-180" : ""}`} />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -290,14 +316,13 @@ export default function TimesheetFilterBar({
|
|||||||
}}
|
}}
|
||||||
disabled={!hasActiveFilters}
|
disabled={!hasActiveFilters}
|
||||||
aria-label={labels?.clear || "Clear"}
|
aria-label={labels?.clear || "Clear"}
|
||||||
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition sm:w-auto sm:gap-2 sm:px-3 ${
|
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition ${
|
||||||
hasActiveFilters
|
hasActiveFilters
|
||||||
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
|
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
|
||||||
: "border-slate-200 bg-white text-slate-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
: "border-slate-200 bg-white text-slate-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">{labels?.clear || "Clear"}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +331,7 @@ export default function TimesheetFilterBar({
|
|||||||
{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-800">
|
||||||
<div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
|
<div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||||
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From"}>
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From date"}>
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={draftFilters.startedAfter}
|
value={draftFilters.startedAfter}
|
||||||
onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))}
|
onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))}
|
||||||
@@ -315,7 +340,7 @@ export default function TimesheetFilterBar({
|
|||||||
/>
|
/>
|
||||||
</MiniFilterBlock>
|
</MiniFilterBlock>
|
||||||
|
|
||||||
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customTo || "To"}>
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customTo || "To date"}>
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={draftFilters.startedBefore}
|
value={draftFilters.startedBefore}
|
||||||
onChange={(value) => setDraftFilters((current) => ({ ...current, startedBefore: value }))}
|
onChange={(value) => setDraftFilters((current) => ({ ...current, startedBefore: value }))}
|
||||||
|
|||||||
147
src/components/ui/SearchableSelect.tsx
Normal file
147
src/components/ui/SearchableSelect.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { Input } from "./input";
|
||||||
|
|
||||||
|
export interface SearchableSelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
searchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchableSelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: SearchableSelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchableSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "",
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
buttonClassName = "",
|
||||||
|
}: SearchableSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return options;
|
||||||
|
return options.filter((option) =>
|
||||||
|
`${option.label} ${option.searchText || ""}`.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node) &&
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !buttonRef.current) return;
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const dropdownHeight = 320;
|
||||||
|
const shouldOpenUp = spaceBelow < dropdownHeight && rect.top > spaceBelow;
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: "fixed",
|
||||||
|
top: shouldOpenUp ? `${rect.top - 4}px` : `${rect.bottom + 4}px`,
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
transform: shouldOpenUp ? "translateY(-100%)" : "none",
|
||||||
|
zIndex: 99999,
|
||||||
|
});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && setIsOpen((current) => !current)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 outline-none transition focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 ${buttonClassName}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selected?.label || placeholder}</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle}
|
||||||
|
className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<div className="border-b border-slate-100 p-2 dark:border-slate-700">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-9 pl-9"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto py-1">
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition hover:bg-slate-100 dark:hover:bg-slate-700 ${
|
||||||
|
option.value === value
|
||||||
|
? "bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
|
||||||
|
: "text-slate-700 dark:text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
{option.value === value && <Check className="h-4 w-4 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredOptions.length === 0 && (
|
||||||
|
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">No results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
src/lib/permissions.ts
Normal file
197
src/lib/permissions.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
export type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
||||||
|
export type ProjectRole = "manager" | "member" | string;
|
||||||
|
|
||||||
|
export const WORKSPACE_VIEW = "workspace.view";
|
||||||
|
export const WORKSPACE_EDIT = "workspace.edit";
|
||||||
|
export const WORKSPACE_DELETE = "workspace.delete";
|
||||||
|
export const WORKSPACE_MEMBERS_VIEW = "workspace.members.view";
|
||||||
|
export const WORKSPACE_MEMBERS_ADD = "workspace.members.add";
|
||||||
|
export const WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove";
|
||||||
|
export const WORKSPACE_MEMBERS_CHANGE_ROLE = "workspace.members.change_role";
|
||||||
|
export const CLIENTS_VIEW = "clients.view";
|
||||||
|
export const CLIENTS_CREATE = "clients.create";
|
||||||
|
export const CLIENTS_EDIT = "clients.edit";
|
||||||
|
export const CLIENTS_DELETE = "clients.delete";
|
||||||
|
export const TAGS_VIEW = "tags.view";
|
||||||
|
export const TAGS_CREATE = "tags.create";
|
||||||
|
export const TAGS_EDIT = "tags.edit";
|
||||||
|
export const TAGS_DELETE = "tags.delete";
|
||||||
|
export const PROJECTS_VIEW = "projects.view";
|
||||||
|
export const PROJECTS_CREATE = "projects.create";
|
||||||
|
export const PROJECTS_EDIT = "projects.edit";
|
||||||
|
export const PROJECTS_DELETE = "projects.delete";
|
||||||
|
export const PROJECTS_ARCHIVE = "projects.archive";
|
||||||
|
export const PROJECT_MEMBERS_VIEW = "project_members.view";
|
||||||
|
export const PROJECT_MEMBERS_ADD = "project_members.add";
|
||||||
|
export const PROJECT_MEMBERS_REMOVE = "project_members.remove";
|
||||||
|
export const PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role";
|
||||||
|
export const TIME_ENTRIES_VIEW_OWN = "time_entries.view_own";
|
||||||
|
export const TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own";
|
||||||
|
|
||||||
|
export type WorkspaceCapability =
|
||||||
|
| typeof WORKSPACE_VIEW
|
||||||
|
| typeof WORKSPACE_EDIT
|
||||||
|
| typeof WORKSPACE_DELETE
|
||||||
|
| typeof WORKSPACE_MEMBERS_VIEW
|
||||||
|
| typeof WORKSPACE_MEMBERS_ADD
|
||||||
|
| typeof WORKSPACE_MEMBERS_REMOVE
|
||||||
|
| typeof WORKSPACE_MEMBERS_CHANGE_ROLE
|
||||||
|
| typeof CLIENTS_VIEW
|
||||||
|
| typeof CLIENTS_CREATE
|
||||||
|
| typeof CLIENTS_EDIT
|
||||||
|
| typeof CLIENTS_DELETE
|
||||||
|
| typeof TAGS_VIEW
|
||||||
|
| typeof TAGS_CREATE
|
||||||
|
| typeof TAGS_EDIT
|
||||||
|
| typeof TAGS_DELETE
|
||||||
|
| typeof PROJECTS_VIEW
|
||||||
|
| typeof PROJECTS_CREATE
|
||||||
|
| typeof PROJECTS_EDIT
|
||||||
|
| typeof PROJECTS_DELETE
|
||||||
|
| typeof PROJECTS_ARCHIVE
|
||||||
|
| typeof PROJECT_MEMBERS_VIEW
|
||||||
|
| typeof PROJECT_MEMBERS_ADD
|
||||||
|
| typeof PROJECT_MEMBERS_REMOVE
|
||||||
|
| typeof PROJECT_MEMBERS_CHANGE_ROLE
|
||||||
|
| typeof TIME_ENTRIES_VIEW_OWN
|
||||||
|
| typeof TIME_ENTRIES_MANAGE_OWN;
|
||||||
|
|
||||||
|
const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
|
||||||
|
owner: new Set([
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_DELETE,
|
||||||
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
|
WORKSPACE_MEMBERS_ADD,
|
||||||
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
CLIENTS_CREATE,
|
||||||
|
CLIENTS_EDIT,
|
||||||
|
CLIENTS_DELETE,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
TAGS_EDIT,
|
||||||
|
TAGS_DELETE,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_DELETE,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
]),
|
||||||
|
admin: new Set([
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
|
WORKSPACE_MEMBERS_ADD,
|
||||||
|
WORKSPACE_MEMBERS_REMOVE,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
CLIENTS_CREATE,
|
||||||
|
CLIENTS_EDIT,
|
||||||
|
CLIENTS_DELETE,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
TAGS_EDIT,
|
||||||
|
TAGS_DELETE,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_DELETE,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
]),
|
||||||
|
member: new Set([
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
TAGS_VIEW,
|
||||||
|
TAGS_CREATE,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
|
]),
|
||||||
|
guest: new Set([
|
||||||
|
WORKSPACE_VIEW,
|
||||||
|
CLIENTS_VIEW,
|
||||||
|
TAGS_VIEW,
|
||||||
|
PROJECTS_VIEW,
|
||||||
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_MANAGER_CAPABILITIES = new Set<WorkspaceCapability>([
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECT_MEMBERS_VIEW,
|
||||||
|
PROJECT_MEMBERS_ADD,
|
||||||
|
PROJECT_MEMBERS_REMOVE,
|
||||||
|
PROJECT_MEMBERS_CHANGE_ROLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const canWorkspace = (
|
||||||
|
role: WorkspaceRole | null | undefined,
|
||||||
|
capability: WorkspaceCapability,
|
||||||
|
) => {
|
||||||
|
if (!role) return 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 canChangeWorkspaceMember = ({
|
||||||
|
actorRole,
|
||||||
|
actorUserId,
|
||||||
|
targetRole,
|
||||||
|
targetUserId,
|
||||||
|
ownerUserId,
|
||||||
|
newRole,
|
||||||
|
}: {
|
||||||
|
actorRole: WorkspaceRole | null | undefined;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
targetRole: WorkspaceRole;
|
||||||
|
targetUserId?: string | null;
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
newRole?: WorkspaceRole;
|
||||||
|
}) => {
|
||||||
|
if (!actorRole || !actorUserId || !targetUserId) return false;
|
||||||
|
if (actorUserId === targetUserId) return false;
|
||||||
|
|
||||||
|
const targetIsCanonicalOwner = !!ownerUserId && targetUserId === ownerUserId;
|
||||||
|
if (actorRole === "admin") {
|
||||||
|
if (targetRole === "owner" || targetIsCanonicalOwner) return false;
|
||||||
|
if (newRole === "owner") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actorRole === "owner") {
|
||||||
|
if (targetIsCanonicalOwner) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
@@ -324,6 +324,8 @@ export const en = {
|
|||||||
create: "Create Tag",
|
create: "Create Tag",
|
||||||
createTitle: "Create Tag",
|
createTitle: "Create Tag",
|
||||||
editTitle: "Edit Tag",
|
editTitle: "Edit Tag",
|
||||||
|
deleteTitle: "Delete Tag",
|
||||||
|
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
|
||||||
searchPlaceholder: "Search tags...",
|
searchPlaceholder: "Search tags...",
|
||||||
nameLabel: "Tag Name",
|
nameLabel: "Tag Name",
|
||||||
namePlaceholder: "e.g. Design",
|
namePlaceholder: "e.g. Design",
|
||||||
@@ -338,6 +340,27 @@ export const en = {
|
|||||||
deleteError: "Failed to delete tag.",
|
deleteError: "Failed to delete tag.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
rates: {
|
||||||
|
workspaceSectionTitle: "Workspace User Rates",
|
||||||
|
projectSectionTitle: "Project User Rates",
|
||||||
|
workspaceRate: "Workspace rate",
|
||||||
|
projectOverride: "Project override",
|
||||||
|
inheritsWorkspaceRate: "Inherits workspace rate",
|
||||||
|
noRate: "No rate",
|
||||||
|
hourlyRatePlaceholder: "0.00",
|
||||||
|
currencyPlaceholder: "USD",
|
||||||
|
searchUnitPlaceholder: "Search unit...",
|
||||||
|
removeRate: "Remove rate",
|
||||||
|
workspaceSaveSuccess: "Workspace user rate saved.",
|
||||||
|
workspaceSaveError: "Failed to save workspace user rate.",
|
||||||
|
workspaceRemoveSuccess: "Workspace user rate removed.",
|
||||||
|
workspaceRemoveError: "Failed to remove workspace user rate.",
|
||||||
|
projectSaveSuccess: "Project user rate saved.",
|
||||||
|
projectSaveError: "Failed to save project user rate.",
|
||||||
|
projectRemoveSuccess: "Project user rate removed.",
|
||||||
|
projectRemoveError: "Failed to remove project user rate.",
|
||||||
|
},
|
||||||
|
|
||||||
timesheet: {
|
timesheet: {
|
||||||
title: "Timesheet",
|
title: "Timesheet",
|
||||||
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
|
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
|
||||||
@@ -376,8 +399,8 @@ export const en = {
|
|||||||
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",
|
||||||
customFromLabel: "From",
|
customFromLabel: "From date",
|
||||||
customToLabel: "To",
|
customToLabel: "To date",
|
||||||
allClientsLabel: "All clients",
|
allClientsLabel: "All clients",
|
||||||
allProjectsLabel: "All projects",
|
allProjectsLabel: "All projects",
|
||||||
allTagsLabel: "All tags",
|
allTagsLabel: "All tags",
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ export const fa = {
|
|||||||
create: "ایجاد تگ",
|
create: "ایجاد تگ",
|
||||||
createTitle: "ایجاد تگ",
|
createTitle: "ایجاد تگ",
|
||||||
editTitle: "ویرایش تگ",
|
editTitle: "ویرایش تگ",
|
||||||
|
deleteTitle: "حذف تگ",
|
||||||
|
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
|
||||||
searchPlaceholder: "جستوجوی تگها...",
|
searchPlaceholder: "جستوجوی تگها...",
|
||||||
nameLabel: "نام تگ",
|
nameLabel: "نام تگ",
|
||||||
namePlaceholder: "مثلاً طراحی",
|
namePlaceholder: "مثلاً طراحی",
|
||||||
@@ -335,6 +337,27 @@ export const fa = {
|
|||||||
deleteError: "حذف تگ با خطا مواجه شد.",
|
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
rates: {
|
||||||
|
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
||||||
|
projectSectionTitle: "نرخهای کاربران پروژه",
|
||||||
|
workspaceRate: "دستمزد ساعتی",
|
||||||
|
projectOverride: "نرخ اختصاصی پروژه",
|
||||||
|
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
||||||
|
noRate: "بدون نرخ",
|
||||||
|
hourlyRatePlaceholder: "0.00",
|
||||||
|
currencyPlaceholder: "USD",
|
||||||
|
searchUnitPlaceholder: "جستوجوی واحد...",
|
||||||
|
removeRate: "حذف نرخ",
|
||||||
|
workspaceSaveSuccess: "نرخ کاربر ورکاسپیس ذخیره شد.",
|
||||||
|
workspaceSaveError: "ذخیره نرخ کاربر ورکاسپیس با خطا مواجه شد.",
|
||||||
|
workspaceRemoveSuccess: "نرخ کاربر ورکاسپیس حذف شد.",
|
||||||
|
workspaceRemoveError: "حذف نرخ کاربر ورکاسپیس با خطا مواجه شد.",
|
||||||
|
projectSaveSuccess: "نرخ کاربر پروژه ذخیره شد.",
|
||||||
|
projectSaveError: "ذخیره نرخ کاربر پروژه با خطا مواجه شد.",
|
||||||
|
projectRemoveSuccess: "نرخ کاربر پروژه حذف شد.",
|
||||||
|
projectRemoveError: "حذف نرخ کاربر پروژه با خطا مواجه شد.",
|
||||||
|
},
|
||||||
|
|
||||||
timesheet: {
|
timesheet: {
|
||||||
title: "تایمشیت",
|
title: "تایمشیت",
|
||||||
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||||
@@ -373,8 +396,8 @@ export const fa = {
|
|||||||
billable: "قابل صورتحساب",
|
billable: "قابل صورتحساب",
|
||||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||||
clearFilters: "پاک کردن فیلترها",
|
clearFilters: "پاک کردن فیلترها",
|
||||||
customFromLabel: "از",
|
customFromLabel: "از تاریخ",
|
||||||
customToLabel: "تا",
|
customToLabel: "تا تاریخ",
|
||||||
allClientsLabel: "همه مشتریها",
|
allClientsLabel: "همه مشتریها",
|
||||||
allProjectsLabel: "همه پروژهها",
|
allProjectsLabel: "همه پروژهها",
|
||||||
allTagsLabel: "همه تگها",
|
allTagsLabel: "همه تگها",
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { useEffect, useState } from "react"
|
|||||||
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
|
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
|
||||||
import { useWorkspace } from "../context/WorkspaceContext"
|
import { useWorkspace } from "../context/WorkspaceContext"
|
||||||
import { useTranslation } from "../hooks/useTranslation"
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
|
import {
|
||||||
|
CLIENTS_CREATE,
|
||||||
|
CLIENTS_DELETE,
|
||||||
|
CLIENTS_EDIT,
|
||||||
|
canWorkspace,
|
||||||
|
} from "../lib/permissions"
|
||||||
import { type Client } from "../types/client"
|
import { type Client } from "../types/client"
|
||||||
import { getClients } from "../api/clients"
|
import { getClients } from "../api/clients"
|
||||||
import CreateClientModal from "../components/CreateClientModal"
|
import CreateClientModal from "../components/CreateClientModal"
|
||||||
@@ -34,6 +40,10 @@ export default function Clients() {
|
|||||||
|
|
||||||
const { t, lang } = useTranslation()
|
const { t, lang } = useTranslation()
|
||||||
const isFa = lang === "fa"
|
const isFa = lang === "fa"
|
||||||
|
const workspaceRole = activeWorkspace?.my_role
|
||||||
|
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
|
||||||
|
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
|
||||||
|
const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE)
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
||||||
@@ -114,10 +124,15 @@ export default function Clients() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2">
|
{canCreateClient && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
size="icon"
|
||||||
|
title={t.clients.addClient}
|
||||||
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
{t.clients.addClient}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -159,7 +174,9 @@ export default function Clients() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(canEditClient || canDeleteClient) && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{canEditClient && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -168,6 +185,8 @@ export default function Clients() {
|
|||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDeleteClient && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -176,7 +195,9 @@ export default function Clients() {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -194,26 +215,32 @@ export default function Clients() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canCreateClient && (
|
||||||
<CreateClientModal
|
<CreateClientModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
workspaceId={activeWorkspace.id}
|
workspaceId={activeWorkspace.id}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEditClient && (
|
||||||
<EditClientModal
|
<EditClientModal
|
||||||
isOpen={!!editClient}
|
isOpen={!!editClient}
|
||||||
onClose={() => setEditClient(null)}
|
onClose={() => setEditClient(null)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
client={editClient}
|
client={editClient}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDeleteClient && (
|
||||||
<DeleteClientModal
|
<DeleteClientModal
|
||||||
isOpen={!!deleteClient}
|
isOpen={!!deleteClient}
|
||||||
onClose={() => setDeleteClient(null)}
|
onClose={() => setDeleteClient(null)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
client={deleteClient}
|
client={deleteClient}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
|||||||
import { useAppContext } from "../context/AppContext";
|
import { useAppContext } from "../context/AppContext";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
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";
|
||||||
@@ -58,6 +59,7 @@ export default function ProjectCreate() {
|
|||||||
const { user } = useAppContext();
|
const { user } = useAppContext();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
const currentUserId = user?.id || "";
|
const currentUserId = user?.id || "";
|
||||||
|
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
||||||
|
|
||||||
// Project Detail States
|
// Project Detail States
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -91,6 +93,13 @@ export default function ProjectCreate() {
|
|||||||
|
|
||||||
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1;
|
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspace && !canCreateProject) {
|
||||||
|
toast.error("You do not have permission to create projects.");
|
||||||
|
navigate("/projects");
|
||||||
|
}
|
||||||
|
}, [activeWorkspace, canCreateProject, navigate]);
|
||||||
|
|
||||||
useBlocker(({ currentLocation, nextLocation }) => {
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
|
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
|
||||||
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
|||||||
import { useAppContext } from "../context/AppContext";
|
import { useAppContext } from "../context/AppContext";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
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";
|
||||||
@@ -59,6 +60,7 @@ export default function ProjectEdit() {
|
|||||||
const { user } = useAppContext();
|
const { user } = useAppContext();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
const currentUserId = user?.id || "";
|
const currentUserId = user?.id || "";
|
||||||
|
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -89,6 +91,13 @@ export default function ProjectEdit() {
|
|||||||
|
|
||||||
const hasUnsavedChanges = name.trim() !== "";
|
const hasUnsavedChanges = name.trim() !== "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspace && !canEditProject) {
|
||||||
|
toast.error("You do not have permission to edit projects.");
|
||||||
|
navigate("/projects");
|
||||||
|
}
|
||||||
|
}, [activeWorkspace, canEditProject, navigate]);
|
||||||
|
|
||||||
useBlocker(({ currentLocation, nextLocation }) => {
|
useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||||
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
|
||||||
@@ -124,7 +133,6 @@ export default function ProjectEdit() {
|
|||||||
}));
|
}));
|
||||||
setMembers(mappedMembers);
|
setMembers(mappedMembers);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetchWorkspaceMemberships({
|
const res = await fetchWorkspaceMemberships({
|
||||||
workspace: activeWorkspace.id,
|
workspace: activeWorkspace.id,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
@@ -594,6 +602,7 @@ export default function ProjectEdit() {
|
|||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,22 @@ import { Card, CardHeader, CardTitle, CardContent } 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";
|
||||||
|
import {
|
||||||
|
PROJECTS_ARCHIVE,
|
||||||
|
PROJECTS_CREATE,
|
||||||
|
PROJECTS_DELETE,
|
||||||
|
PROJECTS_EDIT,
|
||||||
|
canWorkspace,
|
||||||
|
} from "../lib/permissions";
|
||||||
|
|
||||||
export const Projects: React.FC = () => {
|
export const Projects: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const workspaceRole = activeWorkspace?.my_role;
|
||||||
|
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
|
||||||
|
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
||||||
|
const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE);
|
||||||
|
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
||||||
|
|
||||||
const [projects, setProjects] = useState<any[]>([]);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -117,6 +129,7 @@ export const Projects: React.FC = () => {
|
|||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
|
{canArchiveProject && (
|
||||||
<Button
|
<Button
|
||||||
variant={isArchived ? "default" : "secondary"}
|
variant={isArchived ? "default" : "secondary"}
|
||||||
onClick={() => setIsArchived(!isArchived)}
|
onClick={() => setIsArchived(!isArchived)}
|
||||||
@@ -125,13 +138,17 @@ export const Projects: React.FC = () => {
|
|||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{canCreateProject && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
size="icon"
|
||||||
|
className="shadow-sm"
|
||||||
|
title={t.projects?.createNew || 'Create New'}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{t.projects?.createNew || 'Create New'}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,7 +189,9 @@ export const Projects: React.FC = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(canEditProject || canDeleteProject) && (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{canEditProject && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -182,7 +201,9 @@ export const Projects: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDeleteProject && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -192,7 +213,9 @@ export const Projects: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -216,14 +239,14 @@ export const Projects: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{isCreateModalOpen && (
|
{canCreateProject && isCreateModalOpen && (
|
||||||
<ProjectCreateModal
|
<ProjectCreateModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingProject && (
|
{canEditProject && editingProject && (
|
||||||
<ProjectEditModal
|
<ProjectEditModal
|
||||||
project={editingProject}
|
project={editingProject}
|
||||||
isOpen={!!editingProject}
|
isOpen={!!editingProject}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
|||||||
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
|
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import { TAGS_CREATE, TAGS_DELETE, TAGS_EDIT, canWorkspace } from "../lib/permissions";
|
||||||
import FilterBar from "../components/FilterBar";
|
import FilterBar from "../components/FilterBar";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { Pagination } from "../components/Pagination";
|
import { Pagination } from "../components/Pagination";
|
||||||
@@ -17,6 +18,10 @@ const DEFAULT_COLOR = "#3B82F6";
|
|||||||
export default function Tags() {
|
export default function Tags() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
const workspaceRole = activeWorkspace?.my_role;
|
||||||
|
const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE);
|
||||||
|
const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT);
|
||||||
|
const canDeleteTag = canWorkspace(workspaceRole, TAGS_DELETE);
|
||||||
|
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -31,6 +36,7 @@ export default function Tags() {
|
|||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
|
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; tag: Tag | null }>({ isOpen: false, tag: null });
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
|
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
|
||||||
@@ -145,10 +151,11 @@ export default function Tags() {
|
|||||||
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
|
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
|
{canCreateTag && (
|
||||||
|
<Button onClick={openCreateModal} size="icon" className="shadow-sm" title={t.tags?.create || "Create Tag"}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t.tags?.create || "Create Tag"}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -164,25 +171,40 @@ export default function Tags() {
|
|||||||
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
|
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Card key={tag.id} className="dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
<Card key={tag.id} className="overflow-hidden shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4 px-6">
|
<CardContent className="flex h-full flex-col gap-4 p-5">
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-700" style={{ backgroundColor: tag.color || DEFAULT_COLOR }} />
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-9 w-9 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
|
||||||
|
style={{ backgroundColor: tag.color || DEFAULT_COLOR }}
|
||||||
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="text-lg truncate text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">{tag.color || DEFAULT_COLOR}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{(canEditTag || canDeleteTag) && (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{canEditTag && (
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
|
)}
|
||||||
|
{canDeleteTag && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDeleteModal({ isOpen: true, tag })}
|
||||||
|
title={t.actions?.delete || "Delete"}
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -237,6 +259,37 @@ export default function Tags() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{deleteModal.tag && (
|
||||||
|
<Modal
|
||||||
|
isOpen={deleteModal.isOpen}
|
||||||
|
onClose={() => setDeleteModal({ isOpen: false, tag: null })}
|
||||||
|
title={t.tags?.deleteTitle || "Delete Tag"}
|
||||||
|
maxWidth="max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteModal({ isOpen: false, tag: null })}>
|
||||||
|
{t.actions?.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteModal.tag) return;
|
||||||
|
void handleDelete(deleteModal.tag);
|
||||||
|
setDeleteModal({ isOpen: false, tag: null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.actions?.delete || "Delete"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||||
|
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) ||
|
||||||
|
`Are you sure you want to delete "${deleteModal.tag.name}"?`}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getProjects, type Project } from "../api/projects";
|
import { getProjects, type Project } from "../api/projects";
|
||||||
@@ -178,6 +178,15 @@ const formatDateTime = (value: string, locale: "en" | "fa") => {
|
|||||||
}).format(parsed);
|
}).format(parsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDateOnly = (value: string, locale: "en" | "fa") => {
|
||||||
|
const parsed = parseApiDateTime(value);
|
||||||
|
if (!parsed) return value;
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
}).format(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDuration = (entry: TimeEntry, now = Date.now()) => {
|
const formatDuration = (entry: TimeEntry, now = Date.now()) => {
|
||||||
const start = parseApiDateTime(entry.start_time)?.getTime();
|
const start = parseApiDateTime(entry.start_time)?.getTime();
|
||||||
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now;
|
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now;
|
||||||
@@ -533,6 +542,7 @@ function TagMultiSelect({
|
|||||||
portalOwnerId?: string;
|
portalOwnerId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = 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);
|
||||||
@@ -587,8 +597,18 @@ function TagMultiSelect({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
||||||
const joinedSelectedLabels = selectedLabels.join(" | ");
|
const joinedSelectedLabels = selectedLabels.join(" | ");
|
||||||
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||||
|
const filteredTags = normalizedSearch
|
||||||
|
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||||||
|
: tags;
|
||||||
const buttonLabel = compact
|
const buttonLabel = compact
|
||||||
? selectedTags.length > 0
|
? selectedTags.length > 0
|
||||||
? joinedSelectedLabels
|
? joinedSelectedLabels
|
||||||
@@ -638,8 +658,21 @@ function TagMultiSelect({
|
|||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
<>
|
||||||
{tags.map((tag) => {
|
<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={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search tags..."
|
||||||
|
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-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
const selected = selectedTags.includes(tag.id);
|
const selected = selectedTags.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -659,8 +692,14 @@ function TagMultiSelect({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{filteredTags.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
No tags found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
@@ -1527,7 +1566,7 @@ function MobileRecordedEntryCard({
|
|||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
||||||
<span>{formatDateTime(entry.start_time, "en")}</span>
|
<span>{formatDateOnly(entry.start_time, "en")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1646,8 +1685,12 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
const [timerDraft, setTimerDraft] = useState<TimerDraftState>(EMPTY_TIMER_DRAFT);
|
const [timerDraft, setTimerDraft] = useState<TimerDraftState>(EMPTY_TIMER_DRAFT);
|
||||||
const [isStartingTimer, setIsStartingTimer] = useState(false);
|
const [isStartingTimer, setIsStartingTimer] = useState(false);
|
||||||
|
const desktopTimerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileTimerRef = useRef<HTMLDivElement>(null);
|
||||||
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||||
const timerSaveTimeoutRef = useRef<number | null>(null);
|
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||||
|
const isTimerSavingRef = useRef(false);
|
||||||
|
const timerEditorOwnerId = "running-timer-editor";
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
@@ -1793,29 +1836,41 @@ export default function Timesheet() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runningEntry) {
|
if (!runningEntry) {
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||||
|
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||||||
setTimerDraft(EMPTY_TIMER_DRAFT);
|
setTimerDraft(EMPTY_TIMER_DRAFT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDraft = buildTimerDraftState(runningEntry);
|
const nextDraft = buildTimerDraftState(runningEntry);
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
|
timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
|
||||||
|
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||||||
setTimerDraft(nextDraft);
|
setTimerDraft(nextDraft);
|
||||||
}, [runningEntry]);
|
}, [runningEntry]);
|
||||||
|
|
||||||
useEffect(() => {
|
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
|
||||||
|
if (!(target instanceof Node)) return false;
|
||||||
|
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
|
||||||
|
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
|
||||||
|
}, [timerEditorOwnerId]);
|
||||||
|
|
||||||
|
const commitTimerDraft = useCallback(async () => {
|
||||||
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
|
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
|
||||||
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
||||||
|
|
||||||
if (!runningEntry) return;
|
if (!runningEntry) return false;
|
||||||
|
|
||||||
const currentSignature = serializeTimerDraft(timerDraft);
|
const currentSignature = serializeTimerDraft(timerDraft);
|
||||||
if (currentSignature === timerDraftSignatureRef.current) return;
|
if (currentSignature === timerDraftSignatureRef.current) {
|
||||||
|
return false;
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timerSaveTimeoutRef.current = window.setTimeout(async () => {
|
if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTimerSavingRef.current = true;
|
||||||
|
pendingTimerSignatureRef.current = currentSignature;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||||||
description: timerDraft.description.trim(),
|
description: timerDraft.description.trim(),
|
||||||
@@ -1825,30 +1880,43 @@ export default function Timesheet() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(syncedDraft);
|
const syncedSignature = serializeTimerDraft(syncedDraft);
|
||||||
|
timerDraftSignatureRef.current = syncedSignature;
|
||||||
|
pendingTimerSignatureRef.current = syncedSignature;
|
||||||
setTimerDraft(syncedDraft);
|
setTimerDraft(syncedDraft);
|
||||||
setActiveRunningEntry(updatedEntry);
|
setActiveRunningEntry(updatedEntry);
|
||||||
toast.success(saveSuccessText);
|
toast.success(saveSuccessText);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
pendingTimerSignatureRef.current = null;
|
||||||
toast.error(saveErrorText);
|
toast.error(saveErrorText);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isTimerSavingRef.current = false;
|
||||||
}
|
}
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (!runningEntry) return;
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
}
|
if (isInsideTimerEditorContext(event.target)) return;
|
||||||
|
void commitTimerDraft();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
};
|
||||||
|
}, [commitTimerDraft, isInsideTimerEditorContext, runningEntry]);
|
||||||
|
|
||||||
|
const handleTimerBlurCapture = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (isInsideTimerEditorContext(document.activeElement)) return;
|
||||||
|
void commitTimerDraft();
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeCreateModal = () => {
|
const closeCreateModal = () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
@@ -2066,10 +2134,6 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsDiscardingTimer(true);
|
setIsDiscardingTimer(true);
|
||||||
if (timerSaveTimeoutRef.current) {
|
|
||||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
|
||||||
timerSaveTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteTimeEntry(discardTimerModal.entry.id);
|
await deleteTimeEntry(discardTimerModal.entry.id);
|
||||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||||
@@ -2099,7 +2163,11 @@ export default function Timesheet() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 hidden overflow-x-auto border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block">
|
<div
|
||||||
|
ref={desktopTimerRef}
|
||||||
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
|
className="mb-4 hidden overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block"
|
||||||
|
>
|
||||||
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
||||||
<div className="min-w-[360px] flex-1">
|
<div className="min-w-[360px] flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -2122,6 +2190,7 @@ export default function Timesheet() {
|
|||||||
className="min-w-[170px]"
|
className="min-w-[170px]"
|
||||||
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>
|
||||||
|
|
||||||
@@ -2135,6 +2204,7 @@ export default function Timesheet() {
|
|||||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||||
title={t.tags?.title || "Tags"}
|
title={t.tags?.title || "Tags"}
|
||||||
compact
|
compact
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2155,21 +2225,38 @@ export default function Timesheet() {
|
|||||||
<div className="ms-2 flex shrink-0 items-center gap-2">
|
<div className="ms-2 flex shrink-0 items-center gap-2">
|
||||||
{runningEntry ? (
|
{runningEntry ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-12 rounded-md px-5 text-xs font-semibold uppercase">
|
<Button
|
||||||
{t.timesheet?.stopTimer || "Stop"}
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleStop(runningEntry)}
|
||||||
|
className="h-12 w-12 rounded-md"
|
||||||
|
title={t.timesheet?.stopTimer || "Stop"}
|
||||||
|
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
onClick={openDiscardTimerModal}
|
onClick={openDiscardTimerModal}
|
||||||
disabled={isDiscardingTimer}
|
disabled={isDiscardingTimer}
|
||||||
className="h-12 rounded-md px-5 text-xs font-semibold uppercase"
|
className="h-12 w-12 rounded-md"
|
||||||
|
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
|
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
>
|
>
|
||||||
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
|
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-12 rounded-md px-5 text-xs font-semibold uppercase">
|
<Button
|
||||||
{isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")}
|
onClick={() => void handleStartTimer()}
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
size="icon"
|
||||||
|
className="h-12 w-12 rounded-md"
|
||||||
|
title={t.timesheet?.startTimer || "Start"}
|
||||||
|
aria-label={t.timesheet?.startTimer || "Start"}
|
||||||
|
>
|
||||||
|
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2177,7 +2264,11 @@ export default function Timesheet() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:hidden">
|
<div
|
||||||
|
ref={mobileTimerRef}
|
||||||
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
|
className="mb-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:hidden"
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
value={timerDraft.description}
|
value={timerDraft.description}
|
||||||
@@ -2198,6 +2289,7 @@ export default function Timesheet() {
|
|||||||
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">
|
||||||
@@ -2216,6 +2308,7 @@ export default function Timesheet() {
|
|||||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||||
title={t.tags?.title || "Tags"}
|
title={t.tags?.title || "Tags"}
|
||||||
compact
|
compact
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
@@ -2230,21 +2323,38 @@ export default function Timesheet() {
|
|||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{runningEntry ? (
|
{runningEntry ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-10 rounded-md px-4 text-xs font-semibold uppercase">
|
<Button
|
||||||
{t.timesheet?.stopTimer || "Stop"}
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleStop(runningEntry)}
|
||||||
|
className="h-10 w-10 rounded-md"
|
||||||
|
title={t.timesheet?.stopTimer || "Stop"}
|
||||||
|
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
onClick={openDiscardTimerModal}
|
onClick={openDiscardTimerModal}
|
||||||
disabled={isDiscardingTimer}
|
disabled={isDiscardingTimer}
|
||||||
className="h-10 rounded-md px-4 text-xs font-semibold uppercase"
|
className="h-10 w-10 rounded-md"
|
||||||
|
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
|
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
>
|
>
|
||||||
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
|
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-10 rounded-md px-4 text-xs font-semibold uppercase">
|
<Button
|
||||||
{isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")}
|
onClick={() => void handleStartTimer()}
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 rounded-md"
|
||||||
|
title={t.timesheet?.startTimer || "Start"}
|
||||||
|
aria-label={t.timesheet?.startTimer || "Start"}
|
||||||
|
>
|
||||||
|
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2266,8 +2376,8 @@ export default function Timesheet() {
|
|||||||
client: t.projects?.clientLabel || "Client",
|
client: t.projects?.clientLabel || "Client",
|
||||||
tags: t.tags?.title || "Tags",
|
tags: t.tags?.title || "Tags",
|
||||||
clear: extendedTimesheet.clearFilters || "Clear filters",
|
clear: extendedTimesheet.clearFilters || "Clear filters",
|
||||||
customFrom: extendedTimesheet.customFromLabel || "From",
|
customFrom: extendedTimesheet.customFromLabel || "From date",
|
||||||
customTo: extendedTimesheet.customToLabel || "To",
|
customTo: extendedTimesheet.customToLabel || "To date",
|
||||||
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
||||||
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
||||||
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
||||||
|
|||||||
@@ -178,13 +178,13 @@ export default function WorkspaceCreate() {
|
|||||||
const isFirstOwner = true;
|
const isFirstOwner = true;
|
||||||
|
|
||||||
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 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
|
||||||
{t.workspace?.createTitle || "Create Workspace"}
|
{t.workspace?.createTitle || "Create Workspace"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
|
||||||
<div className="w-full lg:w-1/3 lg:max-w-md flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
<div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
@@ -228,7 +228,7 @@ export default function WorkspaceCreate() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
||||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||||
{ t.workspace?.members || "Members" }
|
{ t.workspace?.members || "Members" }
|
||||||
@@ -322,7 +322,7 @@ export default function WorkspaceCreate() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لیست اعضا (با قابلیت اسکرول) */}
|
{/* لیست اعضا (با قابلیت اسکرول) */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30">
|
<div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
return (
|
return (
|
||||||
<div key={m.localId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors">
|
<div key={m.localId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
|
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
|
||||||
|
import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions';
|
||||||
|
|
||||||
export default function WorkspaceDetail() {
|
export default function WorkspaceDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -44,7 +45,8 @@ export default function WorkspaceDetail() {
|
|||||||
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
|
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canEdit = workspace.my_role === 'owner' || workspace.my_role === 'admin';
|
const canEdit = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
||||||
|
const canDelete = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
@@ -75,7 +77,7 @@ export default function WorkspaceDetail() {
|
|||||||
>
|
>
|
||||||
<Edit2 className="h-5 w-5" />
|
<Edit2 className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
{workspace.my_role === 'owner' && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
|
className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../hooks/useTranslation';
|
|||||||
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
|
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
|
||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
|
||||||
import {
|
import {
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
addWorkspaceMembership,
|
addWorkspaceMembership,
|
||||||
@@ -14,11 +15,20 @@ import {
|
|||||||
} from '../api/workspaces';
|
} from '../api/workspaces';
|
||||||
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
|
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
|
||||||
import { useAppContext } from '../context/AppContext';
|
import { useAppContext } from '../context/AppContext';
|
||||||
|
import {
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
WORKSPACE_MEMBERS_ADD,
|
||||||
|
WORKSPACE_MEMBERS_CHANGE_ROLE,
|
||||||
|
canChangeWorkspaceMember,
|
||||||
|
canWorkspace,
|
||||||
|
type WorkspaceRole,
|
||||||
|
} from '../lib/permissions';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { InfiniteScroll } from '../components/InfiniteScroll';
|
import { InfiniteScroll } from '../components/InfiniteScroll';
|
||||||
import { Select } from '../components/ui/Select';
|
import { Select } from '../components/ui/Select';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
||||||
|
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
|
||||||
|
|
||||||
const toEnglishDigits = (str: string) => {
|
const toEnglishDigits = (str: string) => {
|
||||||
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
||||||
@@ -45,10 +55,12 @@ export default function EditWorkspace() {
|
|||||||
// Workspace Info States
|
// Workspace Info States
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [myRole, setMyRole] = useState<'owner' | 'admin' | 'member' | 'guest'>('member');
|
const [myRole, setMyRole] = useState<WorkspaceRole>('member');
|
||||||
const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>('');
|
const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [workspaceRates, setWorkspaceRates] = useState<WorkspaceUserRate[]>([]);
|
||||||
|
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
|
||||||
|
|
||||||
// Members States
|
// Members States
|
||||||
const [members, setMembers] = useState<any[]>([]);
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
@@ -105,6 +117,13 @@ export default function EditWorkspace() {
|
|||||||
if (id) loadData();
|
if (id) loadData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && id && !canWorkspace(myRole, WORKSPACE_EDIT)) {
|
||||||
|
toast.error("You do not have permission to edit this workspace.");
|
||||||
|
navigate(`/workspaces/${id}`);
|
||||||
|
}
|
||||||
|
}, [id, isLoading, myRole, navigate]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -114,10 +133,16 @@ export default function EditWorkspace() {
|
|||||||
setMyRole(workspaceData.my_role || 'member');
|
setMyRole(workspaceData.my_role || 'member');
|
||||||
setWorkspaceOwnerId(workspaceData.owner || '');
|
setWorkspaceOwnerId(workspaceData.owner || '');
|
||||||
|
|
||||||
const membersData = await fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 });
|
const [membersData, ratesData, unitsData] = await Promise.all([
|
||||||
|
fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }),
|
||||||
|
getWorkspaceUserRates(id!),
|
||||||
|
getPriceUnits(),
|
||||||
|
]);
|
||||||
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
|
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
|
||||||
|
|
||||||
setMembers(results);
|
setMembers(results);
|
||||||
|
setWorkspaceRates(ratesData.results || []);
|
||||||
|
setPriceUnits(unitsData.results || []);
|
||||||
setOffset(LIMIT);
|
setOffset(LIMIT);
|
||||||
|
|
||||||
// Robust hasMore check: use `.next` if available, otherwise check if array filled the limit
|
// Robust hasMore check: use `.next` if available, otherwise check if array filled the limit
|
||||||
@@ -258,19 +283,26 @@ export default function EditWorkspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canManageMembers = myRole === 'owner' || myRole === 'admin';
|
const canManageMembers = canWorkspace(myRole, WORKSPACE_MEMBERS_CHANGE_ROLE);
|
||||||
const isFirstOwner = currentUserId === workspaceOwnerId;
|
const isFirstOwner = currentUserId === workspaceOwnerId;
|
||||||
|
|
||||||
|
const roleOptions = (allowOwner: boolean) => [
|
||||||
|
...(allowOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
|
||||||
|
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
|
||||||
|
{ value: "member", label: t.workspace?.roles?.member || "Member" },
|
||||||
|
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
|
||||||
|
];
|
||||||
|
|
||||||
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
|
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
|
||||||
|
|
||||||
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 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
|
||||||
{t.workspace?.editTitle || "Edit Workspace"}
|
{t.workspace?.editTitle || "Edit Workspace"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
|
||||||
<div className="w-full lg:w-1/3 lg:max-w-md flex flex-col shrink-0 overflow-y-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
<div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
@@ -306,13 +338,13 @@ export default function EditWorkspace() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full lg:w-2/3 flex-1 flex flex-col min-h-100 lg:min-h-0 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
||||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||||
{ t.workspace?.members || "Members" }
|
{ t.workspace?.members || "Members" }
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{canManageMembers && (
|
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -357,10 +389,7 @@ export default function EditWorkspace() {
|
|||||||
value={newMemberRole}
|
value={newMemberRole}
|
||||||
onChange={(val) => setNewMemberRole(val as any)}
|
onChange={(val) => setNewMemberRole(val as any)}
|
||||||
options={[
|
options={[
|
||||||
...(isFirstOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
|
...roleOptions(isFirstOwner),
|
||||||
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
|
|
||||||
{ value: "member", label: t.workspace?.roles?.member || "Member" },
|
|
||||||
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
|
|
||||||
]}
|
]}
|
||||||
className="flex-1 sm:flex-none"
|
className="flex-1 sm:flex-none"
|
||||||
buttonClassName="w-full sm:w-[110px] px-3 py-1.5 text-sm"
|
buttonClassName="w-full sm:w-[110px] px-3 py-1.5 text-sm"
|
||||||
@@ -385,7 +414,7 @@ export default function EditWorkspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-3 bg-slate-50/30 dark:bg-slate-900/30">
|
<div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
onLoadMore={loadMoreMembers}
|
onLoadMore={loadMoreMembers}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
@@ -395,10 +424,17 @@ export default function EditWorkspace() {
|
|||||||
>
|
>
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
|
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
|
||||||
const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner');
|
const canChangeThisUserRole = canChangeWorkspaceMember({
|
||||||
|
actorRole: myRole,
|
||||||
|
actorUserId: currentUserId,
|
||||||
|
targetRole: m.role,
|
||||||
|
targetUserId: m.user?.id,
|
||||||
|
ownerUserId: workspaceOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors">
|
<div key={m.id} className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 shadow-sm transition-colors hover:border-blue-200 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{m.user?.profile_picture ? (
|
{m.user?.profile_picture ? (
|
||||||
<img src={m.user?.profile_picture} alt={m.user?.first_name} className="w-10 h-10 rounded-full object-cover shadow-sm" />
|
<img src={m.user?.profile_picture} alt={m.user?.first_name} className="w-10 h-10 rounded-full object-cover shadow-sm" />
|
||||||
@@ -420,12 +456,7 @@ export default function EditWorkspace() {
|
|||||||
<Select
|
<Select
|
||||||
value={m.role}
|
value={m.role}
|
||||||
onChange={(val) => handleChangeRole(m.id, val)}
|
onChange={(val) => handleChangeRole(m.id, val)}
|
||||||
options={[
|
options={roleOptions(isFirstOwner)}
|
||||||
...(isFirstOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
|
|
||||||
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
|
|
||||||
{ value: "member", label: t.workspace?.roles?.member || "Member" },
|
|
||||||
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
|
|
||||||
]}
|
|
||||||
buttonClassName="w-[110px] px-3 py-1.5 text-sm"
|
buttonClassName="w-[110px] px-3 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -437,7 +468,7 @@ export default function EditWorkspace() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && (
|
{canChangeThisUserRole && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -451,6 +482,20 @@ export default function EditWorkspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-slate-100 pt-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.workspaceRate || "Workspace rate"}
|
||||||
|
</div>
|
||||||
|
<WorkspaceMemberRateFields
|
||||||
|
workspaceId={id!}
|
||||||
|
userId={m.user.id}
|
||||||
|
rate={workspaceRates.find((item) => item.user === m.user.id)}
|
||||||
|
priceUnits={priceUnits}
|
||||||
|
onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
@@ -463,6 +508,7 @@ export default function EditWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { Plus, Trash2, Pencil, ChevronRight } from 'lucide-react';
|
import { Plus, Trash2, Pencil, ChevronRight } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
|
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
|
||||||
import { useAppContext } from '../context/AppContext';
|
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
import {
|
||||||
|
WORKSPACE_DELETE,
|
||||||
|
WORKSPACE_EDIT,
|
||||||
|
canWorkspace,
|
||||||
|
type WorkspaceRole,
|
||||||
|
} from '../lib/permissions';
|
||||||
import FilterBar from '../components/FilterBar';
|
import FilterBar from '../components/FilterBar';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
@@ -12,8 +17,6 @@ import { Card, CardContent, CardTitle } from '../components/ui/card';
|
|||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
|
|
||||||
type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
|
||||||
|
|
||||||
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!role) return null;
|
if (!role) return null;
|
||||||
@@ -46,7 +49,6 @@ export default function Workspaces() {
|
|||||||
const [deleteInput, setDeleteInput] = useState('');
|
const [deleteInput, setDeleteInput] = useState('');
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAppContext();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
@@ -122,10 +124,11 @@ export default function Workspaces() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate('/workspaces/create')}
|
onClick={() => navigate('/workspaces/create')}
|
||||||
className="gap-2 shadow-sm"
|
size="icon"
|
||||||
|
className="shadow-sm"
|
||||||
|
title={t.workspace?.createNew || 'Create New'}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{t.workspace?.createNew || 'Create New'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,8 +149,8 @@ export default function Workspaces() {
|
|||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
{workspaces.map((workspace) => {
|
{workspaces.map((workspace) => {
|
||||||
const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner';
|
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
||||||
const isAdmin = workspace.my_role === 'admin' || isOwner;
|
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||||
@@ -165,7 +168,7 @@ export default function Workspaces() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{isOwner && (
|
{canDeleteWorkspace && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -177,7 +180,7 @@ export default function Workspaces() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{canEditWorkspace && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
Reference in New Issue
Block a user