fix(workspaces): streamline member import modal
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { Fragment, useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, HelpCircle, Loader2, UploadCloud, XCircle } from "lucide-react";
|
||||||
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, UploadCloud, XCircle } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
type WorkspaceMemberImportValidationResponse,
|
type WorkspaceMemberImportValidationResponse,
|
||||||
} from "../../api/workspaces";
|
} from "../../api/workspaces";
|
||||||
import type { PriceUnit } from "../../api/rates";
|
import type { PriceUnit } from "../../api/rates";
|
||||||
|
import { Modal } from "../Modal";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
type ImportLabels = {
|
type ImportLabels = {
|
||||||
@@ -45,6 +45,21 @@ type ImportLabels = {
|
|||||||
invalid?: string;
|
invalid?: string;
|
||||||
noRows?: string;
|
noRows?: string;
|
||||||
localErrors?: 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;
|
success?: string;
|
||||||
parseFailed?: string;
|
parseFailed?: string;
|
||||||
missingMobile?: string;
|
missingMobile?: string;
|
||||||
@@ -53,11 +68,10 @@ type ImportLabels = {
|
|||||||
invalidRate?: string;
|
invalidRate?: string;
|
||||||
rateCurrencyPair?: string;
|
rateCurrencyPair?: string;
|
||||||
tooManyRows?: string;
|
tooManyRows?: string;
|
||||||
|
messageMap?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsedRow = WorkspaceMemberImportRowInput & {
|
type ParsedRow = WorkspaceMemberImportRowInput;
|
||||||
local_messages: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -68,8 +82,6 @@ type Props = {
|
|||||||
onImported: () => void | Promise<void>;
|
onImported: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ROWS = 500;
|
|
||||||
const ROLE_VALUES = new Set(["admin", "member", "guest"]);
|
|
||||||
const DIGIT_MAP: Record<string, string> = {
|
const DIGIT_MAP: Record<string, string> = {
|
||||||
"۰": "0",
|
"۰": "0",
|
||||||
"۱": "1",
|
"۱": "1",
|
||||||
@@ -176,46 +188,21 @@ const tableRowsToObjects = (rows: string[][]) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildParsedRows = (records: Array<{ line: number; record: Record<string, string> }>, labels: ImportLabels) => {
|
const buildParsedRows = (records: Array<{ line: number; record: Record<string, string> }>) => {
|
||||||
const seenMobiles = new Set<string>();
|
|
||||||
return records
|
return records
|
||||||
.filter(({ record }) => Object.values(record).some((value) => String(value || "").trim()))
|
.filter(({ record }) => Object.values(record).some((value) => String(value || "").trim()))
|
||||||
.map(({ line, record }) => {
|
.map(({ line, record }) => {
|
||||||
const mobile = normalizeDigits(record.mobile);
|
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 hourlyRate = normalizeDigits(record.hourly_rate);
|
||||||
const currency = normalizeDigits(record.currency).toUpperCase();
|
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 {
|
return {
|
||||||
line,
|
line,
|
||||||
mobile,
|
mobile,
|
||||||
role: ROLE_VALUES.has(role || "") ? role : "member",
|
role: (role || "member") as ParsedRow["role"],
|
||||||
hourly_rate: hourlyRate ? hourlyRate.replace(/,/g, "") : "",
|
hourly_rate: hourlyRate ? hourlyRate.replace(/,/g, "") : "",
|
||||||
currency,
|
currency,
|
||||||
local_messages: localMessages,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -237,6 +224,12 @@ const sampleRows = [
|
|||||||
["09999999998", "guest", "", ""],
|
["09999999998", "guest", "", ""],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ColumnCode = ({ children }: { children: string }) => (
|
||||||
|
<code className="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs font-semibold text-slate-800 dark:bg-slate-800 dark:text-slate-100">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
|
||||||
export function WorkspaceMemberImportModal({
|
export function WorkspaceMemberImportModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -252,15 +245,15 @@ export function WorkspaceMemberImportModal({
|
|||||||
const [validation, setValidation] = useState<WorkspaceMemberImportValidationResponse | null>(null);
|
const [validation, setValidation] = useState<WorkspaceMemberImportValidationResponse | null>(null);
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const [isImporting, setIsImporting] = 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 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(
|
const translateMessage = (message: string) => labels.messageMap?.[message] || message;
|
||||||
() => priceUnits.map((unit) => unit.code).filter(Boolean).join(", "),
|
|
||||||
[priceUnits],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setFileName("");
|
setFileName("");
|
||||||
@@ -269,6 +262,7 @@ export function WorkspaceMemberImportModal({
|
|||||||
setValidation(null);
|
setValidation(null);
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
|
setIsHelpOpen(false);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -279,6 +273,22 @@ export function WorkspaceMemberImportModal({
|
|||||||
onClose();
|
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) => {
|
const parseFile = async (file: File) => {
|
||||||
setParseError("");
|
setParseError("");
|
||||||
setValidation(null);
|
setValidation(null);
|
||||||
@@ -300,21 +310,9 @@ export function WorkspaceMemberImportModal({
|
|||||||
throw new Error("Unsupported file type.");
|
throw new Error("Unsupported file type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMobileHeader = records.length > 0 && Object.prototype.hasOwnProperty.call(records[0].record, "mobile");
|
const nextRows = buildParsedRows(records);
|
||||||
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);
|
setParsedRows(nextRows);
|
||||||
|
void validateRows(nextRows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setParsedRows([]);
|
setParsedRows([]);
|
||||||
setParseError(error instanceof Error ? error.message : labels.parseFailed || "Failed to parse the file.");
|
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 () => {
|
const handleImport = async () => {
|
||||||
if (!validation?.import_token) return;
|
if (!validation?.import_token) return;
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
@@ -380,216 +362,253 @@ export function WorkspaceMemberImportModal({
|
|||||||
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), `workspace-members-sample.${format}`);
|
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), `workspace-members-sample.${format}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayedRows: WorkspaceMemberImportResultRow[] =
|
const displayedRows: WorkspaceMemberImportResultRow[] = validation?.rows || [];
|
||||||
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 (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<>
|
||||||
<Dialog as="div" className="relative z-50" onClose={handleClose}>
|
<Modal
|
||||||
<Transition.Child
|
isOpen={isOpen}
|
||||||
as={Fragment}
|
onClose={handleClose}
|
||||||
enter="ease-out duration-200"
|
maxWidth="max-w-6xl"
|
||||||
enterFrom="opacity-0"
|
title={labels.title || "Import members"}
|
||||||
enterTo="opacity-100"
|
footer={
|
||||||
leave="ease-in duration-150"
|
<>
|
||||||
leaveFrom="opacity-100"
|
<Button type="button" variant="secondary" onClick={handleClose}>
|
||||||
leaveTo="opacity-0"
|
{labels.close || "Close"}
|
||||||
>
|
</Button>
|
||||||
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm" />
|
<Button type="button" disabled={!canCommit} onClick={handleImport}>
|
||||||
</Transition.Child>
|
{isImporting ? labels.importing || "Importing..." : labels.import || "Import members"}
|
||||||
|
</Button>
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
</>
|
||||||
<div className="flex min-h-full items-center justify-center p-4">
|
}
|
||||||
<Transition.Child
|
>
|
||||||
as={Fragment}
|
<div className="space-y-5">
|
||||||
enter="ease-out duration-200"
|
<div className="flex items-start justify-between gap-3">
|
||||||
enterFrom="opacity-0 scale-95"
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
enterTo="opacity-100 scale-100"
|
{labels.description ||
|
||||||
leave="ease-in duration-150"
|
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member."}
|
||||||
leaveFrom="opacity-100 scale-100"
|
</p>
|
||||||
leaveTo="opacity-0 scale-95"
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsHelpOpen(true)}
|
||||||
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 text-slate-500 transition hover:border-blue-300 hover:text-blue-600 dark:border-slate-700 dark:text-slate-400 dark:hover:border-blue-700 dark:hover:text-blue-300"
|
||||||
|
aria-label={labels.helpTitle || "Import help"}
|
||||||
>
|
>
|
||||||
<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">
|
<HelpCircle className="h-4 w-4" />
|
||||||
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-800">
|
</button>
|
||||||
<Dialog.Title className="text-lg font-semibold text-slate-950 dark:text-white">
|
</div>
|
||||||
{labels.title || "Import members"}
|
|
||||||
</Dialog.Title>
|
<div className="space-y-4">
|
||||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
<div className="space-y-4">
|
||||||
{labels.description ||
|
<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">
|
||||||
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member."}
|
<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()} disabled={isValidating}>
|
||||||
|
{isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||||
|
{isValidating ? labels.validating || "Validating..." : 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>
|
||||||
|
|
||||||
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-xl bg-emerald-50 p-3 dark:bg-emerald-950/30">
|
||||||
<div className="grid gap-5 p-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
<p className="text-xs text-emerald-700 dark:text-emerald-300">{labels.validRows || "Valid rows"}</p>
|
||||||
<div className="space-y-4">
|
<p className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">
|
||||||
<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">
|
{validation?.summary.valid ?? 0}
|
||||||
<UploadCloud className="mx-auto h-9 w-9 text-blue-500" />
|
</p>
|
||||||
<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>
|
||||||
|
<div className="rounded-xl bg-red-50 p-3 dark:bg-red-950/30">
|
||||||
<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">
|
<p className="text-xs text-red-700 dark:text-red-300">{labels.invalidRows || "Invalid rows"}</p>
|
||||||
<Button type="button" variant="secondary" onClick={handleClose}>
|
<p className="text-lg font-semibold text-red-700 dark:text-red-300">
|
||||||
{labels.close || "Close"}
|
{validation?.summary.invalid ?? 0}
|
||||||
</Button>
|
</p>
|
||||||
<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>
|
</div>
|
||||||
</Dialog.Panel>
|
</div>
|
||||||
</Transition.Child>
|
|
||||||
|
{isValidating ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-300">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{labels.validating || "Validating..."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="max-h-[460px] 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.map(translateMessage).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>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Modal>
|
||||||
</Transition>
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isHelpOpen}
|
||||||
|
onClose={() => setIsHelpOpen(false)}
|
||||||
|
title={labels.helpTitle || "Import help"}
|
||||||
|
maxWidth="max-w-lg"
|
||||||
|
footer={
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setIsHelpOpen(false)}>
|
||||||
|
{labels.close || "Close"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
<p>{labels.helpDescription || "Use these rules to prepare a valid member import file."}</p>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-semibold text-slate-900 dark:text-white">{labels.helpCurrencyTitle || "Currency options"}</h4>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{currencyColumns.map((units, columnIndex) => (
|
||||||
|
<ul key={columnIndex} className="list-disc space-y-2 ps-5">
|
||||||
|
{units.map((unit) => (
|
||||||
|
<li key={unit.id || unit.code}>
|
||||||
|
<ColumnCode>{unit.code}</ColumnCode>
|
||||||
|
<span className="mx-1">:</span>
|
||||||
|
<span>{unit.local_name || unit.name || unit.symbol || unit.code}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-semibold text-slate-900 dark:text-white">{labels.helpRolesTitle || "Role options"}</h4>
|
||||||
|
<ul className="list-disc space-y-2 ps-5">
|
||||||
|
<li>
|
||||||
|
<ColumnCode>admin</ColumnCode>
|
||||||
|
<span className="mx-1">:</span>
|
||||||
|
<span>{labels.roleAdminDescription || "Can manage workspace settings, members, projects, reports, and shared data."}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ColumnCode>member</ColumnCode>
|
||||||
|
<span className="mx-1">:</span>
|
||||||
|
<span>{labels.roleMemberDescription || "Can use the workspace normally and record time for accessible projects."}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ColumnCode>guest</ColumnCode>
|
||||||
|
<span className="mx-1">:</span>
|
||||||
|
<span>{labels.roleGuestDescription || "Has limited workspace access and can record time only where access is granted."}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="list-disc space-y-2 ps-5">
|
||||||
|
<li>
|
||||||
|
{labels.helpMobilePrefix || "Column"} <ColumnCode>mobile</ColumnCode>{" "}
|
||||||
|
{labels.helpMobileSuffix || "is required and must belong to an existing registered user."}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{labels.helpRolePrefix || "Column"} <ColumnCode>role</ColumnCode>{" "}
|
||||||
|
{labels.helpRoleSuffix || "is optional. Empty role becomes member."}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{labels.helpRatePrefix || "Columns"} <ColumnCode>hourly_rate</ColumnCode>{" "}
|
||||||
|
{labels.helpRateMiddle || "and"} <ColumnCode>currency</ColumnCode>{" "}
|
||||||
|
{labels.helpRateSuffix || "are optional, but must be filled together when used."}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-semibold text-slate-900 dark:text-white">{labels.helpSamplesTitle || "Sample files"}</h4>
|
||||||
|
<div className="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>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,21 @@ export const en = {
|
|||||||
invalid: "Invalid",
|
invalid: "Invalid",
|
||||||
noRows: "No rows loaded yet.",
|
noRows: "No rows loaded yet.",
|
||||||
localErrors: "Fix local file errors before backend validation.",
|
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.",
|
success: "Members imported successfully.",
|
||||||
parseFailed: "Failed to parse the file.",
|
parseFailed: "Failed to parse the file.",
|
||||||
missingMobile: "Mobile is required.",
|
missingMobile: "Mobile is required.",
|
||||||
@@ -323,6 +338,20 @@ export const en = {
|
|||||||
invalidRate: "Hourly rate must be a valid positive number.",
|
invalidRate: "Hourly rate must be a valid positive number.",
|
||||||
rateCurrencyPair: "Hourly rate and currency must be provided together.",
|
rateCurrencyPair: "Hourly rate and currency must be provided together.",
|
||||||
tooManyRows: "Import is limited to 500 rows.",
|
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.",
|
membersLocked: "Only owners and admins can view the full member list.",
|
||||||
manageMembers: "Manage members",
|
manageMembers: "Manage members",
|
||||||
|
|||||||
@@ -283,50 +283,79 @@ export const fa = {
|
|||||||
statsOwnersAdmins: "مالکان و ادمینها",
|
statsOwnersAdmins: "مالکان و ادمینها",
|
||||||
statsGuests: "مهمانها",
|
statsGuests: "مهمانها",
|
||||||
membersSectionTitle: "اعضا",
|
membersSectionTitle: "اعضا",
|
||||||
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||||
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||||
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
||||||
memberImport: {
|
memberImport: {
|
||||||
button: "درونریزی اعضا",
|
button: "اضافهکردن گروهی",
|
||||||
title: "درونریزی اعضا",
|
title: "اضافهکردن گروهی",
|
||||||
description: "فایلی با ستونهای mobile، role، hourly_rate و currency بارگذاری کنید. موبایل الزامی است و نقش در صورت خالی بودن عضو در نظر گرفته میشود.",
|
description: "فایلی با ستونهای mobile، role، hourly_rate و currency بارگذاری کنید. موبایل الزامی است و نقش در صورت خالی بودن عضو در نظر گرفته میشود.",
|
||||||
uploadTitle: "بارگذاری فایل اعضا",
|
uploadTitle: "بارگذاری فایل اعضا",
|
||||||
uploadDescription: "فرمتهای CSV، TSV، TXT یا XLSX پشتیبانی میشوند. ردیف اول باید عنوان ستونها باشد.",
|
uploadDescription: "فرمتهای CSV، TSV، TXT یا XLSX پشتیبانی میشوند. ردیف اول باید عنوان ستونها باشد.",
|
||||||
sampleCsv: "نمونه CSV",
|
sampleCsv: "نمونه CSV",
|
||||||
sampleTsv: "نمونه TSV",
|
sampleTsv: "نمونه TSV",
|
||||||
sampleTxt: "نمونه TXT",
|
sampleTxt: "نمونه TXT",
|
||||||
sampleXlsx: "نمونه XLSX",
|
sampleXlsx: "نمونه XLSX",
|
||||||
validate: "اعتبارسنجی فایل",
|
validate: "اعتبارسنجی فایل",
|
||||||
validating: "در حال اعتبارسنجی...",
|
validating: "در حال اعتبارسنجی...",
|
||||||
import: "درونریزی اعضا",
|
import: "اضافهکردن گروهی",
|
||||||
importing: "در حال درونریزی...",
|
importing: "در حال اضافهکردن...",
|
||||||
chooseFile: "انتخاب فایل",
|
chooseFile: "انتخاب فایل",
|
||||||
selectedFile: "فایل انتخابشده",
|
selectedFile: "فایل انتخابشده",
|
||||||
validRows: "ردیفهای معتبر",
|
validRows: "ردیفهای معتبر",
|
||||||
invalidRows: "ردیفهای نامعتبر",
|
invalidRows: "ردیفهای نامعتبر",
|
||||||
totalRows: "کل ردیفها",
|
totalRows: "کل ردیفها",
|
||||||
line: "ردیف",
|
line: "ردیف",
|
||||||
mobile: "موبایل",
|
mobile: "موبایل",
|
||||||
user: "کاربر",
|
user: "کاربر",
|
||||||
role: "نقش",
|
role: "نقش",
|
||||||
hourlyRate: "نرخ ساعتی",
|
hourlyRate: "نرخ ساعتی",
|
||||||
currency: "واحد پول",
|
currency: "واحد پول",
|
||||||
status: "وضعیت",
|
status: "وضعیت",
|
||||||
messages: "پیامها",
|
messages: "پیامها",
|
||||||
valid: "معتبر",
|
valid: "معتبر",
|
||||||
invalid: "نامعتبر",
|
invalid: "نامعتبر",
|
||||||
noRows: "هنوز ردیفی بارگذاری نشده است.",
|
noRows: "هنوز ردیفی بارگذاری نشده است.",
|
||||||
localErrors: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.",
|
localErrors: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.",
|
||||||
success: "اعضا با موفقیت درونریزی شدند.",
|
helpTitle: "راهنمای اضافهکردن گروهی",
|
||||||
parseFailed: "خواندن فایل ناموفق بود.",
|
helpDescription: "برای آمادهکردن فایل معتبر، این نکات را رعایت کنید.",
|
||||||
missingMobile: "موبایل الزامی است.",
|
helpCurrencyTitle: "واحدهای پول معتبر",
|
||||||
duplicateMobile: "این موبایل بیش از یک بار در فایل آمده است.",
|
helpRolesTitle: "نقشهای معتبر",
|
||||||
invalidRole: "نقش باید admin، member یا guest باشد.",
|
roleAdminDescription: "میتواند تنظیمات، اعضا، پروژهها، گزارشها و دادههای مشترک ورکاسپیس را مدیریت کند.",
|
||||||
invalidRate: "نرخ ساعتی باید عددی معتبر و بزرگتر از صفر باشد.",
|
roleMemberDescription: "میتواند بهصورت عادی از ورکاسپیس استفاده کند و برای پروژههای در دسترس زمان ثبت کند.",
|
||||||
rateCurrencyPair: "نرخ ساعتی و واحد پول باید با هم وارد شوند.",
|
roleGuestDescription: "دسترسی محدود دارد و فقط برای پروژههایی که به او دسترسی داده شده زمان ثبت میکند.",
|
||||||
tooManyRows: "درونریزی به ۵۰۰ ردیف محدود است.",
|
helpMobilePrefix: "ستون",
|
||||||
},
|
helpMobileSuffix: "الزامی است و باید متعلق به یک کاربر ثبتنامشده باشد.",
|
||||||
manageMembers: "مدیریت اعضا",
|
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: "شماره تماس",
|
mobileNumber: "شماره تماس",
|
||||||
youLabel: "شما",
|
youLabel: "شما",
|
||||||
resourcesTitle: "منابع",
|
resourcesTitle: "منابع",
|
||||||
@@ -341,10 +370,10 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
createdSuccess: "ورکاسپیس با موفقیت ایجاد شد",
|
createdSuccess: "ورکاسپیس با موفقیت ایجاد شد",
|
||||||
updatedSuccess: "ورکاسپیس با موفقیت ویرایش شد",
|
updatedSuccess: "ورکاسپیس با موفقیت ویرایش شد",
|
||||||
fetchError: "خطا در دریافت اطلاعات ورکاسپیس",
|
fetchError: "خطا در دریافت اطلاعات ورکاسپیس",
|
||||||
loadErrorDescription: "ممکن است سرویس بکاند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.",
|
loadErrorDescription: "ممکن است سرویس بکاند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.",
|
||||||
retry: "تلاش دوباره",
|
retry: "تلاش دوباره",
|
||||||
remove: "حذف",
|
remove: "حذف",
|
||||||
noUsersFound: "کاربری یافت نشد",
|
noUsersFound: "کاربری یافت نشد",
|
||||||
selectRole: "انتخاب نقش",
|
selectRole: "انتخاب نقش",
|
||||||
add: "افزودن",
|
add: "افزودن",
|
||||||
@@ -433,14 +462,14 @@ export const fa = {
|
|||||||
collapse: 'جمع کردن',
|
collapse: 'جمع کردن',
|
||||||
},
|
},
|
||||||
|
|
||||||
landing: {
|
landing: {
|
||||||
brandLabel: "زیرساخت عملیاتی زمان",
|
brandLabel: "زیرساخت عملیاتی زمان",
|
||||||
eyebrow: "طراحیشده برای تیمهای دقیق که به داده زمانی قابل اتکا نیاز دارند",
|
eyebrow: "طراحیشده برای تیمهای دقیق که به داده زمانی قابل اتکا نیاز دارند",
|
||||||
nav: {
|
nav: {
|
||||||
demo: "دموی محصول",
|
demo: "دموی محصول",
|
||||||
features: "قابلیتها",
|
features: "قابلیتها",
|
||||||
workflow: "فرآیند کار",
|
workflow: "فرآیند کار",
|
||||||
about: "درباره ما",
|
about: "درباره ما",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
switchToEnglish: "English",
|
switchToEnglish: "English",
|
||||||
@@ -449,9 +478,9 @@ export const fa = {
|
|||||||
openApp: "ورود به اپ",
|
openApp: "ورود به اپ",
|
||||||
openWorkspace: "باز کردن ورکاسپیس",
|
openWorkspace: "باز کردن ورکاسپیس",
|
||||||
startNow: "شروع با کنترل کامل",
|
startNow: "شروع با کنترل کامل",
|
||||||
watchDemo: "مشاهده دموی محصول",
|
watchDemo: "مشاهده دموی محصول",
|
||||||
readTerms: "مطالعه قوانین",
|
readTerms: "مطالعه قوانین",
|
||||||
readAbout: "درباره Qlockify",
|
readAbout: "درباره Qlockify",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
|
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
|
||||||
@@ -521,16 +550,16 @@ export const fa = {
|
|||||||
finalCtaTitle: "اگر تیم شما تخصص میفروشد یا پروژه مشتری تحویل میدهد، سیستم زمان شما هم باید همینقدر جدی باشد.",
|
finalCtaTitle: "اگر تیم شما تخصص میفروشد یا پروژه مشتری تحویل میدهد، سیستم زمان شما هم باید همینقدر جدی باشد.",
|
||||||
finalCtaDescription:
|
finalCtaDescription:
|
||||||
"اپ را باز کنید، ورکاسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف میکند، انضباط گزارشدهی چقدر سریع بهتر میشود.",
|
"اپ را باز کنید، ورکاسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف میکند، انضباط گزارشدهی چقدر سریع بهتر میشود.",
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
badge: "محیط دمو",
|
badge: "محیط دمو",
|
||||||
starting: "در حال آمادهسازی دمو...",
|
starting: "در حال آمادهسازی دمو...",
|
||||||
started: "محیط دمو آماده شد.",
|
started: "محیط دمو آماده شد.",
|
||||||
startError: "امکان ساخت محیط دمو وجود ندارد.",
|
startError: "امکان ساخت محیط دمو وجود ندارد.",
|
||||||
expiresAt: "زمان انقضا",
|
expiresAt: "زمان انقضا",
|
||||||
resetAction: "شروع دوباره دمو",
|
resetAction: "شروع دوباره دمو",
|
||||||
reset: "محیط دموی تازه آماده شد.",
|
reset: "محیط دموی تازه آماده شد.",
|
||||||
},
|
},
|
||||||
|
|
||||||
ordering: {
|
ordering: {
|
||||||
createdAtDesc: "جدیدترین",
|
createdAtDesc: "جدیدترین",
|
||||||
@@ -677,10 +706,10 @@ export const fa = {
|
|||||||
timesheet: {
|
timesheet: {
|
||||||
title: "تایمشیت",
|
title: "تایمشیت",
|
||||||
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
addEntry: "افزودن ورودی",
|
addEntry: "افزودن ورودی",
|
||||||
addManualEntry: "افزودن دستی زمان",
|
addManualEntry: "افزودن زمان",
|
||||||
startTimer: "شروع تایمر",
|
startTimer: "شروع تایمر",
|
||||||
stopTimer: "توقف تایمر",
|
stopTimer: "توقف تایمر",
|
||||||
timerRunning: "تایمر فعال است",
|
timerRunning: "تایمر فعال است",
|
||||||
runningLabel: "تایمر فعلی",
|
runningLabel: "تایمر فعلی",
|
||||||
@@ -693,9 +722,9 @@ export const fa = {
|
|||||||
emptyDescription: "بدون توضیح",
|
emptyDescription: "بدون توضیح",
|
||||||
emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.",
|
emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.",
|
||||||
noEntriesSearch: "عبارت جستوجو یا فیلترهای خود را تغییر دهید.",
|
noEntriesSearch: "عبارت جستوجو یا فیلترهای خود را تغییر دهید.",
|
||||||
createTitle: "افزودن ورودی زمان",
|
createTitle: "افزودن ورودی زمان",
|
||||||
manualCreateTitle: "افزودن دستی زمان",
|
manualCreateTitle: "افزودن زمان",
|
||||||
startTitle: "شروع تایمر",
|
startTitle: "شروع تایمر",
|
||||||
editTitle: "ویرایش ورودی زمان",
|
editTitle: "ویرایش ورودی زمان",
|
||||||
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
|
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
|
||||||
startSuccess: "تایمر با موفقیت شروع شد.",
|
startSuccess: "تایمر با موفقیت شروع شد.",
|
||||||
@@ -737,14 +766,14 @@ export const fa = {
|
|||||||
searchTagsLabel: "جستوجوی تگها...",
|
searchTagsLabel: "جستوجوی تگها...",
|
||||||
noTagsFoundLabel: "تگی پیدا نشد.",
|
noTagsFoundLabel: "تگی پیدا نشد.",
|
||||||
searchProjectsLabel: "جستوجوی پروژهها...",
|
searchProjectsLabel: "جستوجوی پروژهها...",
|
||||||
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
||||||
deletedProjectLabel: "پروژه حذفشده",
|
deletedProjectLabel: "پروژه حذفشده",
|
||||||
deletedTagLabel: "تگ حذفشده",
|
deletedTagLabel: "تگ حذفشده",
|
||||||
startRequiredError: "تاریخ و زمان شروع الزامی است.",
|
startRequiredError: "تاریخ و زمان شروع الزامی است.",
|
||||||
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
|
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
|
||||||
invalidEndTimeError: "زمان پایان معتبر نیست.",
|
invalidEndTimeError: "زمان پایان معتبر نیست.",
|
||||||
endBeforeStartError: "پایان باید بعد از شروع باشد.",
|
endBeforeStartError: "پایان باید بعد از شروع باشد.",
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "گزارشها",
|
title: "گزارشها",
|
||||||
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
||||||
|
|||||||
@@ -794,6 +794,21 @@ export default function EditWorkspace() {
|
|||||||
invalid: t.workspace?.memberImport?.invalid || "Invalid",
|
invalid: t.workspace?.memberImport?.invalid || "Invalid",
|
||||||
noRows: t.workspace?.memberImport?.noRows || "No rows loaded yet.",
|
noRows: t.workspace?.memberImport?.noRows || "No rows loaded yet.",
|
||||||
localErrors: t.workspace?.memberImport?.localErrors || "Fix local file errors before backend validation.",
|
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.",
|
success: t.workspace?.memberImport?.success || "Members imported successfully.",
|
||||||
parseFailed: t.workspace?.memberImport?.parseFailed || "Failed to parse the file.",
|
parseFailed: t.workspace?.memberImport?.parseFailed || "Failed to parse the file.",
|
||||||
missingMobile: t.workspace?.memberImport?.missingMobile || "Mobile is required.",
|
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.",
|
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.",
|
rateCurrencyPair: t.workspace?.memberImport?.rateCurrencyPair || "Hourly rate and currency must be provided together.",
|
||||||
tooManyRows: t.workspace?.memberImport?.tooManyRows || "Import is limited to 500 rows.",
|
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}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user