From 2e7ea40a79ee9ee4ec89b62ed5a3903a885a705a Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 19 Jun 2026 01:48:12 +0330 Subject: [PATCH] fix(workspaces): streamline member import modal --- .../workspaces/WorkspaceMemberImportModal.tsx | 567 +++++++++--------- src/locales/en.ts | 29 + src/locales/fa.ts | 191 +++--- src/pages/WorkspaceEdit.tsx | 29 + 4 files changed, 461 insertions(+), 355 deletions(-) diff --git a/src/components/workspaces/WorkspaceMemberImportModal.tsx b/src/components/workspaces/WorkspaceMemberImportModal.tsx index 06544c8..8432611 100644 --- a/src/components/workspaces/WorkspaceMemberImportModal.tsx +++ b/src/components/workspaces/WorkspaceMemberImportModal.tsx @@ -1,6 +1,5 @@ -import { Fragment, useMemo, useRef, useState } from "react"; -import { Dialog, Transition } from "@headlessui/react"; -import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, UploadCloud, XCircle } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, HelpCircle, Loader2, UploadCloud, XCircle } from "lucide-react"; import { toast } from "sonner"; import * as XLSX from "xlsx"; @@ -12,6 +11,7 @@ import { type WorkspaceMemberImportValidationResponse, } from "../../api/workspaces"; import type { PriceUnit } from "../../api/rates"; +import { Modal } from "../Modal"; import { Button } from "../ui/button"; type ImportLabels = { @@ -45,6 +45,21 @@ type ImportLabels = { invalid?: string; noRows?: string; localErrors?: string; + helpTitle?: string; + helpDescription?: string; + helpCurrencyTitle?: string; + helpRolesTitle?: string; + roleAdminDescription?: string; + roleMemberDescription?: string; + roleGuestDescription?: string; + helpMobilePrefix?: string; + helpMobileSuffix?: string; + helpRolePrefix?: string; + helpRoleSuffix?: string; + helpRatePrefix?: string; + helpRateMiddle?: string; + helpRateSuffix?: string; + helpSamplesTitle?: string; success?: string; parseFailed?: string; missingMobile?: string; @@ -53,11 +68,10 @@ type ImportLabels = { invalidRate?: string; rateCurrencyPair?: string; tooManyRows?: string; + messageMap?: Record; }; -type ParsedRow = WorkspaceMemberImportRowInput & { - local_messages: string[]; -}; +type ParsedRow = WorkspaceMemberImportRowInput; type Props = { isOpen: boolean; @@ -68,8 +82,6 @@ type Props = { onImported: () => void | Promise; }; -const MAX_ROWS = 500; -const ROLE_VALUES = new Set(["admin", "member", "guest"]); const DIGIT_MAP: Record = { "۰": "0", "۱": "1", @@ -176,46 +188,21 @@ const tableRowsToObjects = (rows: string[][]) => { }); }; -const buildParsedRows = (records: Array<{ line: number; record: Record }>, labels: ImportLabels) => { - const seenMobiles = new Set(); +const buildParsedRows = (records: Array<{ line: number; record: Record }>) => { 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 role = normalizeDigits(record.role || "member").toLowerCase(); 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", + role: (role || "member") as ParsedRow["role"], hourly_rate: hourlyRate ? hourlyRate.replace(/,/g, "") : "", currency, - local_messages: localMessages, }; }); }; @@ -237,6 +224,12 @@ const sampleRows = [ ["09999999998", "guest", "", ""], ]; +const ColumnCode = ({ children }: { children: string }) => ( + + {children} + +); + export function WorkspaceMemberImportModal({ isOpen, onClose, @@ -252,15 +245,15 @@ export function WorkspaceMemberImportModal({ const [validation, setValidation] = useState(null); const [isValidating, setIsValidating] = useState(false); const [isImporting, setIsImporting] = useState(false); + const [isHelpOpen, setIsHelpOpen] = 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 currencyColumns = useMemo(() => { + const splitIndex = Math.ceil(priceUnits.length / 2); + return [priceUnits.slice(0, splitIndex), priceUnits.slice(splitIndex)].filter((items) => items.length); + }, [priceUnits]); - const currencyCodes = useMemo( - () => priceUnits.map((unit) => unit.code).filter(Boolean).join(", "), - [priceUnits], - ); + const translateMessage = (message: string) => labels.messageMap?.[message] || message; const reset = () => { setFileName(""); @@ -269,6 +262,7 @@ export function WorkspaceMemberImportModal({ setValidation(null); setIsValidating(false); setIsImporting(false); + setIsHelpOpen(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -279,6 +273,22 @@ export function WorkspaceMemberImportModal({ onClose(); }; + const validateRows = async (rows: ParsedRow[]) => { + setIsValidating(true); + setValidation(null); + try { + const response = await validateWorkspaceMemberImport({ + workspace: workspaceId, + rows, + }); + setValidation(response); + } catch (error) { + toast.error(error instanceof Error ? error.message : labels.parseFailed || "Failed to validate member import"); + } finally { + setIsValidating(false); + } + }; + const parseFile = async (file: File) => { setParseError(""); setValidation(null); @@ -300,21 +310,9 @@ export function WorkspaceMemberImportModal({ 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; - } + const nextRows = buildParsedRows(records); setParsedRows(nextRows); + void validateRows(nextRows); } catch (error) { setParsedRows([]); setParseError(error instanceof Error ? error.message : labels.parseFailed || "Failed to parse the file."); @@ -328,22 +326,6 @@ export function WorkspaceMemberImportModal({ } }; - 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); @@ -380,216 +362,253 @@ export function WorkspaceMemberImportModal({ 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, - })); + const displayedRows: WorkspaceMemberImportResultRow[] = validation?.rows || []; return ( - - - -
- - -
-
- + + + + + } + > +
+
+

+ {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} +
+ + {parseError ? ( +
+ + {parseError} +
+ ) : null} +
+ +
+
+
+

{labels.totalRows || "Total rows"}

+

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

- -
-
-
- -

- {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."} -
-
-
+
+

{labels.validRows || "Valid rows"}

+

+ {validation?.summary.valid ?? 0} +

- -
- - - +
+

{labels.invalidRows || "Invalid rows"}

+

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

- - +
+ + {isValidating ? ( +
+ + {labels.validating || "Validating..."} +
+ ) : 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.map(translateMessage).join(" | ") : "-"} +
+ {labels.noRows || "No rows loaded yet."} +
+
+
-
-
+ + + setIsHelpOpen(false)} + title={labels.helpTitle || "Import help"} + maxWidth="max-w-lg" + footer={ + + } + > +
+

{labels.helpDescription || "Use these rules to prepare a valid member import file."}

+
+

{labels.helpCurrencyTitle || "Currency options"}

+
+ {currencyColumns.map((units, columnIndex) => ( +
    + {units.map((unit) => ( +
  • + {unit.code} + : + {unit.local_name || unit.name || unit.symbol || unit.code} +
  • + ))} +
+ ))} +
+
+ +
+

{labels.helpRolesTitle || "Role options"}

+
    +
  • + admin + : + {labels.roleAdminDescription || "Can manage workspace settings, members, projects, reports, and shared data."} +
  • +
  • + member + : + {labels.roleMemberDescription || "Can use the workspace normally and record time for accessible projects."} +
  • +
  • + guest + : + {labels.roleGuestDescription || "Has limited workspace access and can record time only where access is granted."} +
  • +
+
+ +
    +
  • + {labels.helpMobilePrefix || "Column"} mobile{" "} + {labels.helpMobileSuffix || "is required and must belong to an existing registered user."} +
  • +
  • + {labels.helpRolePrefix || "Column"} role{" "} + {labels.helpRoleSuffix || "is optional. Empty role becomes member."} +
  • +
  • + {labels.helpRatePrefix || "Columns"} hourly_rate{" "} + {labels.helpRateMiddle || "and"} currency{" "} + {labels.helpRateSuffix || "are optional, but must be filled together when used."} +
  • +
+
+

{labels.helpSamplesTitle || "Sample files"}

+
+ + + + +
+
+
+
+ ); } diff --git a/src/locales/en.ts b/src/locales/en.ts index 634b6af..39f631f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -315,6 +315,21 @@ export const en = { invalid: "Invalid", noRows: "No rows loaded yet.", localErrors: "Fix local file errors before backend validation.", + helpTitle: "Import help", + helpDescription: "Use these rules to prepare a valid member import file.", + helpCurrencyTitle: "Currency options", + helpRolesTitle: "Role options", + roleAdminDescription: "Can manage workspace settings, members, projects, reports, and shared data.", + roleMemberDescription: "Can use the workspace normally and record time for accessible projects.", + roleGuestDescription: "Has limited workspace access and can record time only where access is granted.", + helpMobilePrefix: "Column", + helpMobileSuffix: "is required and must belong to an existing registered user.", + helpRolePrefix: "Column", + helpRoleSuffix: "is optional. Empty role becomes member.", + helpRatePrefix: "Columns", + helpRateMiddle: "and", + helpRateSuffix: "are optional, but must be filled together when used.", + helpSamplesTitle: "Sample files", success: "Members imported successfully.", parseFailed: "Failed to parse the file.", missingMobile: "Mobile is required.", @@ -323,6 +338,20 @@ export const en = { 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.", + messagesMap: { + too_many_rows: "Import is limited to 500 rows.", + mobile_required: "Mobile is required.", + duplicate_mobile: "This mobile appears more than once.", + user_not_found: "No registered user was found with this mobile.", + already_member: "This user is already a workspace member.", + owner_role_not_allowed: "Owner role cannot be imported.", + invalid_role: "Role must be admin, member, or guest.", + role_permission_denied: "You do not have permission to assign this role.", + rate_currency_pair_required: "Hourly rate and currency must be provided together.", + hourly_rate_positive: "Hourly rate must be greater than zero.", + hourly_rate_invalid: "Hourly rate must be a valid number.", + currency_invalid: "Currency is not valid.", + }, }, membersLocked: "Only owners and admins can view the full member list.", manageMembers: "Manage members", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index a4f6ca7..ed65454 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -283,50 +283,79 @@ export const fa = { statsOwnersAdmins: "مالکان و ادمین‌ها", statsGuests: "مهمان‌ها", membersSectionTitle: "اعضا", - 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: "مدیریت اعضا", + 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: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.", + helpTitle: "راهنمای اضافه‌کردن گروهی", + helpDescription: "برای آماده‌کردن فایل معتبر، این نکات را رعایت کنید.", + helpCurrencyTitle: "واحدهای پول معتبر", + helpRolesTitle: "نقش‌های معتبر", + roleAdminDescription: "می‌تواند تنظیمات، اعضا، پروژه‌ها، گزارش‌ها و داده‌های مشترک ورک‌اسپیس را مدیریت کند.", + roleMemberDescription: "می‌تواند به‌صورت عادی از ورک‌اسپیس استفاده کند و برای پروژه‌های در دسترس زمان ثبت کند.", + roleGuestDescription: "دسترسی محدود دارد و فقط برای پروژه‌هایی که به او دسترسی داده شده زمان ثبت می‌کند.", + helpMobilePrefix: "ستون", + helpMobileSuffix: "الزامی است و باید متعلق به یک کاربر ثبت‌نام‌شده باشد.", + helpRolePrefix: "ستون", + helpRoleSuffix: "اختیاری است. اگر خالی باشد member در نظر گرفته می‌شود.", + helpRatePrefix: "ستون‌های", + helpRateMiddle: "و", + helpRateSuffix: "اختیاری هستند، اما اگر یکی پر شود دیگری هم باید پر شود.", + helpSamplesTitle: "فایل‌های نمونه", + success: "اعضا با موفقیت درون‌ریزی شدند.", + parseFailed: "خواندن فایل ناموفق بود.", + missingMobile: "موبایل الزامی است.", + duplicateMobile: "این موبایل بیش از یک بار در فایل آمده است.", + invalidRole: "نقش باید admin، member یا guest باشد.", + invalidRate: "نرخ ساعتی باید عددی معتبر و بزرگ‌تر از صفر باشد.", + rateCurrencyPair: "نرخ ساعتی و واحد پول باید با هم وارد شوند.", + tooManyRows: "درون‌ریزی به ۵۰۰ ردیف محدود است.", + messagesMap: { + too_many_rows: "اضافه‌کردن گروهی به ۵۰۰ ردیف محدود است.", + mobile_required: "موبایل الزامی است.", + duplicate_mobile: "این موبایل بیش از یک‌بار در فایل آمده است.", + user_not_found: "کاربر ثبت‌نام‌شده‌ای با این موبایل پیدا نشد.", + already_member: "این کاربر از قبل عضو این ورک‌اسپیس است.", + owner_role_not_allowed: "نقش مالک را نمی‌توان از طریق فایل اضافه کرد.", + invalid_role: "نقش باید admin، member یا guest باشد.", + role_permission_denied: "شما اجازه اختصاص این نقش را ندارید.", + rate_currency_pair_required: "نرخ ساعتی و واحد پول باید با هم وارد شوند.", + hourly_rate_positive: "نرخ ساعتی باید بزرگ‌تر از صفر باشد.", + hourly_rate_invalid: "نرخ ساعتی باید یک عدد معتبر باشد.", + currency_invalid: "واحد پول معتبر نیست.", + }, + }, + manageMembers: "مدیریت اعضا", mobileNumber: "شماره تماس", youLabel: "شما", resourcesTitle: "منابع", @@ -341,10 +370,10 @@ export const fa = { }, createdSuccess: "ورک‌اسپیس با موفقیت ایجاد شد", updatedSuccess: "ورک‌اسپیس با موفقیت ویرایش شد", - fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس", - loadErrorDescription: "ممکن است سرویس بک‌اند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.", - retry: "تلاش دوباره", - remove: "حذف", + fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس", + loadErrorDescription: "ممکن است سرویس بک‌اند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.", + retry: "تلاش دوباره", + remove: "حذف", noUsersFound: "کاربری یافت نشد", selectRole: "انتخاب نقش", add: "افزودن", @@ -433,14 +462,14 @@ export const fa = { collapse: 'جمع کردن', }, - landing: { + landing: { brandLabel: "زیرساخت عملیاتی زمان", eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند", nav: { - demo: "دموی محصول", - features: "قابلیت‌ها", - workflow: "فرآیند کار", - about: "درباره ما", + demo: "دموی محصول", + features: "قابلیت‌ها", + workflow: "فرآیند کار", + about: "درباره ما", }, actions: { switchToEnglish: "English", @@ -449,9 +478,9 @@ export const fa = { openApp: "ورود به اپ", openWorkspace: "باز کردن ورک‌اسپیس", startNow: "شروع با کنترل کامل", - watchDemo: "مشاهده دموی محصول", - readTerms: "مطالعه قوانین", - readAbout: "درباره Qlockify", + watchDemo: "مشاهده دموی محصول", + readTerms: "مطالعه قوانین", + readAbout: "درباره Qlockify", }, hero: { titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.", @@ -521,16 +550,16 @@ export const fa = { finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.", finalCtaDescription: "اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.", - }, - demo: { - badge: "محیط دمو", - starting: "در حال آماده‌سازی دمو...", - started: "محیط دمو آماده شد.", - startError: "امکان ساخت محیط دمو وجود ندارد.", - expiresAt: "زمان انقضا", - resetAction: "شروع دوباره دمو", - reset: "محیط دموی تازه آماده شد.", - }, + }, + demo: { + badge: "محیط دمو", + starting: "در حال آماده‌سازی دمو...", + started: "محیط دمو آماده شد.", + startError: "امکان ساخت محیط دمو وجود ندارد.", + expiresAt: "زمان انقضا", + resetAction: "شروع دوباره دمو", + reset: "محیط دموی تازه آماده شد.", + }, ordering: { createdAtDesc: "جدیدترین", @@ -677,10 +706,10 @@ export const fa = { timesheet: { title: "تایم‌شیت", description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`, - selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.", - addEntry: "افزودن ورودی", - addManualEntry: "افزودن دستی زمان", - startTimer: "شروع تایمر", + selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.", + addEntry: "افزودن ورودی", + addManualEntry: "افزودن زمان", + startTimer: "شروع تایمر", stopTimer: "توقف تایمر", timerRunning: "تایمر فعال است", runningLabel: "تایمر فعلی", @@ -693,9 +722,9 @@ export const fa = { emptyDescription: "بدون توضیح", emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.", noEntriesSearch: "عبارت جست‌وجو یا فیلترهای خود را تغییر دهید.", - createTitle: "افزودن ورودی زمان", - manualCreateTitle: "افزودن دستی زمان", - startTitle: "شروع تایمر", + createTitle: "افزودن ورودی زمان", + manualCreateTitle: "افزودن زمان", + startTitle: "شروع تایمر", editTitle: "ویرایش ورودی زمان", createSuccess: "ورودی زمان با موفقیت ایجاد شد.", startSuccess: "تایمر با موفقیت شروع شد.", @@ -737,14 +766,14 @@ export const fa = { searchTagsLabel: "جست‌وجوی تگ‌ها...", noTagsFoundLabel: "تگی پیدا نشد.", searchProjectsLabel: "جست‌وجوی پروژه‌ها...", - noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", - deletedProjectLabel: "پروژه حذف‌شده", - deletedTagLabel: "تگ حذف‌شده", - startRequiredError: "تاریخ و زمان شروع الزامی است.", - endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.", - invalidEndTimeError: "زمان پایان معتبر نیست.", - endBeforeStartError: "پایان باید بعد از شروع باشد.", - }, + noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", + deletedProjectLabel: "پروژه حذف‌شده", + deletedTagLabel: "تگ حذف‌شده", + startRequiredError: "تاریخ و زمان شروع الزامی است.", + endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.", + invalidEndTimeError: "زمان پایان معتبر نیست.", + endBeforeStartError: "پایان باید بعد از شروع باشد.", + }, reports: { title: "گزارش‌ها", description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`, diff --git a/src/pages/WorkspaceEdit.tsx b/src/pages/WorkspaceEdit.tsx index f10a994..c59c3c3 100644 --- a/src/pages/WorkspaceEdit.tsx +++ b/src/pages/WorkspaceEdit.tsx @@ -794,6 +794,21 @@ export default function EditWorkspace() { 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.", + helpTitle: t.workspace?.memberImport?.helpTitle || "Import help", + helpDescription: t.workspace?.memberImport?.helpDescription || "Use these rules to prepare a valid member import file.", + helpCurrencyTitle: t.workspace?.memberImport?.helpCurrencyTitle || "Currency options", + helpRolesTitle: t.workspace?.memberImport?.helpRolesTitle || "Role options", + roleAdminDescription: t.workspace?.memberImport?.roleAdminDescription || "Can manage workspace settings, members, projects, reports, and shared data.", + roleMemberDescription: t.workspace?.memberImport?.roleMemberDescription || "Can use the workspace normally and record time for accessible projects.", + roleGuestDescription: t.workspace?.memberImport?.roleGuestDescription || "Has limited workspace access and can record time only where access is granted.", + helpMobilePrefix: t.workspace?.memberImport?.helpMobilePrefix || "Column", + helpMobileSuffix: t.workspace?.memberImport?.helpMobileSuffix || "is required and must belong to an existing registered user.", + helpRolePrefix: t.workspace?.memberImport?.helpRolePrefix || "Column", + helpRoleSuffix: t.workspace?.memberImport?.helpRoleSuffix || "is optional. Empty role becomes member.", + helpRatePrefix: t.workspace?.memberImport?.helpRatePrefix || "Columns", + helpRateMiddle: t.workspace?.memberImport?.helpRateMiddle || "and", + helpRateSuffix: t.workspace?.memberImport?.helpRateSuffix || "are optional, but must be filled together when used.", + helpSamplesTitle: t.workspace?.memberImport?.helpSamplesTitle || "Sample files", 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.", @@ -802,6 +817,20 @@ export default function EditWorkspace() { 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.", + messageMap: t.workspace?.memberImport?.messagesMap || { + too_many_rows: "Import is limited to 500 rows.", + mobile_required: "Mobile is required.", + duplicate_mobile: "This mobile appears more than once.", + user_not_found: "No registered user was found with this mobile.", + already_member: "This user is already a workspace member.", + owner_role_not_allowed: "Owner role cannot be imported.", + invalid_role: "Role must be admin, member, or guest.", + role_permission_denied: "You do not have permission to assign this role.", + rate_currency_pair_required: "Hourly rate and currency must be provided together.", + hourly_rate_positive: "Hourly rate must be greater than zero.", + hourly_rate_invalid: "Hourly rate must be a valid number.", + currency_invalid: "Currency is not valid.", + }, }} /> ) : null}