feat(projects): expose implicit-access roles in projects and rates modal
This commit is contained in:
@@ -494,6 +494,9 @@ export const Projects: React.FC = () => {
|
||||
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.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
|
||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
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 { toast } from 'sonner';
|
||||
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 { TextAreaInput } from '../components/ui/TextAreaInput';
|
||||
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
|
||||
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
|
||||
|
||||
const toEnglishDigits = (str: string) => {
|
||||
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
||||
@@ -82,6 +83,7 @@ export default function EditWorkspace() {
|
||||
// Modal State
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
|
||||
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -115,16 +117,16 @@ export default function EditWorkspace() {
|
||||
setThumbnailFile(null);
|
||||
return;
|
||||
}
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 2 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
|
||||
return;
|
||||
}
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 2 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
|
||||
return;
|
||||
}
|
||||
setThumbnailFile(file);
|
||||
setClearThumbnail(false);
|
||||
};
|
||||
@@ -338,8 +340,9 @@ export default function EditWorkspace() {
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
|
||||
|
||||
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">
|
||||
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">
|
||||
<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>
|
||||
@@ -372,7 +375,7 @@ export default function EditWorkspace() {
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<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 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">
|
||||
<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">
|
||||
{t.workspace?.uploadImage || "Click to upload image"}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
{t.workspace?.uploadImage || "Click to upload image"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -415,9 +418,9 @@ export default function EditWorkspace() {
|
||||
}}
|
||||
className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
{t.workspace?.removeImage || "Remove image"}
|
||||
</button>
|
||||
)}
|
||||
{t.workspace?.removeImage || "Remove image"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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')}>
|
||||
@@ -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="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>
|
||||
|
||||
<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="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{ t.workspace?.members || "Members" }
|
||||
</h2>
|
||||
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsProjectAccessModalOpen(true)}
|
||||
className="gap-2 self-start sm:self-auto"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{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">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -659,5 +675,57 @@ export default function EditWorkspace() {
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user