feat(workspaces): add bulk member import modal
This commit is contained in:
106
package-lock.json
generated
106
package-lock.json
generated
@@ -22,7 +22,8 @@
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -2366,6 +2367,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/ajv/-/ajv-6.14.0.tgz",
|
||||
@@ -2569,6 +2579,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2607,6 +2630,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2666,6 +2698,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3326,6 +3370,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -4548,6 +4601,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -4870,6 +4935,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -4880,6 +4963,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -36,6 +36,47 @@ export interface WorkspaceMembership {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WorkspaceMemberImportRowInput {
|
||||
line: number;
|
||||
mobile: string;
|
||||
role?: "admin" | "member" | "guest";
|
||||
hourly_rate?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceMemberImportResultRow {
|
||||
line: number | null;
|
||||
mobile: string;
|
||||
role: "admin" | "member" | "guest" | "owner" | string;
|
||||
hourly_rate: string;
|
||||
currency: string;
|
||||
status: "valid" | "invalid";
|
||||
action: "add_member" | "none" | string;
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
mobile: string;
|
||||
} | null;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceMemberImportValidationResponse {
|
||||
can_commit: boolean;
|
||||
import_token: string | null;
|
||||
summary: {
|
||||
total: number;
|
||||
valid: number;
|
||||
invalid: number;
|
||||
};
|
||||
rows: WorkspaceMemberImportResultRow[];
|
||||
}
|
||||
|
||||
export interface WorkspaceMemberImportCommitResponse {
|
||||
created_memberships: number;
|
||||
created_or_updated_rates: number;
|
||||
memberships: WorkspaceMembership[];
|
||||
}
|
||||
|
||||
|
||||
type QueryValue = string | number | boolean | undefined | null;
|
||||
|
||||
@@ -210,7 +251,7 @@ export const removeWorkspaceMembership = async (membershipId: string): Promise<v
|
||||
invalidateApiCache(["workspace-memberships", "reports"]);
|
||||
};
|
||||
|
||||
export const updateWorkspaceMembership = async (membershipId: string | number, data: { role: string }) => {
|
||||
export const updateWorkspaceMembership = async (membershipId: string | number, data: { role: string }) => {
|
||||
const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
@@ -225,3 +266,36 @@ export const updateWorkspaceMembership = async (membershipId: string | number, d
|
||||
invalidateApiCache(["workspace-memberships", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const validateWorkspaceMemberImport = async (data: {
|
||||
workspace: string;
|
||||
rows: WorkspaceMemberImportRowInput[];
|
||||
}): Promise<WorkspaceMemberImportValidationResponse> => {
|
||||
const response = await authFetch("/api/workspace-memberships/import/validate/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.detail || payload?.message || "Failed to validate member import");
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const commitWorkspaceMemberImport = async (data: {
|
||||
workspace: string;
|
||||
import_token: string;
|
||||
}): Promise<WorkspaceMemberImportCommitResponse> => {
|
||||
const response = await authFetch("/api/workspace-memberships/import/commit/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.detail || payload?.message || "Failed to import workspace members");
|
||||
}
|
||||
invalidateApiCache(["workspace-memberships", "workspace-rates", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
595
src/components/workspaces/WorkspaceMemberImportModal.tsx
Normal file
595
src/components/workspaces/WorkspaceMemberImportModal.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import { Fragment, useMemo, useRef, useState } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, UploadCloud, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import {
|
||||
commitWorkspaceMemberImport,
|
||||
validateWorkspaceMemberImport,
|
||||
type WorkspaceMemberImportResultRow,
|
||||
type WorkspaceMemberImportRowInput,
|
||||
type WorkspaceMemberImportValidationResponse,
|
||||
} from "../../api/workspaces";
|
||||
import type { PriceUnit } from "../../api/rates";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type ImportLabels = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
uploadTitle?: string;
|
||||
uploadDescription?: string;
|
||||
sampleCsv?: string;
|
||||
sampleTsv?: string;
|
||||
sampleTxt?: string;
|
||||
sampleXlsx?: string;
|
||||
validate?: string;
|
||||
validating?: string;
|
||||
import?: string;
|
||||
importing?: string;
|
||||
close?: string;
|
||||
chooseFile?: string;
|
||||
selectedFile?: string;
|
||||
validRows?: string;
|
||||
invalidRows?: string;
|
||||
totalRows?: string;
|
||||
line?: string;
|
||||
mobile?: string;
|
||||
user?: string;
|
||||
role?: string;
|
||||
hourlyRate?: string;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
messages?: string;
|
||||
valid?: string;
|
||||
invalid?: string;
|
||||
noRows?: string;
|
||||
localErrors?: string;
|
||||
success?: string;
|
||||
parseFailed?: string;
|
||||
missingMobile?: string;
|
||||
duplicateMobile?: string;
|
||||
invalidRole?: string;
|
||||
invalidRate?: string;
|
||||
rateCurrencyPair?: string;
|
||||
tooManyRows?: string;
|
||||
};
|
||||
|
||||
type ParsedRow = WorkspaceMemberImportRowInput & {
|
||||
local_messages: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
workspaceId: string;
|
||||
priceUnits: PriceUnit[];
|
||||
labels: ImportLabels;
|
||||
onImported: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MAX_ROWS = 500;
|
||||
const ROLE_VALUES = new Set(["admin", "member", "guest"]);
|
||||
const DIGIT_MAP: Record<string, string> = {
|
||||
"۰": "0",
|
||||
"۱": "1",
|
||||
"۲": "2",
|
||||
"۳": "3",
|
||||
"۴": "4",
|
||||
"۵": "5",
|
||||
"۶": "6",
|
||||
"۷": "7",
|
||||
"۸": "8",
|
||||
"۹": "9",
|
||||
"٠": "0",
|
||||
"١": "1",
|
||||
"٢": "2",
|
||||
"٣": "3",
|
||||
"٤": "4",
|
||||
"٥": "5",
|
||||
"٦": "6",
|
||||
"٧": "7",
|
||||
"٨": "8",
|
||||
"٩": "9",
|
||||
};
|
||||
|
||||
const normalizeDigits = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[۰-۹٠-٩]/g, (digit) => DIGIT_MAP[digit] || digit)
|
||||
.trim();
|
||||
|
||||
const normalizeHeader = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s-]+/g, "_");
|
||||
|
||||
const parseDelimited = (text: string, delimiter: string) => {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
|
||||
if (char === '"' && inQuotes && next === '"') {
|
||||
cell += '"';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
if (char === delimiter && !inQuotes) {
|
||||
row.push(cell.trim());
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
if ((char === "\n" || char === "\r") && !inQuotes) {
|
||||
if (char === "\r" && next === "\n") {
|
||||
index += 1;
|
||||
}
|
||||
row.push(cell.trim());
|
||||
if (row.some((item) => item !== "")) {
|
||||
rows.push(row);
|
||||
}
|
||||
row = [];
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
cell += char;
|
||||
}
|
||||
|
||||
row.push(cell.trim());
|
||||
if (row.some((item) => item !== "")) {
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const detectDelimiter = (text: string, extension: string) => {
|
||||
if (extension === "tsv") return "\t";
|
||||
const firstLine = text.split(/\r?\n/).find((line) => line.trim()) || "";
|
||||
const candidates = [",", ";", "\t", "|"];
|
||||
return candidates
|
||||
.map((delimiter) => ({
|
||||
delimiter,
|
||||
count: firstLine.split(delimiter).length,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)[0]?.delimiter || ",";
|
||||
};
|
||||
|
||||
const tableRowsToObjects = (rows: string[][]) => {
|
||||
const [headers = [], ...body] = rows;
|
||||
const normalizedHeaders = headers.map(normalizeHeader);
|
||||
return body.map((row, index) => {
|
||||
const record: Record<string, string> = {};
|
||||
normalizedHeaders.forEach((header, headerIndex) => {
|
||||
if (header) {
|
||||
record[header] = row[headerIndex] || "";
|
||||
}
|
||||
});
|
||||
return { line: index + 2, record };
|
||||
});
|
||||
};
|
||||
|
||||
const buildParsedRows = (records: Array<{ line: number; record: Record<string, string> }>, labels: ImportLabels) => {
|
||||
const seenMobiles = new Set<string>();
|
||||
return records
|
||||
.filter(({ record }) => Object.values(record).some((value) => String(value || "").trim()))
|
||||
.map(({ line, record }) => {
|
||||
const mobile = normalizeDigits(record.mobile);
|
||||
const role = normalizeDigits(record.role || "member").toLowerCase() as ParsedRow["role"];
|
||||
const hourlyRate = normalizeDigits(record.hourly_rate);
|
||||
const currency = normalizeDigits(record.currency).toUpperCase();
|
||||
const localMessages: string[] = [];
|
||||
|
||||
if (!mobile) {
|
||||
localMessages.push(labels.missingMobile || "Mobile is required.");
|
||||
} else if (seenMobiles.has(mobile)) {
|
||||
localMessages.push(labels.duplicateMobile || "This mobile appears more than once.");
|
||||
} else {
|
||||
seenMobiles.add(mobile);
|
||||
}
|
||||
|
||||
if (!ROLE_VALUES.has(role || "member")) {
|
||||
localMessages.push(labels.invalidRole || "Role must be admin, member, or guest.");
|
||||
}
|
||||
|
||||
if (hourlyRate && Number.isNaN(Number(hourlyRate.replace(/,/g, "")))) {
|
||||
localMessages.push(labels.invalidRate || "Hourly rate must be a valid number.");
|
||||
}
|
||||
if (hourlyRate && Number(hourlyRate.replace(/,/g, "")) <= 0) {
|
||||
localMessages.push(labels.invalidRate || "Hourly rate must be greater than zero.");
|
||||
}
|
||||
if (Boolean(hourlyRate) !== Boolean(currency)) {
|
||||
localMessages.push(labels.rateCurrencyPair || "Hourly rate and currency must be provided together.");
|
||||
}
|
||||
|
||||
return {
|
||||
line,
|
||||
mobile,
|
||||
role: ROLE_VALUES.has(role || "") ? role : "member",
|
||||
hourly_rate: hourlyRate ? hourlyRate.replace(/,/g, "") : "",
|
||||
currency,
|
||||
local_messages: localMessages,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const sampleRows = [
|
||||
["mobile", "role", "hourly_rate", "currency"],
|
||||
["09999999999", "member", "150000", "IRT"],
|
||||
["09999999998", "guest", "", ""],
|
||||
];
|
||||
|
||||
export function WorkspaceMemberImportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
workspaceId,
|
||||
priceUnits,
|
||||
labels,
|
||||
onImported,
|
||||
}: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [parseError, setParseError] = useState("");
|
||||
const [validation, setValidation] = useState<WorkspaceMemberImportValidationResponse | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const localInvalidCount = parsedRows.filter((row) => row.local_messages.length > 0).length;
|
||||
const canValidate = parsedRows.length > 0 && localInvalidCount === 0 && !isValidating;
|
||||
const canCommit = Boolean(validation?.can_commit && validation.import_token) && !isImporting;
|
||||
|
||||
const currencyCodes = useMemo(
|
||||
() => priceUnits.map((unit) => unit.code).filter(Boolean).join(", "),
|
||||
[priceUnits],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setFileName("");
|
||||
setParsedRows([]);
|
||||
setParseError("");
|
||||
setValidation(null);
|
||||
setIsValidating(false);
|
||||
setIsImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const parseFile = async (file: File) => {
|
||||
setParseError("");
|
||||
setValidation(null);
|
||||
setFileName(file.name);
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
try {
|
||||
let records: Array<{ line: number; record: Record<string, string> }> = [];
|
||||
if (extension === "xlsx") {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: "array" });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, defval: "" });
|
||||
records = tableRowsToObjects(rows);
|
||||
} else if (["csv", "tsv", "txt"].includes(extension)) {
|
||||
const text = await file.text();
|
||||
const delimiter = detectDelimiter(text, extension);
|
||||
records = tableRowsToObjects(parseDelimited(text, delimiter));
|
||||
} else {
|
||||
throw new Error("Unsupported file type.");
|
||||
}
|
||||
|
||||
const hasMobileHeader = records.length > 0 && Object.prototype.hasOwnProperty.call(records[0].record, "mobile");
|
||||
if (!hasMobileHeader && records.length === 0) {
|
||||
throw new Error(labels.noRows || "No rows were found in this file.");
|
||||
}
|
||||
if (!hasMobileHeader) {
|
||||
throw new Error(labels.missingMobile || "The file must include a mobile column.");
|
||||
}
|
||||
|
||||
const nextRows = buildParsedRows(records, labels);
|
||||
if (nextRows.length > MAX_ROWS) {
|
||||
setParseError(labels.tooManyRows || `Import is limited to ${MAX_ROWS} rows.`);
|
||||
setParsedRows([]);
|
||||
return;
|
||||
}
|
||||
setParsedRows(nextRows);
|
||||
} catch (error) {
|
||||
setParsedRows([]);
|
||||
setParseError(error instanceof Error ? error.message : labels.parseFailed || "Failed to parse the file.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void parseFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
setIsValidating(true);
|
||||
setValidation(null);
|
||||
try {
|
||||
const response = await validateWorkspaceMemberImport({
|
||||
workspace: workspaceId,
|
||||
rows: parsedRows.map(({ local_messages: _localMessages, ...row }) => row),
|
||||
});
|
||||
setValidation(response);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : labels.parseFailed || "Failed to validate member import");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!validation?.import_token) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await commitWorkspaceMemberImport({
|
||||
workspace: workspaceId,
|
||||
import_token: validation.import_token,
|
||||
});
|
||||
toast.success(labels.success || "Members imported successfully.");
|
||||
await onImported();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to import members");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSample = (format: "csv" | "tsv" | "txt" | "xlsx") => {
|
||||
if (format === "xlsx") {
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(sampleRows);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "members");
|
||||
const output = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
|
||||
downloadBlob(
|
||||
new Blob([output], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
|
||||
"workspace-members-sample.xlsx",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const delimiter = format === "tsv" ? "\t" : ",";
|
||||
const content = sampleRows.map((row) => row.join(delimiter)).join("\n");
|
||||
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), `workspace-members-sample.${format}`);
|
||||
};
|
||||
|
||||
const displayedRows: WorkspaceMemberImportResultRow[] =
|
||||
validation?.rows ||
|
||||
parsedRows.map((row) => ({
|
||||
line: row.line,
|
||||
mobile: row.mobile,
|
||||
role: row.role || "member",
|
||||
hourly_rate: row.hourly_rate || "",
|
||||
currency: row.currency || "",
|
||||
status: row.local_messages.length ? "invalid" : "valid",
|
||||
action: row.local_messages.length ? "none" : "add_member",
|
||||
user: null,
|
||||
messages: row.local_messages,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-5xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-800">
|
||||
<Dialog.Title className="text-lg font-semibold text-slate-950 dark:text-white">
|
||||
{labels.title || "Import members"}
|
||||
</Dialog.Title>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{labels.description ||
|
||||
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 p-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-5 text-center dark:border-slate-700 dark:bg-slate-900/60">
|
||||
<UploadCloud className="mx-auto h-9 w-9 text-blue-500" />
|
||||
<h3 className="mt-3 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{labels.uploadTitle || "Upload file"}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">
|
||||
{labels.uploadDescription || "CSV, TSV, TXT, or XLSX. The first row must be headers."}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.tsv,.txt,.xlsx"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button type="button" className="mt-4 gap-2" onClick={() => fileInputRef.current?.click()}>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
{labels.chooseFile || "Choose file"}
|
||||
</Button>
|
||||
{fileName ? (
|
||||
<p className="mt-3 truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{(labels.selectedFile || "Selected file")}: {fileName}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 p-4 dark:border-slate-800">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{labels.currency || "Currency"}: {currencyCodes || "-"}
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("csv")}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{labels.sampleCsv || "CSV sample"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("tsv")}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{labels.sampleTsv || "TSV sample"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("txt")}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{labels.sampleTxt || "TXT sample"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("xlsx")}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{labels.sampleXlsx || "XLSX sample"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parseError ? (
|
||||
<div className="flex gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{parseError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-slate-100 p-3 dark:bg-slate-900">
|
||||
<p className="text-xs text-slate-500">{labels.totalRows || "Total rows"}</p>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{validation?.summary.total ?? parsedRows.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3 dark:bg-emerald-950/30">
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">{labels.validRows || "Valid rows"}</p>
|
||||
<p className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
{validation?.summary.valid ?? parsedRows.length - localInvalidCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-red-50 p-3 dark:bg-red-950/30">
|
||||
<p className="text-xs text-red-700 dark:text-red-300">{labels.invalidRows || "Invalid rows"}</p>
|
||||
<p className="text-lg font-semibold text-red-700 dark:text-red-300">
|
||||
{validation?.summary.invalid ?? localInvalidCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localInvalidCount > 0 && !validation ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
{labels.localErrors || "Fix local file errors before backend validation."}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[440px] overflow-auto rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
|
||||
<thead className="sticky top-0 bg-slate-100 text-xs uppercase text-slate-500 dark:bg-slate-900 dark:text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-start">{labels.line || "Line"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.mobile || "Mobile"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.user || "User"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.role || "Role"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.hourlyRate || "Hourly rate"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.currency || "Currency"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.status || "Status"}</th>
|
||||
<th className="px-3 py-2 text-start">{labels.messages || "Messages"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{displayedRows.length ? (
|
||||
displayedRows.map((row) => (
|
||||
<tr key={`${row.line}-${row.mobile}`} className="bg-white dark:bg-slate-950">
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.line ?? "-"}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900 dark:text-white">{row.mobile || "-"}</td>
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.user?.full_name || "-"}</td>
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.role || "member"}</td>
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.hourly_rate || "-"}</td>
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.currency || "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.status === "valid" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{labels.valid || "Valid"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300">
|
||||
<XCircle className="h-3 w-3" />
|
||||
{labels.invalid || "Invalid"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="min-w-[220px] px-3 py-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{row.messages.length ? row.messages.join(" | ") : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-10 text-center text-slate-500 dark:text-slate-400">
|
||||
{labels.noRows || "No rows loaded yet."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="secondary" onClick={handleClose}>
|
||||
{labels.close || "Close"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={!canValidate} onClick={handleValidate}>
|
||||
{isValidating ? labels.validating || "Validating..." : labels.validate || "Validate file"}
|
||||
</Button>
|
||||
<Button type="button" disabled={!canCommit} onClick={handleImport}>
|
||||
{isImporting ? labels.importing || "Importing..." : labels.import || "Import members"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +284,46 @@ export const en = {
|
||||
membersSectionTitle: "Members",
|
||||
membersSectionSubtitle: "People in this workspace and their current roles.",
|
||||
projectRateHint: "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set a custom rate that overrides the workspace rate for that project.",
|
||||
memberImport: {
|
||||
button: "Import members",
|
||||
title: "Import members",
|
||||
description: "Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member.",
|
||||
uploadTitle: "Upload member file",
|
||||
uploadDescription: "CSV, TSV, TXT, or XLSX. The first row must contain headers.",
|
||||
sampleCsv: "CSV sample",
|
||||
sampleTsv: "TSV sample",
|
||||
sampleTxt: "TXT sample",
|
||||
sampleXlsx: "XLSX sample",
|
||||
validate: "Validate file",
|
||||
validating: "Validating...",
|
||||
import: "Import members",
|
||||
importing: "Importing...",
|
||||
chooseFile: "Choose file",
|
||||
selectedFile: "Selected file",
|
||||
validRows: "Valid rows",
|
||||
invalidRows: "Invalid rows",
|
||||
totalRows: "Total rows",
|
||||
line: "Line",
|
||||
mobile: "Mobile",
|
||||
user: "User",
|
||||
role: "Role",
|
||||
hourlyRate: "Hourly rate",
|
||||
currency: "Currency",
|
||||
status: "Status",
|
||||
messages: "Messages",
|
||||
valid: "Valid",
|
||||
invalid: "Invalid",
|
||||
noRows: "No rows loaded yet.",
|
||||
localErrors: "Fix local file errors before backend validation.",
|
||||
success: "Members imported successfully.",
|
||||
parseFailed: "Failed to parse the file.",
|
||||
missingMobile: "Mobile is required.",
|
||||
duplicateMobile: "This mobile appears more than once.",
|
||||
invalidRole: "Role must be admin, member, or guest.",
|
||||
invalidRate: "Hourly rate must be a valid positive number.",
|
||||
rateCurrencyPair: "Hourly rate and currency must be provided together.",
|
||||
tooManyRows: "Import is limited to 500 rows.",
|
||||
},
|
||||
membersLocked: "Only owners and admins can view the full member list.",
|
||||
manageMembers: "Manage members",
|
||||
mobileNumber: "Mobile Number",
|
||||
|
||||
@@ -283,10 +283,50 @@ export const fa = {
|
||||
statsOwnersAdmins: "مالکان و ادمینها",
|
||||
statsGuests: "مهمانها",
|
||||
membersSectionTitle: "اعضا",
|
||||
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
||||
manageMembers: "مدیریت اعضا",
|
||||
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
||||
memberImport: {
|
||||
button: "درونریزی اعضا",
|
||||
title: "درونریزی اعضا",
|
||||
description: "فایلی با ستونهای mobile، role، hourly_rate و currency بارگذاری کنید. موبایل الزامی است و نقش در صورت خالی بودن عضو در نظر گرفته میشود.",
|
||||
uploadTitle: "بارگذاری فایل اعضا",
|
||||
uploadDescription: "فرمتهای CSV، TSV، TXT یا XLSX پشتیبانی میشوند. ردیف اول باید عنوان ستونها باشد.",
|
||||
sampleCsv: "نمونه CSV",
|
||||
sampleTsv: "نمونه TSV",
|
||||
sampleTxt: "نمونه TXT",
|
||||
sampleXlsx: "نمونه XLSX",
|
||||
validate: "اعتبارسنجی فایل",
|
||||
validating: "در حال اعتبارسنجی...",
|
||||
import: "درونریزی اعضا",
|
||||
importing: "در حال درونریزی...",
|
||||
chooseFile: "انتخاب فایل",
|
||||
selectedFile: "فایل انتخابشده",
|
||||
validRows: "ردیفهای معتبر",
|
||||
invalidRows: "ردیفهای نامعتبر",
|
||||
totalRows: "کل ردیفها",
|
||||
line: "ردیف",
|
||||
mobile: "موبایل",
|
||||
user: "کاربر",
|
||||
role: "نقش",
|
||||
hourlyRate: "نرخ ساعتی",
|
||||
currency: "واحد پول",
|
||||
status: "وضعیت",
|
||||
messages: "پیامها",
|
||||
valid: "معتبر",
|
||||
invalid: "نامعتبر",
|
||||
noRows: "هنوز ردیفی بارگذاری نشده است.",
|
||||
localErrors: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.",
|
||||
success: "اعضا با موفقیت درونریزی شدند.",
|
||||
parseFailed: "خواندن فایل ناموفق بود.",
|
||||
missingMobile: "موبایل الزامی است.",
|
||||
duplicateMobile: "این موبایل بیش از یک بار در فایل آمده است.",
|
||||
invalidRole: "نقش باید admin، member یا guest باشد.",
|
||||
invalidRate: "نرخ ساعتی باید عددی معتبر و بزرگتر از صفر باشد.",
|
||||
rateCurrencyPair: "نرخ ساعتی و واحد پول باید با هم وارد شوند.",
|
||||
tooManyRows: "درونریزی به ۵۰۰ ردیف محدود است.",
|
||||
},
|
||||
manageMembers: "مدیریت اعضا",
|
||||
mobileNumber: "شماره تماس",
|
||||
youLabel: "شما",
|
||||
resourcesTitle: "منابع",
|
||||
|
||||
@@ -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