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 { useState } from "react"
|
||||
import { ThemeProvider } from "./components/ThemeProvider"
|
||||
import { LanguageProvider } from "./components/LanguageProvider"
|
||||
import { Toaster } from "./components/ui/toaster"
|
||||
@@ -21,12 +22,17 @@ import Tags from "./pages/Tags"
|
||||
import Timesheet from "./pages/Timesheet"
|
||||
|
||||
const MainLayout = () => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Navbar />
|
||||
<Navbar onOpenSidebar={() => setMobileSidebarOpen(true)} />
|
||||
|
||||
<main className="flex-1 relative">
|
||||
<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 { Button } from "./ui/button"
|
||||
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 { WorkspaceSelector } from "./WorkspaceSelector"
|
||||
import { toast } from "sonner"
|
||||
import { NotificationBell } from "./notifications/NotificationBell"
|
||||
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 navigate = useNavigate()
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||
@@ -106,12 +110,22 @@ export function Navbar() {
|
||||
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">
|
||||
<div className="flex cursor-pointer items-center gap-2" onClick={() => navigate("/")}>
|
||||
<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("/")}>
|
||||
<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" />
|
||||
{t.title || "Qlockify"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user && <WorkspaceSelector />}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
X,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
@@ -13,7 +14,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
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 { t, lang } = useTranslation();
|
||||
@@ -51,55 +57,110 @@ export const Sidebar = () => {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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 ${
|
||||
isCollapsed ? 'w-20' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<div className={`h-18.25 flex items-center border-b border-slate-200 dark:border-slate-800 shrink-0 transition-all ${
|
||||
isCollapsed ? 'justify-center px-0' : 'justify-between px-6'
|
||||
}`}>
|
||||
{!isCollapsed && (
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-white truncate">
|
||||
{t.title || "Qlockify"}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||
title={isCollapsed ? (t.sidebar?.expand || 'Expand') : (t.sidebar?.collapse || 'Collapse')}
|
||||
const renderNavItems = (mobile = false) =>
|
||||
navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<ToggleIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<Icon size={mobile ? 20 : isCollapsed ? 22 : 18} className="shrink-0" />
|
||||
{(mobile || !isCollapsed) && (
|
||||
<span className="truncate whitespace-nowrap">{item.label}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
});
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto overflow-x-hidden">
|
||||
{navItems.map((item) => {
|
||||
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'
|
||||
return (
|
||||
<>
|
||||
<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 ${
|
||||
isCollapsed ? 'w-20' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<div className={`h-18.25 flex items-center border-b border-slate-200 dark:border-slate-800 shrink-0 transition-all ${
|
||||
isCollapsed ? 'justify-center px-0' : 'justify-between px-6'
|
||||
}`}>
|
||||
{!isCollapsed && (
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-white truncate">
|
||||
{t.title || "Qlockify"}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||
title={isCollapsed ? (t.sidebar?.expand || 'Expand') : (t.sidebar?.collapse || 'Collapse')}
|
||||
>
|
||||
<ToggleIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto overflow-x-hidden">
|
||||
{renderNavItems(false)}
|
||||
</nav>
|
||||
</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"
|
||||
>
|
||||
<Icon size={isCollapsed ? 22 : 18} className="shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<span className="truncate whitespace-nowrap">{item.label}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<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
|
||||
type="button"
|
||||
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"}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Select } from "../ui/Select";
|
||||
import { Input } from "../ui/input";
|
||||
import { TextAreaInput } from "../ui/TextAreaInput";
|
||||
import { toast } from "sonner";
|
||||
import { PROJECTS_CREATE, canWorkspace } from "../../lib/permissions";
|
||||
|
||||
interface ProjectCreateModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,6 +18,7 @@ interface ProjectCreateModalProps {
|
||||
export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [clients, setClients] = useState<any[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -72,6 +74,8 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
||||
</>
|
||||
);
|
||||
|
||||
if (!canCreateProject) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Select } from "../ui/Select";
|
||||
import { Input } from "../ui/input";
|
||||
import { TextAreaInput } from "../ui/TextAreaInput";
|
||||
import { toast } from "sonner";
|
||||
import { PROJECTS_ARCHIVE, PROJECTS_EDIT, canWorkspace } from "../../lib/permissions";
|
||||
|
||||
interface ProjectEditModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,6 +20,8 @@ interface ProjectEditModalProps {
|
||||
export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onClose, project }) => {
|
||||
const { t } = useTranslation();
|
||||
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 [clients, setClients] = useState<any[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -88,19 +91,23 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
||||
|
||||
const footer = (
|
||||
<div className="flex justify-between w-full">
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border ${
|
||||
project?.is_archived
|
||||
? "text-green-600 border-green-600 hover:bg-green-50"
|
||||
: "text-amber-600 border-amber-600 hover:bg-amber-50"
|
||||
}`}
|
||||
>
|
||||
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
||||
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
||||
</button>
|
||||
{canArchiveProject ? (
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border ${
|
||||
project?.is_archived
|
||||
? "text-green-600 border-green-600 hover:bg-green-50"
|
||||
: "text-amber-600 border-amber-600 hover:bg-amber-50"
|
||||
}`}
|
||||
>
|
||||
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
|
||||
{project?.is_archived ? t.projects.restore : t.projects.archive}
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -113,6 +120,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!canEditProject) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
||||
<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 { 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 { Tag } from "../../api/tags";
|
||||
@@ -56,6 +56,7 @@ function FilterTagMultiSelect({
|
||||
title: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -105,7 +106,17 @@ function FilterTagMultiSelect({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -127,8 +138,20 @@ function FilterTagMultiSelect({
|
||||
style={dropdownStyle}
|
||||
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{tags.map((tag) => {
|
||||
<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-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);
|
||||
return (
|
||||
<button
|
||||
@@ -156,6 +179,11 @@ function FilterTagMultiSelect({
|
||||
</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>,
|
||||
document.body,
|
||||
@@ -259,20 +287,18 @@ export default function TimesheetFilterBar({
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
aria-label={isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}
|
||||
className={`relative inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition-colors sm:w-auto sm:gap-2 sm:px-3 ${
|
||||
className={`relative inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition-colors ${
|
||||
isExpanded || hasActiveFilters
|
||||
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute -right-1 -top-1 z-10 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-600 px-1 text-[10px] font-semibold leading-none text-white dark:bg-sky-500 sm:static sm:z-auto sm:h-auto sm:min-w-5 sm:px-1.5 sm:text-[11px] sm:leading-normal">
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`hidden h-4 w-4 transition-transform sm:inline ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -290,14 +316,13 @@ export default function TimesheetFilterBar({
|
||||
}}
|
||||
disabled={!hasActiveFilters}
|
||||
aria-label={labels?.clear || "Clear"}
|
||||
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition sm:w-auto sm:gap-2 sm:px-3 ${
|
||||
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition ${
|
||||
hasActiveFilters
|
||||
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
|
||||
: "border-slate-200 bg-white text-slate-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{labels?.clear || "Clear"}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
@@ -306,7 +331,7 @@ export default function TimesheetFilterBar({
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-200 pt-2 dark:border-slate-800">
|
||||
<div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From"}>
|
||||
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From date"}>
|
||||
<JalaliDatePicker
|
||||
value={draftFilters.startedAfter}
|
||||
onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))}
|
||||
@@ -315,7 +340,7 @@ export default function TimesheetFilterBar({
|
||||
/>
|
||||
</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
|
||||
value={draftFilters.startedBefore}
|
||||
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",
|
||||
createTitle: "Create Tag",
|
||||
editTitle: "Edit Tag",
|
||||
deleteTitle: "Delete Tag",
|
||||
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
|
||||
searchPlaceholder: "Search tags...",
|
||||
nameLabel: "Tag Name",
|
||||
namePlaceholder: "e.g. Design",
|
||||
@@ -338,6 +340,27 @@ export const en = {
|
||||
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: {
|
||||
title: "Timesheet",
|
||||
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
|
||||
@@ -376,8 +399,8 @@ export const en = {
|
||||
billable: "Billable",
|
||||
noTagsHint: "Create tags first from the Tags page.",
|
||||
clearFilters: "Clear filters",
|
||||
customFromLabel: "From",
|
||||
customToLabel: "To",
|
||||
customFromLabel: "From date",
|
||||
customToLabel: "To date",
|
||||
allClientsLabel: "All clients",
|
||||
allProjectsLabel: "All projects",
|
||||
allTagsLabel: "All tags",
|
||||
|
||||
@@ -321,6 +321,8 @@ export const fa = {
|
||||
create: "ایجاد تگ",
|
||||
createTitle: "ایجاد تگ",
|
||||
editTitle: "ویرایش تگ",
|
||||
deleteTitle: "حذف تگ",
|
||||
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
|
||||
searchPlaceholder: "جستوجوی تگها...",
|
||||
nameLabel: "نام تگ",
|
||||
namePlaceholder: "مثلاً طراحی",
|
||||
@@ -335,6 +337,27 @@ export const fa = {
|
||||
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||
},
|
||||
|
||||
rates: {
|
||||
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
||||
projectSectionTitle: "نرخهای کاربران پروژه",
|
||||
workspaceRate: "دستمزد ساعتی",
|
||||
projectOverride: "نرخ اختصاصی پروژه",
|
||||
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
||||
noRate: "بدون نرخ",
|
||||
hourlyRatePlaceholder: "0.00",
|
||||
currencyPlaceholder: "USD",
|
||||
searchUnitPlaceholder: "جستوجوی واحد...",
|
||||
removeRate: "حذف نرخ",
|
||||
workspaceSaveSuccess: "نرخ کاربر ورکاسپیس ذخیره شد.",
|
||||
workspaceSaveError: "ذخیره نرخ کاربر ورکاسپیس با خطا مواجه شد.",
|
||||
workspaceRemoveSuccess: "نرخ کاربر ورکاسپیس حذف شد.",
|
||||
workspaceRemoveError: "حذف نرخ کاربر ورکاسپیس با خطا مواجه شد.",
|
||||
projectSaveSuccess: "نرخ کاربر پروژه ذخیره شد.",
|
||||
projectSaveError: "ذخیره نرخ کاربر پروژه با خطا مواجه شد.",
|
||||
projectRemoveSuccess: "نرخ کاربر پروژه حذف شد.",
|
||||
projectRemoveError: "حذف نرخ کاربر پروژه با خطا مواجه شد.",
|
||||
},
|
||||
|
||||
timesheet: {
|
||||
title: "تایمشیت",
|
||||
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||
@@ -373,8 +396,8 @@ export const fa = {
|
||||
billable: "قابل صورتحساب",
|
||||
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||
clearFilters: "پاک کردن فیلترها",
|
||||
customFromLabel: "از",
|
||||
customToLabel: "تا",
|
||||
customFromLabel: "از تاریخ",
|
||||
customToLabel: "تا تاریخ",
|
||||
allClientsLabel: "همه مشتریها",
|
||||
allProjectsLabel: "همه پروژهها",
|
||||
allTagsLabel: "همه تگها",
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useEffect, useState } from "react"
|
||||
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
|
||||
import { useWorkspace } from "../context/WorkspaceContext"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import {
|
||||
CLIENTS_CREATE,
|
||||
CLIENTS_DELETE,
|
||||
CLIENTS_EDIT,
|
||||
canWorkspace,
|
||||
} from "../lib/permissions"
|
||||
import { type Client } from "../types/client"
|
||||
import { getClients } from "../api/clients"
|
||||
import CreateClientModal from "../components/CreateClientModal"
|
||||
@@ -34,6 +40,10 @@ export default function Clients() {
|
||||
|
||||
const { t, lang } = useTranslation()
|
||||
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 = [
|
||||
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
||||
@@ -114,10 +124,15 @@ export default function Clients() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t.clients.addClient}
|
||||
</Button>
|
||||
{canCreateClient && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
size="icon"
|
||||
title={t.clients.addClient}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
@@ -159,24 +174,30 @@ export default function Clients() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditClient(client)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteClient(client)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{(canEditClient || canDeleteClient) && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{canEditClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditClient(client)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteClient(client)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -194,26 +215,32 @@ export default function Clients() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateClientModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={fetchClientsList}
|
||||
workspaceId={activeWorkspace.id}
|
||||
/>
|
||||
{canCreateClient && (
|
||||
<CreateClientModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={fetchClientsList}
|
||||
workspaceId={activeWorkspace.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditClientModal
|
||||
isOpen={!!editClient}
|
||||
onClose={() => setEditClient(null)}
|
||||
onSuccess={fetchClientsList}
|
||||
client={editClient}
|
||||
/>
|
||||
{canEditClient && (
|
||||
<EditClientModal
|
||||
isOpen={!!editClient}
|
||||
onClose={() => setEditClient(null)}
|
||||
onSuccess={fetchClientsList}
|
||||
client={editClient}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteClientModal
|
||||
isOpen={!!deleteClient}
|
||||
onClose={() => setDeleteClient(null)}
|
||||
onSuccess={fetchClientsList}
|
||||
client={deleteClient}
|
||||
/>
|
||||
{canDeleteClient && (
|
||||
<DeleteClientModal
|
||||
isOpen={!!deleteClient}
|
||||
onClose={() => setDeleteClient(null)}
|
||||
onSuccess={fetchClientsList}
|
||||
client={deleteClient}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { searchUserByExactMobile, type SearchedUser } from "../api/users";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/Select";
|
||||
@@ -58,6 +59,7 @@ export default function ProjectCreate() {
|
||||
const { user } = useAppContext();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const currentUserId = user?.id || "";
|
||||
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
||||
|
||||
// Project Detail States
|
||||
const [name, setName] = useState("");
|
||||
@@ -91,6 +93,13 @@ export default function ProjectCreate() {
|
||||
|
||||
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 }) => {
|
||||
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
|
||||
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 { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/Select";
|
||||
@@ -59,6 +60,7 @@ export default function ProjectEdit() {
|
||||
const { user } = useAppContext();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const currentUserId = user?.id || "";
|
||||
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -89,6 +91,13 @@ export default function ProjectEdit() {
|
||||
|
||||
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 }) => {
|
||||
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
|
||||
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);
|
||||
}
|
||||
|
||||
const res = await fetchWorkspaceMemberships({
|
||||
workspace: activeWorkspace.id,
|
||||
limit: LIMIT,
|
||||
@@ -594,6 +602,7 @@ export default function ProjectEdit() {
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,10 +13,22 @@ import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card"
|
||||
import { Modal } from "../components/Modal";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../components/ui/input";
|
||||
import {
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECTS_CREATE,
|
||||
PROJECTS_DELETE,
|
||||
PROJECTS_EDIT,
|
||||
canWorkspace,
|
||||
} from "../lib/permissions";
|
||||
|
||||
export const Projects: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
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 [loading, setLoading] = useState(false);
|
||||
@@ -117,21 +129,26 @@ 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>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<Button
|
||||
variant={isArchived ? "default" : "secondary"}
|
||||
onClick={() => setIsArchived(!isArchived)}
|
||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
{t.projects?.createNew || 'Create New'}
|
||||
</Button>
|
||||
{canArchiveProject && (
|
||||
<Button
|
||||
variant={isArchived ? "default" : "secondary"}
|
||||
onClick={() => setIsArchived(!isArchived)}
|
||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||
</Button>
|
||||
)}
|
||||
{canCreateProject && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
size="icon"
|
||||
className="shadow-sm"
|
||||
title={t.projects?.createNew || 'Create New'}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,27 +189,33 @@ export const Projects: React.FC = () => {
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingProject(project)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||
title={t.actions?.edit || 'Edit'}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
{(canEditProject || canDeleteProject) && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{canEditProject && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingProject(project)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||
title={t.actions?.edit || 'Edit'}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||
title={t.actions?.delete || 'Delete'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{canDeleteProject && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||
title={t.actions?.delete || 'Delete'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -216,14 +239,14 @@ export const Projects: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{isCreateModalOpen && (
|
||||
{canCreateProject && isCreateModalOpen && (
|
||||
<ProjectCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingProject && (
|
||||
{canEditProject && editingProject && (
|
||||
<ProjectEditModal
|
||||
project={editingProject}
|
||||
isOpen={!!editingProject}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { TAGS_CREATE, TAGS_DELETE, TAGS_EDIT, canWorkspace } from "../lib/permissions";
|
||||
import FilterBar from "../components/FilterBar";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
@@ -17,6 +18,10 @@ const DEFAULT_COLOR = "#3B82F6";
|
||||
export default function Tags() {
|
||||
const { t } = useTranslation();
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
@@ -31,6 +36,7 @@ export default function Tags() {
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; tag: Tag | null }>({ isOpen: false, tag: null });
|
||||
|
||||
const orderingOptions = [
|
||||
{ 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}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.tags?.create || "Create Tag"}
|
||||
</Button>
|
||||
{canCreateTag && (
|
||||
<Button onClick={openCreateModal} size="icon" className="shadow-sm" title={t.tags?.create || "Create Tag"}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="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) => (
|
||||
<Card key={tag.id} className="dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4 px-6">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-700" style={{ backgroundColor: tag.color || DEFAULT_COLOR }} />
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg truncate text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{tag.color || DEFAULT_COLOR}</p>
|
||||
<Card key={tag.id} className="overflow-hidden shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="h-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">
|
||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
{(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"}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{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" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -237,6 +259,37 @@ export default function Tags() {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 { getProjects, type Project } from "../api/projects";
|
||||
@@ -178,6 +178,15 @@ const formatDateTime = (value: string, locale: "en" | "fa") => {
|
||||
}).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 start = parseApiDateTime(entry.start_time)?.getTime();
|
||||
const end = entry.end_time ? parseApiDateTime(entry.end_time)?.getTime() : now;
|
||||
@@ -533,6 +542,7 @@ function TagMultiSelect({
|
||||
portalOwnerId?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -587,8 +597,18 @@ function TagMultiSelect({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
||||
const joinedSelectedLabels = selectedLabels.join(" | ");
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||
const filteredTags = normalizedSearch
|
||||
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||||
: tags;
|
||||
const buttonLabel = compact
|
||||
? selectedTags.length > 0
|
||||
? joinedSelectedLabels
|
||||
@@ -638,8 +658,21 @@ function TagMultiSelect({
|
||||
{tags.length === 0 ? (
|
||||
<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);
|
||||
return (
|
||||
<button
|
||||
@@ -658,8 +691,14 @@ function TagMultiSelect({
|
||||
{selected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
{filteredTags.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
No tags found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -1646,8 +1685,12 @@ export default function Timesheet() {
|
||||
|
||||
const [timerDraft, setTimerDraft] = useState<TimerDraftState>(EMPTY_TIMER_DRAFT);
|
||||
const [isStartingTimer, setIsStartingTimer] = useState(false);
|
||||
const desktopTimerRef = useRef<HTMLDivElement>(null);
|
||||
const mobileTimerRef = useRef<HTMLDivElement>(null);
|
||||
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 }>({
|
||||
isOpen: false,
|
||||
@@ -1793,62 +1836,87 @@ export default function Timesheet() {
|
||||
useEffect(() => {
|
||||
if (!runningEntry) {
|
||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||||
setTimerDraft(EMPTY_TIMER_DRAFT);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDraft = buildTimerDraftState(runningEntry);
|
||||
timerDraftSignatureRef.current = serializeTimerDraft(nextDraft);
|
||||
pendingTimerSignatureRef.current = timerDraftSignatureRef.current;
|
||||
setTimerDraft(nextDraft);
|
||||
}, [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 saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
||||
|
||||
if (!runningEntry) return;
|
||||
if (!runningEntry) return false;
|
||||
|
||||
const currentSignature = serializeTimerDraft(timerDraft);
|
||||
if (currentSignature === timerDraftSignatureRef.current) return;
|
||||
|
||||
if (timerSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
||||
if (currentSignature === timerDraftSignatureRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
timerSaveTimeoutRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||||
description: timerDraft.description.trim(),
|
||||
project_id: timerDraft.projectId || null,
|
||||
tags: timerDraft.tags,
|
||||
is_billable: timerDraft.isBillable,
|
||||
});
|
||||
if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||||
timerDraftSignatureRef.current = serializeTimerDraft(syncedDraft);
|
||||
setTimerDraft(syncedDraft);
|
||||
setActiveRunningEntry(updatedEntry);
|
||||
toast.success(saveSuccessText);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(saveErrorText);
|
||||
}
|
||||
}, 500);
|
||||
isTimerSavingRef.current = true;
|
||||
pendingTimerSignatureRef.current = currentSignature;
|
||||
|
||||
return () => {
|
||||
if (timerSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||||
description: timerDraft.description.trim(),
|
||||
project_id: timerDraft.projectId || null,
|
||||
tags: timerDraft.tags,
|
||||
is_billable: timerDraft.isBillable,
|
||||
});
|
||||
|
||||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||||
const syncedSignature = serializeTimerDraft(syncedDraft);
|
||||
timerDraftSignatureRef.current = syncedSignature;
|
||||
pendingTimerSignatureRef.current = syncedSignature;
|
||||
setTimerDraft(syncedDraft);
|
||||
setActiveRunningEntry(updatedEntry);
|
||||
toast.success(saveSuccessText);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pendingTimerSignatureRef.current = null;
|
||||
toast.error(saveErrorText);
|
||||
return false;
|
||||
} finally {
|
||||
isTimerSavingRef.current = false;
|
||||
}
|
||||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
||||
}
|
||||
if (!runningEntry) return;
|
||||
|
||||
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 = () => {
|
||||
if (isSaving) return;
|
||||
@@ -2066,10 +2134,6 @@ export default function Timesheet() {
|
||||
|
||||
try {
|
||||
setIsDiscardingTimer(true);
|
||||
if (timerSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerSaveTimeoutRef.current);
|
||||
timerSaveTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
await deleteTimeEntry(discardTimerModal.entry.id);
|
||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||
@@ -2099,7 +2163,11 @@ export default function Timesheet() {
|
||||
</h1>
|
||||
</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="min-w-[360px] flex-1">
|
||||
<Input
|
||||
@@ -2122,6 +2190,7 @@ export default function Timesheet() {
|
||||
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"
|
||||
disabled={isStartingTimer}
|
||||
portalOwnerId={timerEditorOwnerId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2135,6 +2204,7 @@ export default function Timesheet() {
|
||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||
title={t.tags?.title || "Tags"}
|
||||
compact
|
||||
portalOwnerId={timerEditorOwnerId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2155,21 +2225,38 @@ export default function Timesheet() {
|
||||
<div className="ms-2 flex shrink-0 items-center gap-2">
|
||||
{runningEntry ? (
|
||||
<>
|
||||
<Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-12 rounded-md px-5 text-xs font-semibold uppercase">
|
||||
{t.timesheet?.stopTimer || "Stop"}
|
||||
<Button
|
||||
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
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={openDiscardTimerModal}
|
||||
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 onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-12 rounded-md px-5 text-xs font-semibold uppercase">
|
||||
{isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")}
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@@ -2177,7 +2264,11 @@ export default function Timesheet() {
|
||||
</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">
|
||||
<Input
|
||||
value={timerDraft.description}
|
||||
@@ -2198,6 +2289,7 @@ export default function Timesheet() {
|
||||
className="w-full"
|
||||
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isStartingTimer}
|
||||
portalOwnerId={timerEditorOwnerId}
|
||||
/>
|
||||
|
||||
<div className="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."}
|
||||
title={t.tags?.title || "Tags"}
|
||||
compact
|
||||
portalOwnerId={timerEditorOwnerId}
|
||||
/>
|
||||
|
||||
<BillableIconButton
|
||||
@@ -2230,21 +2323,38 @@ export default function Timesheet() {
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{runningEntry ? (
|
||||
<>
|
||||
<Button variant="destructive" onClick={() => void handleStop(runningEntry)} className="h-10 rounded-md px-4 text-xs font-semibold uppercase">
|
||||
{t.timesheet?.stopTimer || "Stop"}
|
||||
<Button
|
||||
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
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={openDiscardTimerModal}
|
||||
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 onClick={() => void handleStartTimer()} disabled={isStartingTimer} className="h-10 rounded-md px-4 text-xs font-semibold uppercase">
|
||||
{isStartingTimer ? "..." : (t.timesheet?.startTimer || "Start")}
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@@ -2266,8 +2376,8 @@ export default function Timesheet() {
|
||||
client: t.projects?.clientLabel || "Client",
|
||||
tags: t.tags?.title || "Tags",
|
||||
clear: extendedTimesheet.clearFilters || "Clear filters",
|
||||
customFrom: extendedTimesheet.customFromLabel || "From",
|
||||
customTo: extendedTimesheet.customToLabel || "To",
|
||||
customFrom: extendedTimesheet.customFromLabel || "From date",
|
||||
customTo: extendedTimesheet.customToLabel || "To date",
|
||||
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
||||
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
||||
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
||||
|
||||
@@ -178,13 +178,13 @@ export default function WorkspaceCreate() {
|
||||
const isFirstOwner = true;
|
||||
|
||||
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">
|
||||
{t.workspace?.createTitle || "Create Workspace"}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||
<div className="w-full lg:w-1/3 lg:max-w-md 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="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
|
||||
<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">
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
@@ -322,7 +322,7 @@ export default function WorkspaceCreate() {
|
||||
</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) => {
|
||||
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">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
|
||||
import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions';
|
||||
|
||||
export default function WorkspaceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -44,7 +45,8 @@ export default function WorkspaceDetail() {
|
||||
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 (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
@@ -75,7 +77,7 @@ export default function WorkspaceDetail() {
|
||||
>
|
||||
<Edit2 className="h-5 w-5" />
|
||||
</button>
|
||||
{workspace.my_role === 'owner' && (
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
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 { Dialog, Transition } from '@headlessui/react';
|
||||
import { toast } from 'sonner';
|
||||
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
|
||||
import {
|
||||
updateWorkspace,
|
||||
addWorkspaceMembership,
|
||||
@@ -14,11 +15,20 @@ import {
|
||||
} from '../api/workspaces';
|
||||
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
|
||||
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 { InfiniteScroll } from '../components/InfiniteScroll';
|
||||
import { Select } from '../components/ui/Select';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { TextAreaInput } from '../components/ui/TextAreaInput';
|
||||
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
|
||||
|
||||
const toEnglishDigits = (str: string) => {
|
||||
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
||||
@@ -45,10 +55,12 @@ export default function EditWorkspace() {
|
||||
// Workspace Info States
|
||||
const [name, setName] = 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 [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [workspaceRates, setWorkspaceRates] = useState<WorkspaceUserRate[]>([]);
|
||||
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
|
||||
|
||||
// Members States
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
@@ -105,6 +117,13 @@ export default function EditWorkspace() {
|
||||
if (id) loadData();
|
||||
}, [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 () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -114,10 +133,16 @@ export default function EditWorkspace() {
|
||||
setMyRole(workspaceData.my_role || 'member');
|
||||
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 : []);
|
||||
|
||||
setMembers(results);
|
||||
setWorkspaceRates(ratesData.results || []);
|
||||
setPriceUnits(unitsData.results || []);
|
||||
setOffset(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 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>;
|
||||
|
||||
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">
|
||||
{t.workspace?.editTitle || "Edit Workspace"}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
|
||||
<div className="w-full lg:w-1/3 lg:max-w-md 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="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
|
||||
<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">
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
</h2>
|
||||
|
||||
{canManageMembers && (
|
||||
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -357,10 +389,7 @@ export default function EditWorkspace() {
|
||||
value={newMemberRole}
|
||||
onChange={(val) => setNewMemberRole(val as any)}
|
||||
options={[
|
||||
...(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" },
|
||||
...roleOptions(isFirstOwner),
|
||||
]}
|
||||
className="flex-1 sm:flex-none"
|
||||
buttonClassName="w-full sm:w-[110px] px-3 py-1.5 text-sm"
|
||||
@@ -385,7 +414,7 @@ export default function EditWorkspace() {
|
||||
)}
|
||||
</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
|
||||
onLoadMore={loadMoreMembers}
|
||||
hasMore={hasMore}
|
||||
@@ -395,11 +424,18 @@ export default function EditWorkspace() {
|
||||
>
|
||||
{members.map((m) => {
|
||||
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 (
|
||||
<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 className="flex items-center gap-3">
|
||||
<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">
|
||||
{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" />
|
||||
) : (
|
||||
@@ -415,40 +451,49 @@ export default function EditWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
{canChangeThisUserRole ? (
|
||||
<Select
|
||||
value={m.role}
|
||||
onChange={(val) => handleChangeRole(m.id, val)}
|
||||
options={[
|
||||
...(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"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
|
||||
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
||||
{m.role && m.role in t.workspace.roles
|
||||
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
|
||||
: m.role || "-"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
{canChangeThisUserRole ? (
|
||||
<Select
|
||||
value={m.role}
|
||||
onChange={(val) => handleChangeRole(m.id, val)}
|
||||
options={roleOptions(isFirstOwner)}
|
||||
buttonClassName="w-[110px] px-3 py-1.5 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
|
||||
{m.role === 'owner' && <Shield className="w-3 h-3" />}
|
||||
{m.role && m.role in t.workspace.roles
|
||||
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
|
||||
: m.role || "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openDeleteModal(m.id)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
title={t.workspace?.removeMemberTitle || "Remove member"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canChangeThisUserRole && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openDeleteModal(m.id)}
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
title={t.workspace?.removeMemberTitle || "Remove member"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
@@ -463,6 +508,7 @@ export default function EditWorkspace() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Trash2, Pencil, ChevronRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import {
|
||||
WORKSPACE_DELETE,
|
||||
WORKSPACE_EDIT,
|
||||
canWorkspace,
|
||||
type WorkspaceRole,
|
||||
} from '../lib/permissions';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
@@ -12,8 +17,6 @@ import { Card, CardContent, CardTitle } from '../components/ui/card';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { Modal } from '../components/Modal';
|
||||
|
||||
type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
||||
|
||||
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!role) return null;
|
||||
@@ -46,7 +49,6 @@ export default function Workspaces() {
|
||||
const [deleteInput, setDeleteInput] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAppContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const orderingOptions = [
|
||||
@@ -122,10 +124,11 @@ export default function Workspaces() {
|
||||
</div>
|
||||
<Button
|
||||
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" />
|
||||
{t.workspace?.createNew || 'Create New'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -146,8 +149,8 @@ export default function Workspaces() {
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
{workspaces.map((workspace) => {
|
||||
const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner';
|
||||
const isAdmin = workspace.my_role === 'admin' || isOwner;
|
||||
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
||||
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
||||
|
||||
return (
|
||||
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||
@@ -165,7 +168,7 @@ export default function Workspaces() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isOwner && (
|
||||
{canDeleteWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -177,7 +180,7 @@ export default function Workspaces() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
{canEditWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
Reference in New Issue
Block a user