From eee22ad6fbf84108e263392e8f4fd27967634c23 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 27 Apr 2026 20:52:19 +0330 Subject: [PATCH] feat(workspaces): turn workspace detail into a management hub --- src/locales/en.ts | 22 +- src/locales/fa.ts | 36 ++- src/pages/WorkspaceDetail.tsx | 538 +++++++++++++++++++++++++++------- 3 files changed, 481 insertions(+), 115 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index c6fb1a0..b4d49b0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -157,10 +157,24 @@ export const en = { noWorkspaceTitle: "Welcome!", noWorkspaceDesc: "Please create your first workspace.", back: "Back to Workspaces", - roleLabel: "Your Role", - roles: { - owner: "Owner", - admin: "Admin", + roleLabel: "Your Role", + openReports: "Open reports", + statsMembers: "Members", + statsRates: "Rates set", + statsOwnersAdmins: "Owners & admins", + statsGuests: "Guests", + membersSectionTitle: "Members", + membersSectionSubtitle: "People in this workspace and their current roles.", + membersLocked: "Only owners and admins can view the full member list.", + manageMembers: "Manage members", + joinedLabel: "Joined", + resourcesTitle: "Resources", + resourceOpen: "Open", + roleDistributionTitle: "Role distribution", + unknownMember: "Unknown member", + roles: { + owner: "Owner", + admin: "Admin", member: "Member", guest: "Guest", }, diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 54bfd08..30ee81c 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -159,6 +159,20 @@ export const fa = { noWorkspaceDesc: "لطفاً اولین ورک‌اسپیس خود را ایجاد کنید.", back: "بازگشت به ورک‌اسپیس‌ها", roleLabel: "نقش شما", + openReports: "مشاهده گزارش‌ها", + statsMembers: "اعضا", + statsRates: "نرخ‌های ثبت‌شده", + statsOwnersAdmins: "مالکان و ادمین‌ها", + statsGuests: "مهمان‌ها", + membersSectionTitle: "اعضا", + membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.", + membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.", + manageMembers: "مدیریت اعضا", + joinedLabel: "زمان عضویت", + resourcesTitle: "منابع", + resourceOpen: "مشاهده", + roleDistributionTitle: "توزیع نقش‌ها", + unknownMember: "عضو ناشناس", roles: { owner: "مالک", admin: "ادمین", @@ -202,10 +216,10 @@ export const fa = { }, clients: { - title: "مشتریان", - description: (workspaceName: string) => `مدیریت مشتریان برای ${workspaceName}`, + title: "مشتری‌ها", + description: (workspaceName: string) => `مدیریت مشتری‌ها برای ${workspaceName}`, addClient: "افزودن مشتری", - searchPlaceholder: "جستجوی مشتریان...", + searchPlaceholder: "جستجوی مشتری‌ها...", noClients: "مشتری یافت نشد", noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.", noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.", @@ -225,7 +239,7 @@ export const fa = { saveChanges: "ذخیره تغییرات", errors: { createFailed: "خطا در ایجاد مشتری", - fetchFailed: "خطا در دریافت لیست مشتریان", + fetchFailed: "خطا در دریافت لیست مشتری‌ها", updateFailed: "خطا در ویرایش مشتری", deleteFailed: "خطا در حذف مشتری", }, @@ -245,7 +259,7 @@ export const fa = { timesheet: 'تایم‌شیت', reports: 'گزارش‌ها', workspaces: 'ورک‌اسپیس‌ها', - clients: 'مشتریان', + clients: 'مشتری‌ها', projects: "پروژه‌ها", tags: "تگ‌ها", expand: 'باز کردن', @@ -287,7 +301,7 @@ export const fa = { editProject: "ویرایش پروژه", restore: "بازیابی", archive: "بایگانی", - clientFetchError: "خطا در دریافت لیست مشتریان.", + clientFetchError: "خطا در دریافت لیست مشتری‌ها.", memberAlreadyAdded: "این کاربر قبلا اضافه شده است", creator: "سازنده", addUser: "افزودن کاربر", @@ -470,11 +484,11 @@ export const fa = { loadMore: "بارگذاری بیشتر", markAllRead: "خواندن همه", markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.", - markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", - deleteError: "حذف اعلان با خطا مواجه شد.", - loadError: "دریافت اعلان‌ها با خطا مواجه شد.", - openError: "باز کردن اعلان با خطا مواجه شد.", - newTitle: "اعلان جدید", + markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.", + deleteError: "حذف اعلان با خطا مواجه شد.", + loadError: "دریافت اعلان‌ها با خطا مواجه شد.", + openError: "باز کردن اعلان با خطا مواجه شد.", + newTitle: "اعلان جدید", openAction: "باز کردن", summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`, }, diff --git a/src/pages/WorkspaceDetail.tsx b/src/pages/WorkspaceDetail.tsx index 09edbf9..ffa14da 100644 --- a/src/pages/WorkspaceDetail.tsx +++ b/src/pages/WorkspaceDetail.tsx @@ -1,101 +1,439 @@ -import { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + ArrowLeft, + ArrowRight, + Banknote, + BriefcaseBusiness, + Edit2, + FolderKanban, + Tag, + Trash2, + Users, +} from 'lucide-react'; + +import { getClients } from '../api/clients'; +import { getProjects } from '../api/projects'; +import { getWorkspaceUserRates, type WorkspaceUserRate } from '../api/rates'; +import { getTags } from '../api/tags'; +import { + deleteWorkspace, + fetchWorkspaceMemberships, + getWorkspace, + type Workspace, + type WorkspaceMembership, +} from '../api/workspaces'; +import { useWorkspace } from '../context/WorkspaceContext'; 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 }>(); - const navigate = useNavigate(); - const { t, lang } = useTranslation(); - const [workspace, setWorkspace] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - const isRtl = lang === 'fa'; - const BackIcon = isRtl ? ArrowRight : ArrowLeft; - - useEffect(() => { - if (id) loadWorkspace(); - }, [id]); - - const loadWorkspace = async () => { - try { - const data = await getWorkspace(id!); - setWorkspace(data); - } catch (error) { - console.error(error); - navigate('/workspaces'); - } finally { - setIsLoading(false); - } - }; - - const handleDelete = async () => { - if (!window.confirm(t.workspace?.confirmDelete) || !id) return; - try { - await deleteWorkspace(id); - navigate('/workspaces'); - } catch (error) { - console.error(error); - } - }; - - if (isLoading || !workspace) { - return
{t.workspace?.loading}
; - } - - const canEdit = canWorkspace(workspace.my_role, WORKSPACE_EDIT); - const canDelete = canWorkspace(workspace.my_role, WORKSPACE_DELETE); - - return ( -
- - -
-
-
-

- {workspace.name} -

- - {workspace.my_role ? t.workspace?.roles[workspace.my_role] : "-"} - -
- - {canEdit && ( -
- - {canDelete && ( - - )} -
- )} -
- -
-

{t.workspace?.descriptionLabel}

-

- {workspace.description || t.workspace?.noDescription} -

-
-
-
- ); -} +import { + CLIENTS_VIEW, + PROJECTS_VIEW, + TAGS_VIEW, + WORKSPACE_DELETE, + WORKSPACE_EDIT, + WORKSPACE_MEMBERS_VIEW, + WORKSPACE_VIEW, + canWorkspace, +} from '../lib/permissions'; + +type ResourceCounts = { + projects: number; + clients: number; + tags: number; +}; + +const roleBadgeStyles: Record = { + owner: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + admin: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + member: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + guest: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300', +}; + +export default function WorkspaceDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { t, lang } = useTranslation(); + const { setActiveWorkspace } = useWorkspace(); + + const [workspace, setWorkspace] = useState(null); + const [members, setMembers] = useState([]); + const [rates, setRates] = useState([]); + const [counts, setCounts] = useState({ projects: 0, clients: 0, tags: 0 }); + const [isLoading, setIsLoading] = useState(true); + + const isRtl = lang === 'fa'; + const BackIcon = isRtl ? ArrowRight : ArrowLeft; + + useEffect(() => { + if (!id) return; + void loadWorkspace(); + }, [id]); + + const loadWorkspace = async () => { + try { + const data = await getWorkspace(id!); + setWorkspace(data); + + const canViewMembers = canWorkspace(data.my_role, WORKSPACE_MEMBERS_VIEW); + const canViewClients = canWorkspace(data.my_role, CLIENTS_VIEW); + const canViewProjects = canWorkspace(data.my_role, PROJECTS_VIEW); + const canViewTags = canWorkspace(data.my_role, TAGS_VIEW); + const canViewRates = canWorkspace(data.my_role, WORKSPACE_EDIT); + + const tasks: Promise[] = []; + + if (canViewMembers) { + tasks.push( + fetchWorkspaceMemberships({ workspace: id!, limit: 200, offset: 0 }).then((response) => { + setMembers(response.results || []); + }), + ); + } else { + setMembers([]); + } + + if (canViewRates) { + tasks.push( + getWorkspaceUserRates(id!).then((response) => { + setRates(response.results || []); + }), + ); + } else { + setRates([]); + } + + tasks.push( + Promise.all([ + canViewProjects ? getProjects(id!, { limit: 1, offset: 0, is_archived: false }) : Promise.resolve({ count: 0 }), + canViewClients ? getClients(id!, '', '', 1, 0) : Promise.resolve({ count: 0 }), + canViewTags ? getTags(id!, { limit: 1, offset: 0 }) : Promise.resolve({ count: 0, results: [] }), + ]).then(([projectsData, clientsData, tagsData]) => { + setCounts({ + projects: projectsData.count || 0, + clients: clientsData.count || 0, + tags: tagsData.count || 0, + }); + }), + ); + + await Promise.all(tasks); + } catch (error) { + console.error(error); + navigate('/workspaces'); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async () => { + if (!window.confirm(t.workspace?.confirmDelete) || !id) return; + try { + await deleteWorkspace(id); + navigate('/workspaces'); + } catch (error) { + console.error(error); + } + }; + + const activeMembers = members.filter((member) => member.is_active); + const roleCounts = useMemo( + () => + activeMembers.reduce( + (acc, member) => { + acc[member.role] += 1; + return acc; + }, + { owner: 0, admin: 0, member: 0, guest: 0 }, + ), + [activeMembers], + ); + + const memberRateMap = useMemo( + () => new Map(rates.map((rate) => [rate.user, rate])), + [rates], + ); + + const displayNumber = (value: number) => + new Intl.NumberFormat(lang === 'fa' ? 'fa-IR' : 'en-US').format(value); + + const formatDate = (value?: string) => { + if (!value) return '-'; + try { + return new Intl.DateTimeFormat(lang === 'fa' ? 'fa-IR' : 'en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(value)); + } catch { + return value; + } + }; + + const getMemberName = (member: WorkspaceMembership) => { + const firstName = member.user?.first_name?.trim() || ''; + const lastName = member.user?.last_name?.trim() || ''; + const fullName = `${firstName} ${lastName}`.trim(); + return fullName || member.user?.email || t.workspace?.unknownMember || 'Unknown member'; + }; + + const getMemberContact = (member: WorkspaceMembership) => { + return member.user?.mobile || member.user?.email || '-'; + }; + + const formatRateUnit = (rate?: WorkspaceUserRate) => { + if (!rate) return t.rates?.noRate || 'No rate'; + const unitLabel = + lang === 'fa' + ? rate.price_unit?.local_name || rate.price_unit?.code || rate.currency + : rate.price_unit?.code || rate.currency; + return `${rate.hourly_rate} ${unitLabel}`; + }; + + const workspaceRole = workspace?.my_role; + const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT); + const canDelete = canWorkspace(workspaceRole, WORKSPACE_DELETE); + const canViewMembers = canWorkspace(workspaceRole, WORKSPACE_MEMBERS_VIEW); + const canViewReports = canWorkspace(workspaceRole, WORKSPACE_VIEW); + + if (isLoading || !workspace) { + return
{t.workspace?.loading}
; + } + + const openWorkspaceRoute = (path: string) => { + setActiveWorkspace(workspace); + navigate(path); + }; + + const resourceCards = [ + { + key: 'projects', + title: t.sidebar?.projects || 'Projects', + value: displayNumber(counts.projects), + icon: FolderKanban, + onClick: () => openWorkspaceRoute('/projects'), + visible: canWorkspace(workspace.my_role, PROJECTS_VIEW), + }, + { + key: 'clients', + title: t.sidebar?.clients || 'Clients', + value: displayNumber(counts.clients), + icon: BriefcaseBusiness, + onClick: () => openWorkspaceRoute('/clients'), + visible: canWorkspace(workspace.my_role, CLIENTS_VIEW), + }, + { + key: 'tags', + title: t.sidebar?.tags || 'Tags', + value: displayNumber(counts.tags), + icon: Tag, + onClick: () => openWorkspaceRoute('/tags'), + visible: canWorkspace(workspace.my_role, TAGS_VIEW), + }, + { + key: 'reports', + title: t.sidebar?.reports || 'Reports', + value: t.workspace?.resourceOpen || 'Open', + icon: Banknote, + onClick: () => openWorkspaceRoute('/reports'), + visible: canViewReports, + }, + ].filter((item) => item.visible); + + return ( +
+ + +
+
+
+
+

+ {workspace.name} +

+ + {workspace.my_role ? t.workspace?.roles[workspace.my_role] : '-'} + +
+

+ {workspace.description || t.workspace?.noDescription} +

+
+ +
+ {canViewReports && ( + + )} + {canEdit && ( + + )} + {canDelete && ( + + )} +
+
+
+ +
+
+
+ {t.workspace?.statsMembers || 'Members'} + +
+

{displayNumber(activeMembers.length)}

+
+ +
+
+ {t.workspace?.statsRates || 'Rates set'} + +
+

{displayNumber(rates.length)}

+
+ +
+
+ {t.workspace?.statsOwnersAdmins || 'Owners & admins'} + +
+

+ {displayNumber(roleCounts.owner + roleCounts.admin)} +

+
+ +
+
+ {t.workspace?.statsGuests || 'Guests'} + +
+

{displayNumber(roleCounts.guest)}

+
+
+ +
+

+ {t.workspace?.resourcesTitle || 'Resources'} +

+
+ {resourceCards.map((card) => { + const Icon = card.icon; + return ( + + ); + })} +
+
+ +
+
+
+
+

+ {t.workspace?.membersSectionTitle || t.workspace?.members || 'Members'} +

+

+ {canViewMembers + ? t.workspace?.membersSectionSubtitle || 'People in this workspace and their current roles.' + : t.workspace?.membersLocked || 'Only owners and admins can view the full member list.'} +

+
+ {canEdit && ( + + )} +
+
+ + {canViewMembers ? ( +
6 ? 'max-h-[36rem] overflow-y-auto' : ''}`}> + {activeMembers.length === 0 ? ( +
+ {t.workspace?.noMembers || 'No members found.'} +
+ ) : ( + activeMembers.map((member) => { + const rate = memberRateMap.get(member.user.id); + return ( +
+
+
+
+

+ {getMemberName(member)} +

+ + {t.workspace?.roles[member.role]} + +
+
+

{getMemberContact(member)}

+

+ {t.workspace?.joinedLabel || 'Joined'}: {formatDate(member.joined_at)} +

+
+
+ +
+

+ {t.rates?.workspaceRate || 'Workspace rate'} +

+

+ {formatRateUnit(rate)} +

+
+
+
+ ); + }) + )} +
+ ) : ( +
+ {t.workspace?.membersLocked || 'Only owners and admins can view the full member list.'} +
+ )} +
+
+ ); +}