fix(workspaces): streamline member import modal
Some checks are pending
Frontend CI/CD / build (push) Waiting to run
Frontend CI/CD / deploy (push) Blocked by required conditions

This commit is contained in:
2026-06-19 01:48:12 +03:30
parent c7ede31b68
commit 2e7ea40a79
4 changed files with 461 additions and 355 deletions

View File

@@ -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>
</>
); );
} }

View File

@@ -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",

View File

@@ -287,8 +287,8 @@ export const fa = {
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 پشتیبانی می‌شوند. ردیف اول باید عنوان ستون‌ها باشد.",
@@ -298,8 +298,8 @@ export const fa = {
sampleXlsx: "نمونه XLSX", sampleXlsx: "نمونه XLSX",
validate: "اعتبارسنجی فایل", validate: "اعتبارسنجی فایل",
validating: "در حال اعتبارسنجی...", validating: "در حال اعتبارسنجی...",
import: "درون‌ریزی اعضا", import: "اضافه‌کردن گروهی",
importing: "در حال درون‌ریزی...", importing: "در حال اضافه‌کردن...",
chooseFile: "انتخاب فایل", chooseFile: "انتخاب فایل",
selectedFile: "فایل انتخاب‌شده", selectedFile: "فایل انتخاب‌شده",
validRows: "ردیف‌های معتبر", validRows: "ردیف‌های معتبر",
@@ -317,6 +317,21 @@ export const fa = {
invalid: "نامعتبر", invalid: "نامعتبر",
noRows: "هنوز ردیفی بارگذاری نشده است.", noRows: "هنوز ردیفی بارگذاری نشده است.",
localErrors: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.", localErrors: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.",
helpTitle: "راهنمای اضافه‌کردن گروهی",
helpDescription: "برای آماده‌کردن فایل معتبر، این نکات را رعایت کنید.",
helpCurrencyTitle: "واحدهای پول معتبر",
helpRolesTitle: "نقش‌های معتبر",
roleAdminDescription: "می‌تواند تنظیمات، اعضا، پروژه‌ها، گزارش‌ها و داده‌های مشترک ورک‌اسپیس را مدیریت کند.",
roleMemberDescription: "می‌تواند به‌صورت عادی از ورک‌اسپیس استفاده کند و برای پروژه‌های در دسترس زمان ثبت کند.",
roleGuestDescription: "دسترسی محدود دارد و فقط برای پروژه‌هایی که به او دسترسی داده شده زمان ثبت می‌کند.",
helpMobilePrefix: "ستون",
helpMobileSuffix: "الزامی است و باید متعلق به یک کاربر ثبت‌نام‌شده باشد.",
helpRolePrefix: "ستون",
helpRoleSuffix: "اختیاری است. اگر خالی باشد member در نظر گرفته می‌شود.",
helpRatePrefix: "ستون‌های",
helpRateMiddle: "و",
helpRateSuffix: "اختیاری هستند، اما اگر یکی پر شود دیگری هم باید پر شود.",
helpSamplesTitle: "فایل‌های نمونه",
success: "اعضا با موفقیت درون‌ریزی شدند.", success: "اعضا با موفقیت درون‌ریزی شدند.",
parseFailed: "خواندن فایل ناموفق بود.", parseFailed: "خواندن فایل ناموفق بود.",
missingMobile: "موبایل الزامی است.", missingMobile: "موبایل الزامی است.",
@@ -325,6 +340,20 @@ export const fa = {
invalidRate: "نرخ ساعتی باید عددی معتبر و بزرگ‌تر از صفر باشد.", invalidRate: "نرخ ساعتی باید عددی معتبر و بزرگ‌تر از صفر باشد.",
rateCurrencyPair: "نرخ ساعتی و واحد پول باید با هم وارد شوند.", rateCurrencyPair: "نرخ ساعتی و واحد پول باید با هم وارد شوند.",
tooManyRows: "درون‌ریزی به ۵۰۰ ردیف محدود است.", 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: "مدیریت اعضا", manageMembers: "مدیریت اعضا",
mobileNumber: "شماره تماس", mobileNumber: "شماره تماس",
@@ -679,7 +708,7 @@ export const fa = {
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`, description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.", selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
addEntry: "افزودن ورودی", addEntry: "افزودن ورودی",
addManualEntry: "افزودن دستی زمان", addManualEntry: "افزودن زمان",
startTimer: "شروع تایمر", startTimer: "شروع تایمر",
stopTimer: "توقف تایمر", stopTimer: "توقف تایمر",
timerRunning: "تایمر فعال است", timerRunning: "تایمر فعال است",
@@ -694,7 +723,7 @@ export const fa = {
emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.", emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.",
noEntriesSearch: "عبارت جست‌وجو یا فیلترهای خود را تغییر دهید.", noEntriesSearch: "عبارت جست‌وجو یا فیلترهای خود را تغییر دهید.",
createTitle: "افزودن ورودی زمان", createTitle: "افزودن ورودی زمان",
manualCreateTitle: "افزودن دستی زمان", manualCreateTitle: "افزودن زمان",
startTitle: "شروع تایمر", startTitle: "شروع تایمر",
editTitle: "ویرایش ورودی زمان", editTitle: "ویرایش ورودی زمان",
createSuccess: "ورودی زمان با موفقیت ایجاد شد.", createSuccess: "ورودی زمان با موفقیت ایجاد شد.",

View File

@@ -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}