diff --git a/package-lock.json b/package-lock.json index db0f6ec..e6dceeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0e031d4..0a6f016 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/workspaces.ts b/src/api/workspaces.ts index b7345a9..f189791 100644 --- a/src/api/workspaces.ts +++ b/src/api/workspaces.ts @@ -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 { +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 => { + 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 => { + 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; +}; diff --git a/src/components/workspaces/WorkspaceMemberImportModal.tsx b/src/components/workspaces/WorkspaceMemberImportModal.tsx new file mode 100644 index 0000000..06544c8 --- /dev/null +++ b/src/components/workspaces/WorkspaceMemberImportModal.tsx @@ -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; +}; + +const MAX_ROWS = 500; +const ROLE_VALUES = new Set(["admin", "member", "guest"]); +const DIGIT_MAP: Record = { + "۰": "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 = {}; + normalizedHeaders.forEach((header, headerIndex) => { + if (header) { + record[header] = row[headerIndex] || ""; + } + }); + return { line: index + 2, record }; + }); +}; + +const buildParsedRows = (records: Array<{ line: number; record: Record }>, labels: ImportLabels) => { + const seenMobiles = new Set(); + 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(null); + const [fileName, setFileName] = useState(""); + const [parsedRows, setParsedRows] = useState([]); + const [parseError, setParseError] = useState(""); + const [validation, setValidation] = useState(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 }> = []; + 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(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) => { + 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 ( + + + +
+ + +
+
+ + +
+ + {labels.title || "Import members"} + +

+ {labels.description || + "Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member."} +

+
+ +
+
+
+ +

+ {labels.uploadTitle || "Upload file"} +

+

+ {labels.uploadDescription || "CSV, TSV, TXT, or XLSX. The first row must be headers."} +

+ + + {fileName ? ( +

+ {(labels.selectedFile || "Selected file")}: {fileName} +

+ ) : null} +
+ +
+

+ {labels.currency || "Currency"}: {currencyCodes || "-"} +

+
+ + + + +
+
+ + {parseError ? ( +
+ + {parseError} +
+ ) : null} +
+ +
+
+
+

{labels.totalRows || "Total rows"}

+

+ {validation?.summary.total ?? parsedRows.length} +

+
+
+

{labels.validRows || "Valid rows"}

+

+ {validation?.summary.valid ?? parsedRows.length - localInvalidCount} +

+
+
+

{labels.invalidRows || "Invalid rows"}

+

+ {validation?.summary.invalid ?? localInvalidCount} +

+
+
+ + {localInvalidCount > 0 && !validation ? ( +
+ {labels.localErrors || "Fix local file errors before backend validation."} +
+ ) : null} + +
+ + + + + + + + + + + + + + + {displayedRows.length ? ( + displayedRows.map((row) => ( + + + + + + + + + + + )) + ) : ( + + + + )} + +
{labels.line || "Line"}{labels.mobile || "Mobile"}{labels.user || "User"}{labels.role || "Role"}{labels.hourlyRate || "Hourly rate"}{labels.currency || "Currency"}{labels.status || "Status"}{labels.messages || "Messages"}
{row.line ?? "-"}{row.mobile || "-"}{row.user?.full_name || "-"}{row.role || "member"}{row.hourly_rate || "-"}{row.currency || "-"} + {row.status === "valid" ? ( + + + {labels.valid || "Valid"} + + ) : ( + + + {labels.invalid || "Invalid"} + + )} + + {row.messages.length ? row.messages.join(" | ") : "-"} +
+ {labels.noRows || "No rows loaded yet."} +
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+ ); +} diff --git a/src/locales/en.ts b/src/locales/en.ts index 5186da5..5f631d2 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index af1ed3a..1251bbd 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -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: "منابع", diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index affcd1e..f10a994 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -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(null); - const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [memberIdToDelete, setMemberIdToDelete] = useState(null); + const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false); + const [isMemberImportModalOpen, setIsMemberImportModalOpen] = useState(false); const searchTimeoutRef = useRef | 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() {

{ t.workspace?.members || "Members" }

- {canWorkspace(myRole, WORKSPACE_EDIT) && id ? ( - - ) : null} +
+ {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && id ? ( + + ) : null} + {canWorkspace(myRole, WORKSPACE_EDIT) && id ? ( + + ) : null} +
@@ -676,8 +704,8 @@ export default function EditWorkspace() {
- {canWorkspace(myRole, WORKSPACE_EDIT) && id ? ( - 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 ? ( + 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} ); }