feat(workspaces): add bulk member import modal

This commit is contained in:
2026-06-18 22:53:44 +03:30
parent 29cadb83e6
commit 55ba274346
7 changed files with 961 additions and 28 deletions

View File

@@ -28,8 +28,9 @@ import { InfiniteScroll } from '../components/InfiniteScroll';
import { Select } from '../components/ui/Select';
import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
import { WorkspaceMemberImportModal } from '../components/workspaces/WorkspaceMemberImportModal';
const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -81,9 +82,10 @@ export default function EditWorkspace() {
const [isLoadingMembers, setIsLoadingMembers] = useState(false);
// Modal State
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const [isMemberImportModalOpen, setIsMemberImportModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -160,7 +162,7 @@ export default function EditWorkspace() {
}
}, [id, isLoading, myRole, navigate]);
const loadData = async () => {
const loadData = async () => {
try {
setIsLoading(true);
const workspaceData = await getWorkspace(id!);
@@ -197,7 +199,20 @@ export default function EditWorkspace() {
} finally {
setIsLoading(false);
}
};
};
const refreshMembersAndRates = async () => {
if (!id) return;
const [membersData, ratesData] = await Promise.all([
fetchWorkspaceMemberships({ workspace: id, limit: LIMIT, offset: 0 }),
getWorkspaceUserRates(id),
]);
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
setMembers(results);
setWorkspaceRates(ratesData.results || []);
setOffset(LIMIT);
setHasMore(membersData.next ? true : results.length >= LIMIT);
};
const loadMoreMembers = useCallback(async () => {
if (isLoadingMembers || !hasMore || !id) return;
@@ -439,17 +454,30 @@ export default function EditWorkspace() {
<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 className="flex flex-wrap gap-2 self-start sm:self-auto">
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsMemberImportModalOpen(true)}
className="gap-2"
>
<UploadCloud className="h-4 w-4" />
{t.workspace?.memberImport?.button || "Import members"}
</Button>
) : null}
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsProjectAccessModalOpen(true)}
className="gap-2"
>
<ShieldCheck className="h-4 w-4" />
{t.projects?.manageAccess || "Projects & Rates"}
</Button>
) : null}
</div>
</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">
@@ -676,8 +704,8 @@ export default function EditWorkspace() {
</Transition>
</div>
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<ProjectAccessModal
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<ProjectAccessModal
isOpen={isProjectAccessModalOpen}
onClose={() => setIsProjectAccessModalOpen(false)}
workspaceId={id}
@@ -724,8 +752,59 @@ export default function EditWorkspace() {
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}}
/>
/>
) : null}
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && id ? (
<WorkspaceMemberImportModal
isOpen={isMemberImportModalOpen}
onClose={() => setIsMemberImportModalOpen(false)}
workspaceId={id}
priceUnits={priceUnits}
onImported={refreshMembersAndRates}
labels={{
title: t.workspace?.memberImport?.title || "Import members",
description:
t.workspace?.memberImport?.description ||
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member.",
uploadTitle: t.workspace?.memberImport?.uploadTitle || "Upload member file",
uploadDescription: t.workspace?.memberImport?.uploadDescription || "CSV, TSV, TXT, or XLSX. The first row must contain headers.",
sampleCsv: t.workspace?.memberImport?.sampleCsv || "CSV sample",
sampleTsv: t.workspace?.memberImport?.sampleTsv || "TSV sample",
sampleTxt: t.workspace?.memberImport?.sampleTxt || "TXT sample",
sampleXlsx: t.workspace?.memberImport?.sampleXlsx || "XLSX sample",
validate: t.workspace?.memberImport?.validate || "Validate file",
validating: t.workspace?.memberImport?.validating || "Validating...",
import: t.workspace?.memberImport?.import || "Import members",
importing: t.workspace?.memberImport?.importing || "Importing...",
close: t.actions?.cancel || "Close",
chooseFile: t.workspace?.memberImport?.chooseFile || "Choose file",
selectedFile: t.workspace?.memberImport?.selectedFile || "Selected file",
validRows: t.workspace?.memberImport?.validRows || "Valid rows",
invalidRows: t.workspace?.memberImport?.invalidRows || "Invalid rows",
totalRows: t.workspace?.memberImport?.totalRows || "Total rows",
line: t.workspace?.memberImport?.line || "Line",
mobile: t.workspace?.memberImport?.mobile || "Mobile",
user: t.workspace?.memberImport?.user || "User",
role: t.workspace?.memberImport?.role || "Role",
hourlyRate: t.workspace?.memberImport?.hourlyRate || "Hourly rate",
currency: t.workspace?.memberImport?.currency || "Currency",
status: t.workspace?.memberImport?.status || "Status",
messages: t.workspace?.memberImport?.messages || "Messages",
valid: t.workspace?.memberImport?.valid || "Valid",
invalid: t.workspace?.memberImport?.invalid || "Invalid",
noRows: t.workspace?.memberImport?.noRows || "No rows loaded yet.",
localErrors: t.workspace?.memberImport?.localErrors || "Fix local file errors before backend validation.",
success: t.workspace?.memberImport?.success || "Members imported successfully.",
parseFailed: t.workspace?.memberImport?.parseFailed || "Failed to parse the file.",
missingMobile: t.workspace?.memberImport?.missingMobile || "Mobile is required.",
duplicateMobile: t.workspace?.memberImport?.duplicateMobile || "This mobile appears more than once.",
invalidRole: t.workspace?.memberImport?.invalidRole || "Role must be admin, member, or guest.",
invalidRate: t.workspace?.memberImport?.invalidRate || "Hourly rate must be a valid positive number.",
rateCurrencyPair: t.workspace?.memberImport?.rateCurrencyPair || "Hourly rate and currency must be provided together.",
tooManyRows: t.workspace?.memberImport?.tooManyRows || "Import is limited to 500 rows.",
}}
/>
) : null}
</>
);
}