feat(workspaces): add bulk member import modal
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user