feat(workspaces): add bulk member import modal

This commit is contained in:
2026-06-18 22:53:44 +03:30
parent 29cadb83e6
commit 55ba274346
7 changed files with 961 additions and 28 deletions

View File

@@ -0,0 +1,595 @@
import { Fragment, useMemo, useRef, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, UploadCloud, XCircle } from "lucide-react";
import { toast } from "sonner";
import * as XLSX from "xlsx";
import {
commitWorkspaceMemberImport,
validateWorkspaceMemberImport,
type WorkspaceMemberImportResultRow,
type WorkspaceMemberImportRowInput,
type WorkspaceMemberImportValidationResponse,
} from "../../api/workspaces";
import type { PriceUnit } from "../../api/rates";
import { Button } from "../ui/button";
type ImportLabels = {
title?: string;
description?: string;
uploadTitle?: string;
uploadDescription?: string;
sampleCsv?: string;
sampleTsv?: string;
sampleTxt?: string;
sampleXlsx?: string;
validate?: string;
validating?: string;
import?: string;
importing?: string;
close?: string;
chooseFile?: string;
selectedFile?: string;
validRows?: string;
invalidRows?: string;
totalRows?: string;
line?: string;
mobile?: string;
user?: string;
role?: string;
hourlyRate?: string;
currency?: string;
status?: string;
messages?: string;
valid?: string;
invalid?: string;
noRows?: string;
localErrors?: string;
success?: string;
parseFailed?: string;
missingMobile?: string;
duplicateMobile?: string;
invalidRole?: string;
invalidRate?: string;
rateCurrencyPair?: string;
tooManyRows?: string;
};
type ParsedRow = WorkspaceMemberImportRowInput & {
local_messages: string[];
};
type Props = {
isOpen: boolean;
onClose: () => void;
workspaceId: string;
priceUnits: PriceUnit[];
labels: ImportLabels;
onImported: () => void | Promise<void>;
};
const MAX_ROWS = 500;
const ROLE_VALUES = new Set(["admin", "member", "guest"]);
const DIGIT_MAP: Record<string, string> = {
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
"٠": "0",
"١": "1",
"٢": "2",
"٣": "3",
"٤": "4",
"٥": "5",
"٦": "6",
"٧": "7",
"٨": "8",
"٩": "9",
};
const normalizeDigits = (value: unknown) =>
String(value ?? "")
.replace(/[۰-۹٠-٩]/g, (digit) => DIGIT_MAP[digit] || digit)
.trim();
const normalizeHeader = (value: unknown) =>
String(value ?? "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
const parseDelimited = (text: string, delimiter: string) => {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (char === '"' && inQuotes && next === '"') {
cell += '"';
index += 1;
continue;
}
if (char === '"') {
inQuotes = !inQuotes;
continue;
}
if (char === delimiter && !inQuotes) {
row.push(cell.trim());
cell = "";
continue;
}
if ((char === "\n" || char === "\r") && !inQuotes) {
if (char === "\r" && next === "\n") {
index += 1;
}
row.push(cell.trim());
if (row.some((item) => item !== "")) {
rows.push(row);
}
row = [];
cell = "";
continue;
}
cell += char;
}
row.push(cell.trim());
if (row.some((item) => item !== "")) {
rows.push(row);
}
return rows;
};
const detectDelimiter = (text: string, extension: string) => {
if (extension === "tsv") return "\t";
const firstLine = text.split(/\r?\n/).find((line) => line.trim()) || "";
const candidates = [",", ";", "\t", "|"];
return candidates
.map((delimiter) => ({
delimiter,
count: firstLine.split(delimiter).length,
}))
.sort((a, b) => b.count - a.count)[0]?.delimiter || ",";
};
const tableRowsToObjects = (rows: string[][]) => {
const [headers = [], ...body] = rows;
const normalizedHeaders = headers.map(normalizeHeader);
return body.map((row, index) => {
const record: Record<string, string> = {};
normalizedHeaders.forEach((header, headerIndex) => {
if (header) {
record[header] = row[headerIndex] || "";
}
});
return { line: index + 2, record };
});
};
const buildParsedRows = (records: Array<{ line: number; record: Record<string, string> }>, labels: ImportLabels) => {
const seenMobiles = new Set<string>();
return records
.filter(({ record }) => Object.values(record).some((value) => String(value || "").trim()))
.map(({ line, record }) => {
const mobile = normalizeDigits(record.mobile);
const role = normalizeDigits(record.role || "member").toLowerCase() as ParsedRow["role"];
const hourlyRate = normalizeDigits(record.hourly_rate);
const currency = normalizeDigits(record.currency).toUpperCase();
const localMessages: string[] = [];
if (!mobile) {
localMessages.push(labels.missingMobile || "Mobile is required.");
} else if (seenMobiles.has(mobile)) {
localMessages.push(labels.duplicateMobile || "This mobile appears more than once.");
} else {
seenMobiles.add(mobile);
}
if (!ROLE_VALUES.has(role || "member")) {
localMessages.push(labels.invalidRole || "Role must be admin, member, or guest.");
}
if (hourlyRate && Number.isNaN(Number(hourlyRate.replace(/,/g, "")))) {
localMessages.push(labels.invalidRate || "Hourly rate must be a valid number.");
}
if (hourlyRate && Number(hourlyRate.replace(/,/g, "")) <= 0) {
localMessages.push(labels.invalidRate || "Hourly rate must be greater than zero.");
}
if (Boolean(hourlyRate) !== Boolean(currency)) {
localMessages.push(labels.rateCurrencyPair || "Hourly rate and currency must be provided together.");
}
return {
line,
mobile,
role: ROLE_VALUES.has(role || "") ? role : "member",
hourly_rate: hourlyRate ? hourlyRate.replace(/,/g, "") : "",
currency,
local_messages: localMessages,
};
});
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const sampleRows = [
["mobile", "role", "hourly_rate", "currency"],
["09999999999", "member", "150000", "IRT"],
["09999999998", "guest", "", ""],
];
export function WorkspaceMemberImportModal({
isOpen,
onClose,
workspaceId,
priceUnits,
labels,
onImported,
}: Props) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [parseError, setParseError] = useState("");
const [validation, setValidation] = useState<WorkspaceMemberImportValidationResponse | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const localInvalidCount = parsedRows.filter((row) => row.local_messages.length > 0).length;
const canValidate = parsedRows.length > 0 && localInvalidCount === 0 && !isValidating;
const canCommit = Boolean(validation?.can_commit && validation.import_token) && !isImporting;
const currencyCodes = useMemo(
() => priceUnits.map((unit) => unit.code).filter(Boolean).join(", "),
[priceUnits],
);
const reset = () => {
setFileName("");
setParsedRows([]);
setParseError("");
setValidation(null);
setIsValidating(false);
setIsImporting(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClose = () => {
reset();
onClose();
};
const parseFile = async (file: File) => {
setParseError("");
setValidation(null);
setFileName(file.name);
const extension = file.name.split(".").pop()?.toLowerCase() || "";
try {
let records: Array<{ line: number; record: Record<string, string> }> = [];
if (extension === "xlsx") {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: "array" });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, defval: "" });
records = tableRowsToObjects(rows);
} else if (["csv", "tsv", "txt"].includes(extension)) {
const text = await file.text();
const delimiter = detectDelimiter(text, extension);
records = tableRowsToObjects(parseDelimited(text, delimiter));
} else {
throw new Error("Unsupported file type.");
}
const hasMobileHeader = records.length > 0 && Object.prototype.hasOwnProperty.call(records[0].record, "mobile");
if (!hasMobileHeader && records.length === 0) {
throw new Error(labels.noRows || "No rows were found in this file.");
}
if (!hasMobileHeader) {
throw new Error(labels.missingMobile || "The file must include a mobile column.");
}
const nextRows = buildParsedRows(records, labels);
if (nextRows.length > MAX_ROWS) {
setParseError(labels.tooManyRows || `Import is limited to ${MAX_ROWS} rows.`);
setParsedRows([]);
return;
}
setParsedRows(nextRows);
} catch (error) {
setParsedRows([]);
setParseError(error instanceof Error ? error.message : labels.parseFailed || "Failed to parse the file.");
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
void parseFile(file);
}
};
const handleValidate = async () => {
setIsValidating(true);
setValidation(null);
try {
const response = await validateWorkspaceMemberImport({
workspace: workspaceId,
rows: parsedRows.map(({ local_messages: _localMessages, ...row }) => row),
});
setValidation(response);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.parseFailed || "Failed to validate member import");
} finally {
setIsValidating(false);
}
};
const handleImport = async () => {
if (!validation?.import_token) return;
setIsImporting(true);
try {
await commitWorkspaceMemberImport({
workspace: workspaceId,
import_token: validation.import_token,
});
toast.success(labels.success || "Members imported successfully.");
await onImported();
handleClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to import members");
} finally {
setIsImporting(false);
}
};
const downloadSample = (format: "csv" | "tsv" | "txt" | "xlsx") => {
if (format === "xlsx") {
const worksheet = XLSX.utils.aoa_to_sheet(sampleRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "members");
const output = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
downloadBlob(
new Blob([output], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
"workspace-members-sample.xlsx",
);
return;
}
const delimiter = format === "tsv" ? "\t" : ",";
const content = sampleRows.map((row) => row.join(delimiter)).join("\n");
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), `workspace-members-sample.${format}`);
};
const displayedRows: WorkspaceMemberImportResultRow[] =
validation?.rows ||
parsedRows.map((row) => ({
line: row.line,
mobile: row.mobile,
role: row.role || "member",
hourly_rate: row.hourly_rate || "",
currency: row.currency || "",
status: row.local_messages.length ? "invalid" : "valid",
action: row.local_messages.length ? "none" : "add_member",
user: null,
messages: row.local_messages,
}));
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-5xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-950">
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-800">
<Dialog.Title className="text-lg font-semibold text-slate-950 dark:text-white">
{labels.title || "Import members"}
</Dialog.Title>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{labels.description ||
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member."}
</p>
</div>
<div className="grid gap-5 p-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-5 text-center dark:border-slate-700 dark:bg-slate-900/60">
<UploadCloud className="mx-auto h-9 w-9 text-blue-500" />
<h3 className="mt-3 text-sm font-semibold text-slate-900 dark:text-white">
{labels.uploadTitle || "Upload file"}
</h3>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">
{labels.uploadDescription || "CSV, TSV, TXT, or XLSX. The first row must be headers."}
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.tsv,.txt,.xlsx"
className="hidden"
onChange={handleFileChange}
/>
<Button type="button" className="mt-4 gap-2" onClick={() => fileInputRef.current?.click()}>
<FileSpreadsheet className="h-4 w-4" />
{labels.chooseFile || "Choose file"}
</Button>
{fileName ? (
<p className="mt-3 truncate text-xs text-slate-500 dark:text-slate-400">
{(labels.selectedFile || "Selected file")}: {fileName}
</p>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 p-4 dark:border-slate-800">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{labels.currency || "Currency"}: {currencyCodes || "-"}
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("csv")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleCsv || "CSV sample"}
</Button>
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("tsv")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleTsv || "TSV sample"}
</Button>
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("txt")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleTxt || "TXT sample"}
</Button>
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("xlsx")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleXlsx || "XLSX sample"}
</Button>
</div>
</div>
{parseError ? (
<div className="flex gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{parseError}
</div>
) : null}
</div>
<div className="min-w-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-slate-100 p-3 dark:bg-slate-900">
<p className="text-xs text-slate-500">{labels.totalRows || "Total rows"}</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{validation?.summary.total ?? parsedRows.length}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3 dark:bg-emerald-950/30">
<p className="text-xs text-emerald-700 dark:text-emerald-300">{labels.validRows || "Valid rows"}</p>
<p className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">
{validation?.summary.valid ?? parsedRows.length - localInvalidCount}
</p>
</div>
<div className="rounded-xl bg-red-50 p-3 dark:bg-red-950/30">
<p className="text-xs text-red-700 dark:text-red-300">{labels.invalidRows || "Invalid rows"}</p>
<p className="text-lg font-semibold text-red-700 dark:text-red-300">
{validation?.summary.invalid ?? localInvalidCount}
</p>
</div>
</div>
{localInvalidCount > 0 && !validation ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300">
{labels.localErrors || "Fix local file errors before backend validation."}
</div>
) : null}
<div className="max-h-[440px] overflow-auto rounded-xl border border-slate-200 dark:border-slate-800">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="sticky top-0 bg-slate-100 text-xs uppercase text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-3 py-2 text-start">{labels.line || "Line"}</th>
<th className="px-3 py-2 text-start">{labels.mobile || "Mobile"}</th>
<th className="px-3 py-2 text-start">{labels.user || "User"}</th>
<th className="px-3 py-2 text-start">{labels.role || "Role"}</th>
<th className="px-3 py-2 text-start">{labels.hourlyRate || "Hourly rate"}</th>
<th className="px-3 py-2 text-start">{labels.currency || "Currency"}</th>
<th className="px-3 py-2 text-start">{labels.status || "Status"}</th>
<th className="px-3 py-2 text-start">{labels.messages || "Messages"}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{displayedRows.length ? (
displayedRows.map((row) => (
<tr key={`${row.line}-${row.mobile}`} className="bg-white dark:bg-slate-950">
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.line ?? "-"}</td>
<td className="px-3 py-2 font-medium text-slate-900 dark:text-white">{row.mobile || "-"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.user?.full_name || "-"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.role || "member"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.hourly_rate || "-"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.currency || "-"}</td>
<td className="px-3 py-2">
{row.status === "valid" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
{labels.valid || "Valid"}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300">
<XCircle className="h-3 w-3" />
{labels.invalid || "Invalid"}
</span>
)}
</td>
<td className="min-w-[220px] px-3 py-2 text-xs text-slate-500 dark:text-slate-400">
{row.messages.length ? row.messages.join(" | ") : "-"}
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-slate-500 dark:text-slate-400">
{labels.noRows || "No rows loaded yet."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" onClick={handleClose}>
{labels.close || "Close"}
</Button>
<Button type="button" variant="secondary" disabled={!canValidate} onClick={handleValidate}>
{isValidating ? labels.validating || "Validating..." : labels.validate || "Validate file"}
</Button>
<Button type="button" disabled={!canCommit} onClick={handleImport}>
{isImporting ? labels.importing || "Importing..." : labels.import || "Import members"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}