Compare commits

...

15 Commits

Author SHA1 Message Date
8c5b4e258e fix(timesheet): show entry date without time in mobile cards 2026-04-26 10:32:07 +03:30
ab6fe908d3 fix(timesheet): save running timer draft on editor blur 2026-04-26 10:22:50 +03:30
2d843046fa feat(pricing): manage workspace member rates in edit flows 2026-04-26 10:21:58 +03:30
f9dfd8826e feat(pricing): manage workspace member rates in edit flows 2026-04-26 10:19:25 +03:30
846668add9 refactor(tags): use a responsive compact card layout 2026-04-25 19:13:02 +03:30
cf7dd06046 feat(timesheet): add searchable tag selectors 2026-04-25 19:09:10 +03:30
8b16344aef refactor(timesheet): rename filter date range labels 2026-04-25 19:08:16 +03:30
d53a8a67d7 refactor(timesheet): simplify filter bar controls 2026-04-25 19:08:00 +03:30
29eefdea27 refactor(timesheet): use icon actions for the main timer 2026-04-25 19:07:39 +03:30
679d1cafb6 refactor(ui): use icon-only create actions in list pages 2026-04-25 19:06:58 +03:30
204b093937 feat(tags): confirm deletion before removing tags 2026-04-25 19:06:37 +03:30
7f0e00f09d feat(permissions): gate workspace resources by role 2026-04-25 18:48:49 +03:30
c8c689e693 fix(timesheet): add corner edges to timer 2026-04-25 18:47:41 +03:30
7b8c3d7ff7 fix(notifications): add minimal styling to notification bell navbar 2026-04-25 17:58:02 +03:30
056ff31ef8 feat(layout): add mobile sidebar drawer navigation 2026-04-25 17:28:00 +03:30
23 changed files with 1599 additions and 571 deletions

View File

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

View File

@@ -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 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 />}

View File

@@ -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,7 +57,41 @@ export const Sidebar = () => {
},
];
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'
}`
}
>
<Icon size={mobile ? 20 : isCollapsed ? 22 : 18} className="shrink-0" />
{(mobile || !isCollapsed) && (
<span className="truncate whitespace-nowrap">{item.label}</span>
)}
</NavLink>
);
});
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'
@@ -75,31 +115,52 @@ export const Sidebar = () => {
</div>
<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'
}`
}
>
<Icon size={isCollapsed ? 22 : 18} className="shrink-0" />
{!isCollapsed && (
<span className="truncate whitespace-nowrap">{item.label}</span>
)}
</NavLink>
);
})}
{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"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
{renderNavItems(true)}
</nav>
</aside>
</div>
</>
);
};

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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,6 +91,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
const footer = (
<div className="flex justify-between w-full">
{canArchiveProject ? (
<button
onClick={handleArchiveToggle}
type="button"
@@ -101,6 +105,9 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
{project?.is_archived ? <RefreshCcw size={16} /> : <Archive size={16} />}
{project?.is_archived ? 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">

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

View File

@@ -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 }))}

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

View File

@@ -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",

View File

@@ -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: "همه تگ‌ها",

View File

@@ -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">
{canCreateClient && (
<Button
onClick={() => setIsCreateModalOpen(true)}
size="icon"
title={t.clients.addClient}
>
<Plus className="w-4 h-4" />
{t.clients.addClient}
</Button>
)}
</div>
<FilterBar
@@ -159,7 +174,9 @@ export default function Clients() {
</div>
</div>
{(canEditClient || canDeleteClient) && (
<div className="flex items-center gap-1 shrink-0">
{canEditClient && (
<Button
variant="ghost"
size="icon"
@@ -168,6 +185,8 @@ export default function Clients() {
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteClient && (
<Button
variant="ghost"
size="icon"
@@ -176,7 +195,9 @@ export default function Clients() {
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
)}
</li>
))}
</ul>
@@ -194,26 +215,32 @@ export default function Clients() {
/>
)}
{canCreateClient && (
<CreateClientModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={fetchClientsList}
workspaceId={activeWorkspace.id}
/>
)}
{canEditClient && (
<EditClientModal
isOpen={!!editClient}
onClose={() => setEditClient(null)}
onSuccess={fetchClientsList}
client={editClient}
/>
)}
{canDeleteClient && (
<DeleteClientModal
isOpen={!!deleteClient}
onClose={() => setDeleteClient(null)}
onSuccess={fetchClientsList}
client={deleteClient}
/>
)}
</div>
)
}

View File

@@ -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?");

View File

@@ -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>

View File

@@ -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,6 +129,7 @@ export const Projects: React.FC = () => {
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
{canArchiveProject && (
<Button
variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)}
@@ -125,13 +138,17 @@ export const Projects: React.FC = () => {
<Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</Button>
)}
{canCreateProject && (
<Button
onClick={() => setIsCreateModalOpen(true)}
className="gap-2 shadow-sm flex-1 sm:flex-none"
size="icon"
className="shadow-sm"
title={t.projects?.createNew || 'Create New'}
>
<Plus className="h-5 w-5" />
{t.projects?.createNew || 'Create New'}
</Button>
)}
</div>
</div>
@@ -172,7 +189,9 @@ export const Projects: React.FC = () => {
</div>
{(canEditProject || canDeleteProject) && (
<div className="flex items-center gap-2 shrink-0">
{canEditProject && (
<Button
variant="ghost"
size="icon"
@@ -182,7 +201,9 @@ export const Projects: React.FC = () => {
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteProject && (
<Button
variant="ghost"
size="icon"
@@ -192,7 +213,9 @@ export const Projects: React.FC = () => {
>
<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}

View File

@@ -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">
{canCreateTag && (
<Button onClick={openCreateModal} size="icon" className="shadow-sm" title={t.tags?.create || "Create Tag"}>
<Plus className="h-4 w-4" />
{t.tags?.create || "Create Tag"}
</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 }} />
<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="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>
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{tag.name}</CardTitle>
</div>
</div>
<div className="flex items-center gap-2">
{(canEditTag || canDeleteTag) && (
<div className="flex shrink-0 items-center gap-1">
{canEditTag && (
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
)}
{canDeleteTag && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, tag })}
title={t.actions?.delete || "Delete"}
>
<Trash2 className="w-4 h-4 text-red-500" />
</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>
);
}

View File

@@ -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
@@ -659,8 +692,14 @@ function TagMultiSelect({
</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
)
@@ -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,29 +1836,41 @@ 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 () => {
if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) {
return false;
}
isTimerSavingRef.current = true;
pendingTimerSignatureRef.current = currentSignature;
try {
const updatedEntry = await updateTimeEntry(runningEntry.id, {
description: timerDraft.description.trim(),
@@ -1825,30 +1880,43 @@ export default function Timesheet() {
});
const syncedDraft = buildTimerDraftState(updatedEntry);
timerDraftSignatureRef.current = serializeTimerDraft(syncedDraft);
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;
}
}, 500);
return () => {
if (timerSaveTimeoutRef.current) {
window.clearTimeout(timerSaveTimeoutRef.current);
}
};
}, [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",

View File

@@ -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">

View File

@@ -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"

View File

@@ -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,10 +424,17 @@ 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 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" />
@@ -420,12 +456,7 @@ export default function EditWorkspace() {
<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" },
]}
options={roleOptions(isFirstOwner)}
buttonClassName="w-[110px] px-3 py-1.5 text-sm"
/>
) : (
@@ -437,7 +468,7 @@ export default function EditWorkspace() {
</span>
)}
{canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner') && (
{canChangeThisUserRole && (
<Button
type="button"
variant="ghost"
@@ -451,6 +482,20 @@ export default function EditWorkspace() {
)}
</div>
</div>
<div className="flex flex-col gap-2 border-t border-slate-100 pt-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRate || "Workspace rate"}
</div>
<WorkspaceMemberRateFields
workspaceId={id!}
userId={m.user.id}
rate={workspaceRates.find((item) => item.user === m.user.id)}
priceUnits={priceUnits}
onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))}
/>
</div>
</div>
);
})}
</InfiniteScroll>
@@ -463,6 +508,7 @@ export default function EditWorkspace() {
</div>
)}
</div>
</div>
</div>

View File

@@ -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"