Compare commits

...

2 Commits

Author SHA1 Message Date
eb41c8528d refactor(auth): replace escaped persian digits
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:17:55 +03:30
c673159032 feat(projects): expose implicit-access roles in projects and rates modal 2026-05-24 10:31:32 +03:30
8 changed files with 247 additions and 157 deletions

View File

@@ -47,7 +47,7 @@ export interface ProjectAccessItem {
export interface ProjectAccessState { export interface ProjectAccessState {
workspace: { id: string; name: string }; workspace: { id: string; name: string };
user: { id: string; name: string; mobile: string; role: "member" | "guest" }; user: { id: string; name: string; mobile: string; role: "owner" | "admin" | "member" | "guest" };
items: ProjectAccessItem[]; items: ProjectAccessItem[];
} }

View File

@@ -69,6 +69,7 @@ type Labels = {
projectRateRemoved: string; projectRateRemoved: string;
projectRateSaveError: string; projectRateSaveError: string;
projectRateRemoveError: string; projectRateRemoveError: string;
implicitAccessHint: string;
}; };
type RateDraft = { type RateDraft = {
@@ -76,8 +77,6 @@ type RateDraft = {
currency: string; currency: string;
}; };
const MANAGEABLE_ROLES = new Set(["member", "guest"]);
function getMemberName(member: WorkspaceMembership) { function getMemberName(member: WorkspaceMembership) {
return ( return (
member.user?.name || member.user?.name ||
@@ -132,22 +131,23 @@ export function ProjectAccessModal({
const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null); const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]); const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({}); const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
const [selectedUserRole, setSelectedUserRole] = useState<"owner" | "admin" | "member" | "guest" | "">("");
const { lang } = useTranslation(); const { lang } = useTranslation();
const isRtl = const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl"; typeof document !== "undefined" && document.documentElement.dir === "rtl";
const defaultCurrency = priceUnits[0]?.code || "USD"; const defaultCurrency = priceUnits[0]?.code || "USD";
const manageableMembers = useMemo( const activeMembers = useMemo(
() => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)), () => members.filter((member) => member.is_active),
[members], [members],
); );
const filteredMembers = useMemo(() => { const filteredMembers = useMemo(() => {
const normalizedSearch = memberSearchQuery.trim().toLowerCase(); const normalizedSearch = memberSearchQuery.trim().toLowerCase();
const baseMembers = !normalizedSearch const baseMembers = !normalizedSearch
? manageableMembers ? activeMembers
: manageableMembers.filter((member) => { : activeMembers.filter((member) => {
const memberName = getMemberName(member).toLowerCase(); const memberName = getMemberName(member).toLowerCase();
const memberMobile = member.user?.mobile?.toLowerCase() ?? ""; const memberMobile = member.user?.mobile?.toLowerCase() ?? "";
return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch); return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch);
@@ -158,7 +158,9 @@ export function ProjectAccessModal({
if (b.user.id === selectedUserId) return 1; if (b.user.id === selectedUserId) return 1;
return 0; return 0;
}); });
}, [manageableMembers, memberSearchQuery, selectedUserId]); }, [activeMembers, memberSearchQuery, selectedUserId]);
const canManageExplicitAccess = selectedUserRole === "member" || selectedUserRole === "guest";
const clientOptions = useMemo(() => { const clientOptions = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -246,14 +248,14 @@ export function ProjectAccessModal({
}, [isOpen, labels.loadError, workspaceId]); }, [isOpen, labels.loadError, workspaceId]);
useEffect(() => { useEffect(() => {
if (!manageableMembers.length) { if (!activeMembers.length) {
setSelectedUserId(""); setSelectedUserId("");
return; return;
} }
if (!manageableMembers.some((member) => member.user.id === selectedUserId)) { if (!activeMembers.some((member) => member.user.id === selectedUserId)) {
setSelectedUserId(manageableMembers[0].user.id); setSelectedUserId(activeMembers[0].user.id);
} }
}, [manageableMembers, selectedUserId]); }, [activeMembers, selectedUserId]);
useEffect(() => { useEffect(() => {
if (!isOpen || !selectedUserId) { if (!isOpen || !selectedUserId) {
@@ -265,10 +267,12 @@ export function ProjectAccessModal({
setLoadingProjects(true); setLoadingProjects(true);
try { try {
const response = await getProjectAccessState(workspaceId, selectedUserId); const response = await getProjectAccessState(workspaceId, selectedUserId);
setSelectedUserRole(response.user.role);
setProjectItems(response.items); setProjectItems(response.items);
setSelectedProjectIds([]); setSelectedProjectIds([]);
} catch { } catch {
toast.error(labels.loadError); toast.error(labels.loadError);
setSelectedUserRole("");
setProjectItems([]); setProjectItems([]);
} finally { } finally {
setLoadingProjects(false); setLoadingProjects(false);
@@ -305,6 +309,7 @@ export function ProjectAccessModal({
}; };
const toggleProjectSelection = (projectId: string) => { const toggleProjectSelection = (projectId: string) => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) => setSelectedProjectIds((current) =>
current.includes(projectId) current.includes(projectId)
? current.filter((id) => id !== projectId) ? current.filter((id) => id !== projectId)
@@ -313,11 +318,12 @@ export function ProjectAccessModal({
}; };
const handleSelectAllVisible = () => { const handleSelectAllVisible = () => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds]))); setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
}; };
const handleSelectClientProjects = () => { const handleSelectClientProjects = () => {
if (!selectedClientId) return; if (!selectedClientId || !canManageExplicitAccess) return;
const clientProjectIds = visibleProjects const clientProjectIds = visibleProjects
.filter((item) => item.client?.id === selectedClientId) .filter((item) => item.client?.id === selectedClientId)
.map((item) => item.id); .map((item) => item.id);
@@ -442,7 +448,7 @@ export function ProjectAccessModal({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => void handleMutation("grant")} onClick={() => void handleMutation("grant")}
disabled={!selectedProjectIds.length || isSaving} disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2" className="gap-2"
> >
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />} {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
@@ -452,7 +458,7 @@ export function ProjectAccessModal({
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => void handleMutation("revoke")} onClick={() => void handleMutation("revoke")}
disabled={!selectedProjectIds.length || isSaving} disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2" className="gap-2"
> >
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />} {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />}
@@ -473,6 +479,11 @@ export function ProjectAccessModal({
<p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400"> <p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400">
{labels.description} {labels.description}
</p> </p>
{!canManageExplicitAccess && selectedUserRole ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100">
{labels.implicitAccessHint}
</div>
) : null}
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr"> <div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<section <section
@@ -518,7 +529,7 @@ export function ProjectAccessModal({
variant="secondary" variant="secondary"
size="icon" size="icon"
onClick={handleSelectAllVisible} onClick={handleSelectAllVisible}
disabled={!visibleProjects.length} disabled={!canManageExplicitAccess || !visibleProjects.length}
title={labels.selectAllVisible} title={labels.selectAllVisible}
aria-label={labels.selectAllVisible} aria-label={labels.selectAllVisible}
> >
@@ -542,7 +553,7 @@ export function ProjectAccessModal({
variant="secondary" variant="secondary"
size="icon" size="icon"
onClick={handleSelectClientProjects} onClick={handleSelectClientProjects}
disabled={!selectedClientId} disabled={!canManageExplicitAccess || !selectedClientId}
title={labels.selectClientProjects} title={labels.selectClientProjects}
aria-label={labels.selectClientProjects} aria-label={labels.selectClientProjects}
> >
@@ -583,7 +594,7 @@ export function ProjectAccessModal({
<button <button
type="button" type="button"
onClick={() => toggleProjectSelection(item.id)} onClick={() => toggleProjectSelection(item.id)}
className="flex w-full items-start gap-3 text-start" className={`flex w-full items-start gap-3 text-start ${!canManageExplicitAccess ? "cursor-default" : ""}`}
> >
<div <div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${ className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
@@ -747,6 +758,7 @@ export function ProjectAccessModal({
) : ( ) : (
filteredMembers.map((member) => { filteredMembers.map((member) => {
const isActive = member.user.id === selectedUserId; const isActive = member.user.id === selectedUserId;
const isImplicitUser = member.role === "owner" || member.role === "admin";
return ( return (
<button <button
@@ -779,6 +791,11 @@ export function ProjectAccessModal({
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300"> <span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{member.role} {member.role}
</span> </span>
{isImplicitUser ? (
<span className="shrink-0 rounded-full bg-sky-100 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-200">
{labels.accessOn}
</span>
) : null}
</div> </div>
{isActive ? ( {isActive ? (

View File

@@ -527,11 +527,11 @@ export const en = {
clearClientFilters: "Clear filters", clearClientFilters: "Clear filters",
namePlaceholder: "Project name...", namePlaceholder: "Project name...",
teamMembers: "Team Members", teamMembers: "Team Members",
manageAccess: "Manage access", manageAccess: "Projects & Rates",
accessModalTitle: "Project access", accessModalTitle: "Projects & Rates",
accessModalDescription: "Grant or revoke project access for workspace members.", accessModalDescription: "Manage project access for members and guests, and set project-specific rates for any workspace user.",
accessMemberLabel: "Member", accessMemberLabel: "User",
accessNoMembers: "No eligible members were found.", accessNoMembers: "No workspace users were found.",
accessNoProjects: "No projects found.", accessNoProjects: "No projects found.",
accessSelectVisible: "Select all visible", accessSelectVisible: "Select all visible",
accessClearSelection: "Clear selection", accessClearSelection: "Clear selection",
@@ -544,6 +544,7 @@ export const en = {
accessRevokeSuccess: "Project access revoked.", accessRevokeSuccess: "Project access revoked.",
accessLoadError: "Failed to load project access state.", accessLoadError: "Failed to load project access state.",
accessSaveError: "Failed to update project access.", accessSaveError: "Failed to update project access.",
implicitAccessHint: "Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
creator: "Creator", creator: "Creator",
addUser: "Add user by mobile", addUser: "Add user by mobile",
addFromWorkspace: "Add from workspace", addFromWorkspace: "Add from workspace",

View File

@@ -536,11 +536,11 @@ export const fa = {
}, },
namePlaceholder: "نام پروژه...", namePlaceholder: "نام پروژه...",
teamMembers: "اعضای تیم", teamMembers: "اعضای تیم",
manageAccess: "مدیریت دسترسی", manageAccess: "پروژه‌ها و نرخ‌ها",
accessModalTitle: "دسترسی پروژه‌ها", accessModalTitle: "پروژهها و نرخها",
accessModalDescription: "دسترسی اعضای ورک‌اسپیس به پروژه‌ها را اعطا یا لغو کنید.", accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.",
accessMemberLabel: "عضو", accessMemberLabel: "کاربر",
accessNoMembers: "عضو واجد شرایطی پیدا نشد.", accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.",
accessNoProjects: "پروژه‌ای پیدا نشد.", accessNoProjects: "پروژه‌ای پیدا نشد.",
accessSelectVisible: "انتخاب همه موارد قابل مشاهده", accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
accessClearSelection: "پاک کردن انتخاب", accessClearSelection: "پاک کردن انتخاب",
@@ -553,6 +553,7 @@ export const fa = {
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.", accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.", accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.",
accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.", accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.",
implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.",
createSuccess: "پروژه با موفقیت ایجاد شد.", createSuccess: "پروژه با موفقیت ایجاد شد.",
createError: "خطا در ایجاد پروژه.", createError: "خطا در ایجاد پروژه.",
updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.", updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.",

View File

@@ -494,6 +494,9 @@ export const Projects: React.FC = () => {
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.", projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.", projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.", projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.",
implicitAccessHint:
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}} }}
/> />
)} )}

View File

@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Banknote, CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react"; import { Banknote, CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getProjects, type Project } from "../api/projects"; import { getProjects, type Project } from "../api/projects";
import { getMyWorkspaceRates, type MyWorkspaceRatesResponse } from "../api/rates"; import { getMyWorkspaceRates, type MyWorkspaceRatesResponse } from "../api/rates";
import { import {
createTimeEntry, createTimeEntry,
deleteTimeEntry, deleteTimeEntry,
getTimeEntries, getTimeEntries,
@@ -19,10 +19,10 @@ import {
} from "../api/timeEntries"; } from "../api/timeEntries";
import { getTags, type Tag } from "../api/tags"; import { getTags, type Tag } from "../api/tags";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import EmptyStateCard from "../components/EmptyStateCard"; import EmptyStateCard from "../components/EmptyStateCard";
import { InfiniteScroll } from "../components/InfiniteScroll"; import { InfiniteScroll } from "../components/InfiniteScroll";
import { WorkspaceRatesPanel } from "../components/rates/WorkspaceRatesPanel"; import { WorkspaceRatesPanel } from "../components/rates/WorkspaceRatesPanel";
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar"; import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
import JalaliDatePicker from "../components/ui/JalaliDatePicker"; import JalaliDatePicker from "../components/ui/JalaliDatePicker";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
@@ -1262,10 +1262,10 @@ function EntryEditorFields({
<div className="grid min-w-0 flex-1 grid-cols-[minmax(430px,1fr)_minmax(0,220px)_40px_188px_40px] 2xl:grid-cols-[minmax(430px,1fr)_minmax(0,max-content)_40px_188px_40px] items-center"> <div className="grid min-w-0 flex-1 grid-cols-[minmax(430px,1fr)_minmax(0,220px)_40px_188px_40px] 2xl:grid-cols-[minmax(430px,1fr)_minmax(0,max-content)_40px_188px_40px] items-center">
<div className="flex min-w-0 items-center"> <div className="flex min-w-0 items-center">
<Input <Input
value={state.description} value={state.description}
onChange={(event) => onChange({ description: event.target.value })} onChange={(event) => onChange({ description: event.target.value })}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600" className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600"
/> />
<span className="me-2 shrink-0 text-sm font-semibold leading-none text-sky-600 dark:text-sky-400"></span> <span className="me-2 shrink-0 text-sm font-semibold leading-none text-sky-600 dark:text-sky-400"></span>
@@ -1344,11 +1344,11 @@ function EntryEditorFields({
<label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}> <label className={`block font-medium text-slate-700 dark:text-slate-300 ${compact ? "mb-0.5 text-[11px]" : "mb-1 text-sm"}`}>
{t.timesheet?.descriptionLabel || "Description"} {t.timesheet?.descriptionLabel || "Description"}
</label> </label>
<Input <Input
value={state.description} value={state.description}
onChange={(event) => onChange({ description: event.target.value })} onChange={(event) => onChange({ description: event.target.value })}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"} className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"}
/> />
</div> </div>
@@ -1837,7 +1837,7 @@ function MobileRecordedEntryCard({
</p> </p>
{project && ( {project && (
<span className="inline-flex min-w-0 items-center gap-2 text-xs"> <span className="inline-flex min-w-0 items-center gap-2 text-xs">
<span className="shrink-0 text-sky-600 dark:text-sky-400">{"\u2022"}</span> <span className="shrink-0 text-sky-600 dark:text-sky-400">{""}</span>
<span className={`max-w-[10rem] truncate font-medium ${project.isDeleted ? "italic text-slate-500 dark:text-slate-400" : "text-sky-600 dark:text-sky-400"}`}> <span className={`max-w-[10rem] truncate font-medium ${project.isDeleted ? "italic text-slate-500 dark:text-slate-400" : "text-sky-600 dark:text-sky-400"}`}>
{project.isDeleted ? buildDeletedProjectLabel(project.name, deletedProjectLabel) : project.name} {project.isDeleted ? buildDeletedProjectLabel(project.name, deletedProjectLabel) : project.name}
</span> </span>
@@ -2017,13 +2017,13 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
); );
} }
export default function Timesheet() { export default function Timesheet() {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const [isRatesPanelOpen, setIsRatesPanelOpen] = useState(false); const [isRatesPanelOpen, setIsRatesPanelOpen] = useState(false);
const [isRatesPanelLoading, setIsRatesPanelLoading] = useState(false); const [isRatesPanelLoading, setIsRatesPanelLoading] = useState(false);
const [myRates, setMyRates] = useState<MyWorkspaceRatesResponse | null>(null); const [myRates, setMyRates] = useState<MyWorkspaceRatesResponse | null>(null);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isRtl = lang === "fa"; const isRtl = lang === "fa";
const extendedTimesheet = (t.timesheet as { const extendedTimesheet = (t.timesheet as {
deleteTitle?: string; deleteTitle?: string;
@@ -2163,17 +2163,17 @@ export default function Timesheet() {
void loadOptions(); void loadOptions();
}, [activeWorkspace?.id, t.timesheet?.optionsError]); }, [activeWorkspace?.id, t.timesheet?.optionsError]);
useEffect(() => { useEffect(() => {
setGroupedHistory([]); setGroupedHistory([]);
setNextOffset(0); setNextOffset(0);
setHasMoreHistory(false); setHasMoreHistory(false);
}, [activeWorkspace?.id]); }, [activeWorkspace?.id]);
useEffect(() => { useEffect(() => {
setIsRatesPanelOpen(false); setIsRatesPanelOpen(false);
setIsRatesPanelLoading(false); setIsRatesPanelLoading(false);
setMyRates(null); setMyRates(null);
}, [activeWorkspace?.id]); }, [activeWorkspace?.id]);
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
@@ -2606,29 +2606,29 @@ export default function Timesheet() {
setDebouncedSearchQuery(""); setDebouncedSearchQuery("");
}, [setSearchParams]); }, [setSearchParams]);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return; if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
void loadHistory({ offset: nextOffset, append: true }); void loadHistory({ offset: nextOffset, append: true });
}, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]); }, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]);
const openRatesPanel = useCallback(async () => { const openRatesPanel = useCallback(async () => {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
setIsRatesPanelOpen(true); setIsRatesPanelOpen(true);
if (myRates || isRatesPanelLoading) return; if (myRates || isRatesPanelLoading) return;
try { try {
setIsRatesPanelLoading(true); setIsRatesPanelLoading(true);
const response = await getMyWorkspaceRates(activeWorkspace.id); const response = await getMyWorkspaceRates(activeWorkspace.id);
setMyRates(response); setMyRates(response);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.rates?.projectSaveError || "Failed to load rates."); toast.error(t.rates?.projectSaveError || "Failed to load rates.");
setIsRatesPanelOpen(false); setIsRatesPanelOpen(false);
} finally { } finally {
setIsRatesPanelLoading(false); setIsRatesPanelLoading(false);
} }
}, [activeWorkspace?.id, isRatesPanelLoading, myRates, t.rates?.projectSaveError]); }, [activeWorkspace?.id, isRatesPanelLoading, myRates, t.rates?.projectSaveError]);
const handleDiscardTimerDraft = useCallback(async () => { const handleDiscardTimerDraft = useCallback(async () => {
if (!discardTimerModal.entry || isDiscardingTimer) return; if (!discardTimerModal.entry || isDiscardingTimer) return;
@@ -2658,16 +2658,16 @@ export default function Timesheet() {
return ( return (
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900"> <div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p> <p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
</div> </div>
<Button type="button" variant="secondary" onClick={() => void openRatesPanel()} className="gap-2"> <Button type="button" variant="secondary" onClick={() => void openRatesPanel()} className="gap-2">
<Banknote className="h-4 w-4" /> <Banknote className="h-4 w-4" />
{t.rates?.myRatesTitle || "My rates"} {t.rates?.myRatesTitle || "My rates"}
</Button> </Button>
</div> </div>
<div <div
ref={desktopTimerRef} ref={desktopTimerRef}
@@ -2677,11 +2677,11 @@ export default function Timesheet() {
<div className="flex min-w-0 items-center gap-2 px-3 py-3"> <div className="flex min-w-0 items-center gap-2 px-3 py-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Input <Input
value={timerDraft.description} value={timerDraft.description}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))} onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
disabled={isStartingTimer} disabled={isStartingTimer}
className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600" className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600"
/> />
</div> </div>
@@ -2785,11 +2785,11 @@ export default function Timesheet() {
> >
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
value={timerDraft.description} value={timerDraft.description}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))} onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
disabled={isStartingTimer} disabled={isStartingTimer}
className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600" className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600"
/> />
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
@@ -3100,8 +3100,8 @@ export default function Timesheet() {
</Modal> </Modal>
)} )}
{discardTimerModal.entry && ( {discardTimerModal.entry && (
<Modal <Modal
isOpen={discardTimerModal.isOpen} isOpen={discardTimerModal.isOpen}
onClose={closeDiscardTimerModal} onClose={closeDiscardTimerModal}
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"} title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
@@ -3130,15 +3130,15 @@ export default function Timesheet() {
</p> </p>
</div> </div>
</div> </div>
</Modal> </Modal>
)} )}
<WorkspaceRatesPanel <WorkspaceRatesPanel
open={isRatesPanelOpen} open={isRatesPanelOpen}
data={myRates} data={myRates}
isLoading={isRatesPanelLoading} isLoading={isRatesPanelLoading}
onClose={() => setIsRatesPanelOpen(false)} onClose={() => setIsRatesPanelOpen(false)}
/> />
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router-dom'; import { useBlocker, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { AlertCircle, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react'; import { AlertCircle, ShieldCheck, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react';
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates'; import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
@@ -29,6 +29,7 @@ import { Select } from '../components/ui/Select';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput'; import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields'; import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
const toEnglishDigits = (str: string) => { const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -82,6 +83,7 @@ export default function EditWorkspace() {
// Modal State // Modal State
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null); const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -115,16 +117,16 @@ export default function EditWorkspace() {
setThumbnailFile(null); setThumbnailFile(null);
return; return;
} }
const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return; return;
} }
const maxBytes = 2 * 1024 * 1024; const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) { if (file.size > maxBytes) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return; return;
} }
setThumbnailFile(file); setThumbnailFile(file);
setClearThumbnail(false); setClearThumbnail(false);
}; };
@@ -338,8 +340,9 @@ export default function EditWorkspace() {
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>; if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
return ( return (
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6"> <>
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.editTitle || "Edit Workspace"} {t.workspace?.editTitle || "Edit Workspace"}
</h1> </h1>
@@ -372,7 +375,7 @@ export default function EditWorkspace() {
</div> </div>
<div className="mb-6"> <div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"> <label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"} {t.workspace?.thumbnailLabel || "Thumbnail"}
</label> </label>
<label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"> <label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700">
@@ -392,9 +395,9 @@ export default function EditWorkspace() {
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<UploadCloud className="h-10 w-10 text-slate-500 dark:text-slate-400" /> <UploadCloud className="h-10 w-10 text-slate-500 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-500 dark:text-slate-400"> <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{t.workspace?.uploadImage || "Click to upload image"} {t.workspace?.uploadImage || "Click to upload image"}
</span> </span>
</div> </div>
)} )}
@@ -415,9 +418,9 @@ export default function EditWorkspace() {
}} }}
className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400" className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400"
> >
{t.workspace?.removeImage || "Remove image"} {t.workspace?.removeImage || "Remove image"}
</button> </button>
)} )}
</div> </div>
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0"> <div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
<Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}> <Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}>
@@ -432,19 +435,32 @@ export default function EditWorkspace() {
<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="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10"> <div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{ t.workspace?.members || "Members" } <h2 className="text-lg font-semibold text-slate-900 dark:text-white">
</h2> { t.workspace?.members || "Members" }
</h2>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100"> {canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" /> <Button
<p className="leading-6"> type="button"
{t.workspace?.projectRateHint || variant="secondary"
"Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."} onClick={() => setIsProjectAccessModalOpen(true)}
</p> className="gap-2 self-start sm:self-auto"
</div> >
<ShieldCheck className="h-4 w-4" />
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && ( {t.projects?.manageAccess || "Projects & Rates"}
</Button>
) : null}
</div>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="leading-6">
{t.workspace?.projectRateHint ||
"Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."}
</p>
</div>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
type="text" type="text"
@@ -659,5 +675,57 @@ export default function EditWorkspace() {
</Dialog> </Dialog>
</Transition> </Transition>
</div> </div>
);
} {canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<ProjectAccessModal
isOpen={isProjectAccessModalOpen}
onClose={() => setIsProjectAccessModalOpen(false)}
workspaceId={id}
onApplied={() => {}}
labels={{
title: t.projects?.accessModalTitle || "Projects & Rates",
description:
t.projects?.accessModalDescription ||
"Manage project access for members and guests, and set project-specific rates for any workspace user.",
close: t.actions?.cancel || "Close",
member: t.projects?.accessMemberLabel || "User",
projects: t.sidebar?.projects || "Projects",
loading: t.loading || "Loading...",
noMembers: t.projects?.accessNoMembers || "No workspace users were found.",
noProjects: t.projects?.accessNoProjects || "No projects found.",
searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...",
allClients: t.reports?.allClients || "All clients",
selectAllVisible: t.projects?.accessSelectVisible || "Select all visible",
clearSelection: t.projects?.accessClearSelection || "Clear selection",
selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client",
grantSelected: t.projects?.accessGrant || "Grant selected",
revokeSelected: t.projects?.accessRevoke || "Revoke selected",
accessGranted: t.projects?.accessGrantSuccess || "Project access granted.",
accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.",
memberRole: t.workspace?.roleLabel || "Role",
client: t.projects?.clientLabel || "Client",
noClient: t.projects?.noClient || "No client",
accessOn: t.projects?.accessOn || "Has access",
accessOff: t.projects?.accessOff || "No access",
loadError: t.projects?.accessLoadError || "Failed to load project access state.",
saveError: t.projects?.accessSaveError || "Failed to update project access.",
workspaceRate: t.rates?.workspaceRate || "Workspace rate",
projectOverride: t.rates?.projectOverride || "Project override",
inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate",
noRate: t.rates?.noRate || "No rate",
hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00",
currencyPlaceholder: t.rates?.currencyPlaceholder || "USD",
removeRate: t.rates?.removeRate || "Remove rate",
projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.",
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.",
implicitAccessHint:
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}}
/>
) : null}
</>
);
}

View File

@@ -3,7 +3,7 @@ import { toast } from "sonner"
import { ApiError } from "../../api/client" import { ApiError } from "../../api/client"
import { setSessionTokens } from "../../lib/session" import { setSessionTokens } from "../../lib/session"
const PERSIAN_DIGITS = ["\u06f0", "\u06f1", "\u06f2", "\u06f3", "\u06f4", "\u06f5", "\u06f6", "\u06f7", "\u06f8", "\u06f9"] const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]
export const localizeDigits = (value: string, isRtl: boolean) => export const localizeDigits = (value: string, isRtl: boolean) =>
isRtl ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) : value isRtl ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) : value