feat(workspaces): turn workspace detail into a management hub
This commit is contained in:
@@ -157,10 +157,24 @@ export const en = {
|
|||||||
noWorkspaceTitle: "Welcome!",
|
noWorkspaceTitle: "Welcome!",
|
||||||
noWorkspaceDesc: "Please create your first workspace.",
|
noWorkspaceDesc: "Please create your first workspace.",
|
||||||
back: "Back to Workspaces",
|
back: "Back to Workspaces",
|
||||||
roleLabel: "Your Role",
|
roleLabel: "Your Role",
|
||||||
roles: {
|
openReports: "Open reports",
|
||||||
owner: "Owner",
|
statsMembers: "Members",
|
||||||
admin: "Admin",
|
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",
|
member: "Member",
|
||||||
guest: "Guest",
|
guest: "Guest",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -159,6 +159,20 @@ export const fa = {
|
|||||||
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
||||||
back: "بازگشت به ورکاسپیسها",
|
back: "بازگشت به ورکاسپیسها",
|
||||||
roleLabel: "نقش شما",
|
roleLabel: "نقش شما",
|
||||||
|
openReports: "مشاهده گزارشها",
|
||||||
|
statsMembers: "اعضا",
|
||||||
|
statsRates: "نرخهای ثبتشده",
|
||||||
|
statsOwnersAdmins: "مالکان و ادمینها",
|
||||||
|
statsGuests: "مهمانها",
|
||||||
|
membersSectionTitle: "اعضا",
|
||||||
|
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||||
|
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||||
|
manageMembers: "مدیریت اعضا",
|
||||||
|
joinedLabel: "زمان عضویت",
|
||||||
|
resourcesTitle: "منابع",
|
||||||
|
resourceOpen: "مشاهده",
|
||||||
|
roleDistributionTitle: "توزیع نقشها",
|
||||||
|
unknownMember: "عضو ناشناس",
|
||||||
roles: {
|
roles: {
|
||||||
owner: "مالک",
|
owner: "مالک",
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
@@ -202,10 +216,10 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
title: "مشتریان",
|
title: "مشتریها",
|
||||||
description: (workspaceName: string) => `مدیریت مشتریان برای ${workspaceName}`,
|
description: (workspaceName: string) => `مدیریت مشتریها برای ${workspaceName}`,
|
||||||
addClient: "افزودن مشتری",
|
addClient: "افزودن مشتری",
|
||||||
searchPlaceholder: "جستجوی مشتریان...",
|
searchPlaceholder: "جستجوی مشتریها...",
|
||||||
noClients: "مشتری یافت نشد",
|
noClients: "مشتری یافت نشد",
|
||||||
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
|
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
|
||||||
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
|
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
|
||||||
@@ -225,7 +239,7 @@ export const fa = {
|
|||||||
saveChanges: "ذخیره تغییرات",
|
saveChanges: "ذخیره تغییرات",
|
||||||
errors: {
|
errors: {
|
||||||
createFailed: "خطا در ایجاد مشتری",
|
createFailed: "خطا در ایجاد مشتری",
|
||||||
fetchFailed: "خطا در دریافت لیست مشتریان",
|
fetchFailed: "خطا در دریافت لیست مشتریها",
|
||||||
updateFailed: "خطا در ویرایش مشتری",
|
updateFailed: "خطا در ویرایش مشتری",
|
||||||
deleteFailed: "خطا در حذف مشتری",
|
deleteFailed: "خطا در حذف مشتری",
|
||||||
},
|
},
|
||||||
@@ -245,7 +259,7 @@ export const fa = {
|
|||||||
timesheet: 'تایمشیت',
|
timesheet: 'تایمشیت',
|
||||||
reports: 'گزارشها',
|
reports: 'گزارشها',
|
||||||
workspaces: 'ورکاسپیسها',
|
workspaces: 'ورکاسپیسها',
|
||||||
clients: 'مشتریان',
|
clients: 'مشتریها',
|
||||||
projects: "پروژهها",
|
projects: "پروژهها",
|
||||||
tags: "تگها",
|
tags: "تگها",
|
||||||
expand: 'باز کردن',
|
expand: 'باز کردن',
|
||||||
@@ -287,7 +301,7 @@ export const fa = {
|
|||||||
editProject: "ویرایش پروژه",
|
editProject: "ویرایش پروژه",
|
||||||
restore: "بازیابی",
|
restore: "بازیابی",
|
||||||
archive: "بایگانی",
|
archive: "بایگانی",
|
||||||
clientFetchError: "خطا در دریافت لیست مشتریان.",
|
clientFetchError: "خطا در دریافت لیست مشتریها.",
|
||||||
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
||||||
creator: "سازنده",
|
creator: "سازنده",
|
||||||
addUser: "افزودن کاربر",
|
addUser: "افزودن کاربر",
|
||||||
@@ -470,11 +484,11 @@ export const fa = {
|
|||||||
loadMore: "بارگذاری بیشتر",
|
loadMore: "بارگذاری بیشتر",
|
||||||
markAllRead: "خواندن همه",
|
markAllRead: "خواندن همه",
|
||||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||||
openError: "باز کردن اعلان با خطا مواجه شد.",
|
openError: "باز کردن اعلان با خطا مواجه شد.",
|
||||||
newTitle: "اعلان جدید",
|
newTitle: "اعلان جدید",
|
||||||
openAction: "باز کردن",
|
openAction: "باز کردن",
|
||||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,101 +1,439 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
|
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 { useTranslation } from '../hooks/useTranslation';
|
||||||
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
|
import {
|
||||||
import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions';
|
CLIENTS_VIEW,
|
||||||
|
PROJECTS_VIEW,
|
||||||
export default function WorkspaceDetail() {
|
TAGS_VIEW,
|
||||||
const { id } = useParams<{ id: string }>();
|
WORKSPACE_DELETE,
|
||||||
const navigate = useNavigate();
|
WORKSPACE_EDIT,
|
||||||
const { t, lang } = useTranslation();
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
WORKSPACE_VIEW,
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
canWorkspace,
|
||||||
|
} from '../lib/permissions';
|
||||||
const isRtl = lang === 'fa';
|
|
||||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
type ResourceCounts = {
|
||||||
|
projects: number;
|
||||||
useEffect(() => {
|
clients: number;
|
||||||
if (id) loadWorkspace();
|
tags: number;
|
||||||
}, [id]);
|
};
|
||||||
|
|
||||||
const loadWorkspace = async () => {
|
const roleBadgeStyles: Record<string, string> = {
|
||||||
try {
|
owner: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
const data = await getWorkspace(id!);
|
admin: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
setWorkspace(data);
|
member: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
} catch (error) {
|
guest: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||||
console.error(error);
|
};
|
||||||
navigate('/workspaces');
|
|
||||||
} finally {
|
export default function WorkspaceDetail() {
|
||||||
setIsLoading(false);
|
const { id } = useParams<{ id: string }>();
|
||||||
}
|
const navigate = useNavigate();
|
||||||
};
|
const { t, lang } = useTranslation();
|
||||||
|
const { setActiveWorkspace } = useWorkspace();
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!window.confirm(t.workspace?.confirmDelete) || !id) return;
|
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||||
try {
|
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
|
||||||
await deleteWorkspace(id);
|
const [rates, setRates] = useState<WorkspaceUserRate[]>([]);
|
||||||
navigate('/workspaces');
|
const [counts, setCounts] = useState<ResourceCounts>({ projects: 0, clients: 0, tags: 0 });
|
||||||
} catch (error) {
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
console.error(error);
|
|
||||||
}
|
const isRtl = lang === 'fa';
|
||||||
};
|
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||||
|
|
||||||
if (isLoading || !workspace) {
|
useEffect(() => {
|
||||||
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
|
if (!id) return;
|
||||||
}
|
void loadWorkspace();
|
||||||
|
}, [id]);
|
||||||
const canEdit = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
|
||||||
const canDelete = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
const loadWorkspace = async () => {
|
||||||
|
try {
|
||||||
return (
|
const data = await getWorkspace(id!);
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
setWorkspace(data);
|
||||||
<button
|
|
||||||
onClick={() => navigate('/workspaces')}
|
const canViewMembers = canWorkspace(data.my_role, WORKSPACE_MEMBERS_VIEW);
|
||||||
className="flex items-center gap-2 text-slate-500 hover:text-slate-900 dark:hover:text-white mb-6 transition-colors"
|
const canViewClients = canWorkspace(data.my_role, CLIENTS_VIEW);
|
||||||
>
|
const canViewProjects = canWorkspace(data.my_role, PROJECTS_VIEW);
|
||||||
<BackIcon className="h-5 w-5" />
|
const canViewTags = canWorkspace(data.my_role, TAGS_VIEW);
|
||||||
<span>{t.workspace?.back}</span>
|
const canViewRates = canWorkspace(data.my_role, WORKSPACE_EDIT);
|
||||||
</button>
|
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm relative">
|
|
||||||
<div className="flex justify-between items-start mb-6">
|
if (canViewMembers) {
|
||||||
<div>
|
tasks.push(
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
fetchWorkspaceMemberships({ workspace: id!, limit: 200, offset: 0 }).then((response) => {
|
||||||
{workspace.name}
|
setMembers(response.results || []);
|
||||||
</h1>
|
}),
|
||||||
<span className="inline-block px-3 py-1 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-sm rounded-full font-medium">
|
);
|
||||||
{workspace.my_role ? t.workspace?.roles[workspace.my_role] : "-"}
|
} else {
|
||||||
</span>
|
setMembers([]);
|
||||||
</div>
|
}
|
||||||
|
|
||||||
{canEdit && (
|
if (canViewRates) {
|
||||||
<div className="flex gap-2">
|
tasks.push(
|
||||||
<button
|
getWorkspaceUserRates(id!).then((response) => {
|
||||||
onClick={() => navigate(`/workspaces/${id}/edit`)}
|
setRates(response.results || []);
|
||||||
className="p-2 text-slate-500 hover:text-emerald-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
|
}),
|
||||||
>
|
);
|
||||||
<Edit2 className="h-5 w-5" />
|
} else {
|
||||||
</button>
|
setRates([]);
|
||||||
{canDelete && (
|
}
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
tasks.push(
|
||||||
className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
|
Promise.all([
|
||||||
>
|
canViewProjects ? getProjects(id!, { limit: 1, offset: 0, is_archived: false }) : Promise.resolve({ count: 0 }),
|
||||||
<Trash2 className="h-5 w-5" />
|
canViewClients ? getClients(id!, '', '', 1, 0) : Promise.resolve({ count: 0 }),
|
||||||
</button>
|
canViewTags ? getTags(id!, { limit: 1, offset: 0 }) : Promise.resolve({ count: 0, results: [] }),
|
||||||
)}
|
]).then(([projectsData, clientsData, tagsData]) => {
|
||||||
</div>
|
setCounts({
|
||||||
)}
|
projects: projectsData.count || 0,
|
||||||
</div>
|
clients: clientsData.count || 0,
|
||||||
|
tags: tagsData.count || 0,
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
});
|
||||||
<h3 className="text-lg font-semibold mb-2">{t.workspace?.descriptionLabel}</h3>
|
}),
|
||||||
<p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
|
);
|
||||||
{workspace.description || t.workspace?.noDescription}
|
|
||||||
</p>
|
await Promise.all(tasks);
|
||||||
</div>
|
} catch (error) {
|
||||||
</div>
|
console.error(error);
|
||||||
</div>
|
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 <div className="p-8 text-center">{t.workspace?.loading}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-4 sm:p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/workspaces')}
|
||||||
|
className="flex items-center gap-2 text-slate-500 transition-colors hover:text-slate-900 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<BackIcon className="h-5 w-5" />
|
||||||
|
<span>{t.workspace?.back}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-8">
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||||
|
{workspace.name}
|
||||||
|
</h1>
|
||||||
|
<span className={`inline-flex rounded-full px-3 py-1 text-sm font-semibold ${roleBadgeStyles[workspace.my_role || 'guest']}`}>
|
||||||
|
{workspace.my_role ? t.workspace?.roles[workspace.my_role] : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-3xl whitespace-pre-wrap text-sm leading-7 text-slate-600 dark:text-slate-400">
|
||||||
|
{workspace.description || t.workspace?.noDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canViewReports && (
|
||||||
|
<button
|
||||||
|
onClick={() => openWorkspaceRoute('/reports')}
|
||||||
|
className="inline-flex h-11 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{t.workspace?.openReports || 'Open reports'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/workspaces/${id}/edit`)}
|
||||||
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-xl border px-4 text-sm font-semibold transition bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400 border-blue-200 hover:border-blue-300 hover:bg-blue-100 dark:border-blue-900/60 dark:hover:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
<span>{t.actions?.edit || 'Edit'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-xl border border-red-200 bg-red-50 px-4 text-sm font-semibold text-red-700 transition hover:border-red-300 hover:bg-red-100 dark:border-red-900/60 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
<span>{t.actions?.delete || 'Delete'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsMembers || 'Members'}</span>
|
||||||
|
<Users className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(activeMembers.length)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsRates || 'Rates set'}</span>
|
||||||
|
<Banknote className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(rates.length)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsOwnersAdmins || 'Owners & admins'}</span>
|
||||||
|
<Users className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{displayNumber(roleCounts.owner + roleCounts.admin)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsGuests || 'Guests'}</span>
|
||||||
|
<Users className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(roleCounts.guest)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.workspace?.resourcesTitle || 'Resources'}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{resourceCards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={card.key}
|
||||||
|
onClick={card.onClick}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 p-4 text-start transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl bg-white p-2 text-slate-600 shadow-sm dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">{card.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BackIcon className="h-4 w-4 rtl:rotate-180 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="border-b border-slate-100 p-6 dark:border-slate-800">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.workspace?.membersSectionTitle || t.workspace?.members || 'Members'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/workspaces/${id}/edit`)}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{t.workspace?.manageMembers || 'Manage members'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canViewMembers ? (
|
||||||
|
<div className={`divide-y divide-slate-100 dark:divide-slate-800 ${activeMembers.length > 6 ? 'max-h-[36rem] overflow-y-auto' : ''}`}>
|
||||||
|
{activeMembers.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.workspace?.noMembers || 'No members found.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeMembers.map((member) => {
|
||||||
|
const rate = memberRateMap.get(member.user.id);
|
||||||
|
return (
|
||||||
|
<div key={member.id} className="flex flex-col gap-4 p-5 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{getMemberName(member)}
|
||||||
|
</h3>
|
||||||
|
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeStyles[member.role]}`}>
|
||||||
|
{t.workspace?.roles[member.role]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<p>{getMemberContact(member)}</p>
|
||||||
|
<p>
|
||||||
|
{t.workspace?.joinedLabel || 'Joined'}: {formatDate(member.joined_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800/80 sm:min-w-[210px]">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.workspaceRate || 'Workspace rate'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatRateUnit(rate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.workspace?.membersLocked || 'Only owners and admins can view the full member list.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user