diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx index ad7bdf0..c4da54b 100644 --- a/app/dashboard/users/page.tsx +++ b/app/dashboard/users/page.tsx @@ -11,11 +11,15 @@ import { Card, Modal, PageHeader, + Field, + Input, Pagination, Select, Spinner, } from "@/components/ui"; -import { EyeIcon } from "@/components/icons"; +import { DownloadIcon, EyeIcon } from "@/components/icons"; +import { useToast } from "@/components/toast"; +import { exportToExcel, mapWithConcurrency } from "@/lib/excel"; import { toFa } from "@/lib/utils"; interface MeditationUser { @@ -61,6 +65,11 @@ interface DetailData { questions_answered?: number; } +// Confirmation password for the full export. This is a guard against an +// accidental click, not a security boundary — it ships in the client bundle, so +// anyone with the page can read it. Real protection belongs on the API side. +const EXPORT_PASSWORD = "amnk1380"; + const GENDER_LABELS: Record = { 1: "مرد", 2: "زن", @@ -83,6 +92,16 @@ export default function UsersPage() { query: answered ? { answered } : undefined, }); + const toast = useToast(); + const [exporting, setExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + + // The full export hammers the API with one request per user, so it sits + // behind a confirmation password instead of being one stray click away. + const [gateOpen, setGateOpen] = useState(false); + const [gatePassword, setGatePassword] = useState(""); + const [gateError, setGateError] = useState(null); + const [detailUser, setDetailUser] = useState(null); const [detailData, setDetailData] = useState(null); const [detailOpen, setDetailOpen] = useState(false); @@ -134,6 +153,162 @@ export default function UsersPage() { setDetailLoading(false); } + function genderLabel(value?: number | null) { + return value != null ? GENDER_LABELS[value] ?? "—" : "—"; + } + + function openGate() { + if (exporting) return; + setGatePassword(""); + setGateError(null); + setGateOpen(true); + } + + function submitGate() { + if (gatePassword !== EXPORT_PASSWORD) { + setGateError("رمز عبور نادرست است."); + return; + } + setGateOpen(false); + setGatePassword(""); + setGateError(null); + void exportAll(); + } + + // Full export: walks every page of the current filter, then pulls each user's + // survey answers so the sheet gets one column per question. + async function exportAll() { + if (exporting) return; + setExporting(true); + setExportProgress(0); + + try { + const users: MeditationUser[] = []; + let currentPage = 1; + let lastPage = 1; + + do { + const res = await apiFetch<{ + data: MeditationUser[]; + last_page?: number; + meta?: { last_page?: number }; + }>("/admin/survey-users", { + baseUrl: MEDITATION_BASE_URL, + query: { + page: currentPage, + per_page: 100, + ...(answered ? { answered } : {}), + }, + }); + users.push(...(res.data ?? [])); + lastPage = Number(res.last_page ?? res.meta?.last_page ?? 1); + currentPage++; + } while (currentPage <= lastPage); + + if (users.length === 0) { + toast.error("کاربری برای خروجی گرفتن وجود ندارد."); + return; + } + + const details = await mapWithConcurrency( + users, + 6, + (user) => + apiFetch<{ survey_answers?: SurveyAnswerItem[] }>( + `/admin/survey-users/${user.identifier || user.id}`, + { baseUrl: MEDITATION_BASE_URL }, + ) + .then((r) => r.survey_answers ?? []) + .catch(() => [] as SurveyAnswerItem[]), + (done, total) => setExportProgress(Math.round((done / total) * 100)), + ); + + // Union of questions, in first-seen order, so every row lines up. + const questionOrder: number[] = []; + const questionTitles = new Map(); + for (const answers of details) { + for (const item of answers) { + if (!questionTitles.has(item.question_id)) { + questionTitles.set(item.question_id, item.question || `سوال ${item.question_id}`); + questionOrder.push(item.question_id); + } + } + } + + const headers = [ + "شناسه", + "نام", + "سن", + "جنسیت", + "ایمیل", + "موبایل", + "تاریخ ثبت‌نام", + "تعداد پاسخ‌ها", + "سوالات پاسخ داده شده", + ...questionOrder.map((id) => questionTitles.get(id)!), + ]; + + const rows = users.map((user, i) => { + const byQuestion = new Map( + details[i].map((item) => [ + item.question_id, + item.answers.map((a) => a.label).join("، "), + ]), + ); + return [ + user.id, + user.full_name || [user.first_name, user.last_name].filter(Boolean).join(" ") || "—", + user.age ?? "—", + genderLabel(user.gender), + user.email || "—", + user.mobile || "—", + faDate(user.created_at), + user.answers_count ?? "—", + user.questions_answered ?? "—", + ...questionOrder.map((id) => byQuestion.get(id) || ""), + ]; + }); + + const suffix = answered === "1" ? "-answered" : answered === "0" ? "-unanswered" : ""; + exportToExcel(`survey-users${suffix}`, headers, rows, "کاربران"); + toast.success(`خروجی اکسل ${toFa(users.length)} کاربر آماده شد.`); + } catch { + toast.error("خطا در تهیه خروجی اکسل."); + } finally { + setExporting(false); + setExportProgress(0); + } + } + + // Single-user export: profile fields plus that user's survey answers, taken + // straight from what the open modal already loaded. + function exportDetail() { + if (!detailUser || !detailData) return; + const appro = detailData.appro; + + const headers = ["عنوان", "مقدار"]; + const rows: (string | number)[][] = [ + ["شناسه", detailUser.id], + ["نام", userName || "—"], + ["سن", (appro?.age ?? detailUser.age) ?? "—"], + ["جنسیت", genderLabel(appro?.gender ?? detailUser.gender)], + ["تاریخ تولد", appro?.birthday ? faDate(appro.birthday) : "—"], + ["ایمیل", (appro?.email ?? detailUser.email) || "—"], + ["موبایل", detailUser.mobile || "—"], + ["تاریخ ثبت‌نام", faDate(detailUser.created_at)], + ["تعداد پاسخ‌ها", detailData.answers_count ?? "—"], + ["سوالات پاسخ داده شده", detailData.questions_answered ?? "—"], + ["", ""], + ["سوال", "پاسخ"], + ...detailData.survey_answers.map((item) => [ + item.question || `سوال ${item.question_id}`, + item.answers.map((a) => a.label).join("، ") || "بدون پاسخ", + ]), + ]; + + exportToExcel(`survey-user-${detailUser.id}`, headers, rows, "جزئیات کاربر"); + } + const columns: Column[] = [ { key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" }, { @@ -169,6 +344,21 @@ export default function UsersPage() { + {exporting ? ( + <> + + در حال آماده‌سازی… {toFa(exportProgress)}٪ + + ) : ( + <> + + خروجی اکسل + + )} + + } />
@@ -204,14 +394,69 @@ export default function UsersPage() { /> )} + setGateOpen(false)} + title="تایید خروجی اکسل" + footer={ + <> + + + + } + > +
{ + e.preventDefault(); + submitGate(); + }} + className="flex flex-col gap-3" + > +

+ این عملیات اطلاعات همه کاربران و پاسخ‌های نظرسنجی را از سرور + می‌خواند و ممکن است بار زیادی روی سرور بگذارد. برای ادامه رمز عبور + را وارد کنید. +

+ + { + setGatePassword(e.target.value); + setGateError(null); + }} + placeholder="رمز عبور خروجی" + dir="ltr" + /> + + {gateError &&

{gateError}

} + {/* Lets Enter submit the form even though the button lives in the footer. */} +
+ <> + + + } > {detailLoading && ( diff --git a/components/icons.tsx b/components/icons.tsx index b9e0d11..a2a8616 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -24,6 +24,14 @@ export const PlusIcon = (p: IconProps) => ( ); +export const DownloadIcon = (p: IconProps) => ( + + + + + +); + export const EditIcon = (p: IconProps) => ( diff --git a/lib/excel.ts b/lib/excel.ts new file mode 100644 index 0000000..0fc4629 --- /dev/null +++ b/lib/excel.ts @@ -0,0 +1,304 @@ +// Dependency-free .xlsx writer. An .xlsx is just a ZIP of XML parts, so we +// build the parts by hand and pack them with STORE (no compression) — that +// keeps us from needing a deflate implementation while still producing a file +// Excel opens natively, with no "format and extension don't match" warning. + +type Cell = string | number | null | undefined; + +/* --------------------------------- ZIP ---------------------------------- */ + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +})(); + +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) { + c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +interface ZipEntry { + name: string; + data: Uint8Array; + crc: number; + offset: number; +} + +// Builds a ZIP archive with stored (uncompressed) entries. Fixed DOS timestamp +// so repeated exports of the same data are byte-identical. +function zip(files: { name: string; content: string }[]): Blob { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + const entries: ZipEntry[] = []; + let offset = 0; + + const DOS_TIME = 0; // 00:00:00 + const DOS_DATE = (2020 - 1980) << 9 | (1 << 5) | 1; // 2020-01-01 + + function push(bytes: Uint8Array) { + chunks.push(bytes); + offset += bytes.length; + } + + function header(size: number) { + const buf = new ArrayBuffer(size); + return { view: new DataView(buf), bytes: new Uint8Array(buf) }; + } + + for (const file of files) { + const nameBytes = encoder.encode(file.name); + const data = encoder.encode(file.content); + const crc = crc32(data); + const local = header(30 + nameBytes.length); + + local.view.setUint32(0, 0x04034b50, true); // local file header signature + local.view.setUint16(4, 20, true); // version needed + local.view.setUint16(6, 0x0800, true); // flags: UTF-8 names + local.view.setUint16(8, 0, true); // method: stored + local.view.setUint16(10, DOS_TIME, true); + local.view.setUint16(12, DOS_DATE, true); + local.view.setUint32(14, crc, true); + local.view.setUint32(18, data.length, true); // compressed size + local.view.setUint32(22, data.length, true); // uncompressed size + local.view.setUint16(26, nameBytes.length, true); + local.view.setUint16(28, 0, true); // extra field length + local.bytes.set(nameBytes, 30); + + entries.push({ name: file.name, data, crc, offset }); + push(local.bytes); + push(data); + } + + const centralStart = offset; + for (const entry of entries) { + const nameBytes = encoder.encode(entry.name); + const central = header(46 + nameBytes.length); + + central.view.setUint32(0, 0x02014b50, true); // central directory signature + central.view.setUint16(4, 20, true); // version made by + central.view.setUint16(6, 20, true); // version needed + central.view.setUint16(8, 0x0800, true); // flags: UTF-8 names + central.view.setUint16(10, 0, true); // method: stored + central.view.setUint16(12, DOS_TIME, true); + central.view.setUint16(14, DOS_DATE, true); + central.view.setUint32(16, entry.crc, true); + central.view.setUint32(20, entry.data.length, true); + central.view.setUint32(24, entry.data.length, true); + central.view.setUint16(28, nameBytes.length, true); + central.view.setUint16(30, 0, true); // extra + central.view.setUint16(32, 0, true); // comment + central.view.setUint16(34, 0, true); // disk number + central.view.setUint16(36, 0, true); // internal attrs + central.view.setUint32(38, 0, true); // external attrs + central.view.setUint32(42, entry.offset, true); + central.bytes.set(nameBytes, 46); + + push(central.bytes); + } + + const end = header(22); + end.view.setUint32(0, 0x06054b50, true); // end of central directory + end.view.setUint16(8, entries.length, true); + end.view.setUint16(10, entries.length, true); + end.view.setUint32(12, offset - centralStart, true); + end.view.setUint32(16, centralStart, true); + push(end.bytes); + + return new Blob(chunks as BlobPart[], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); +} + +/* --------------------------------- XML ---------------------------------- */ + +// Built via RegExp so the source file stays free of literal control bytes. +const CONTROL_CHARS = new RegExp( + "[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", + "g", +); + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + // Control characters are illegal in XML 1.0 and make Excel reject the file. + .replace(CONTROL_CHARS, ""); +} + +// 0 -> A, 25 -> Z, 26 -> AA ... +function columnName(index: number): string { + let name = ""; + let n = index; + while (n >= 0) { + name = String.fromCharCode(65 + (n % 26)) + name; + n = Math.floor(n / 26) - 1; + } + return name; +} + +function cellXml(ref: string, value: Cell, style: number): string { + if (value === null || value === undefined || value === "") { + return ``; + } + if (typeof value === "number" && Number.isFinite(value)) { + return `${value}`; + } + // Inline strings keep us from having to build a sharedStrings part. + return `${escapeXml( + String(value), + )}`; +} + +function sheetXml(headers: string[], rows: Cell[][]): string { + const width = Math.max(headers.length, ...rows.map((r) => r.length), 1); + const cols = + `` + + Array.from( + { length: width }, + (_, i) => ``, + ).join("") + + ``; + + const lines: string[] = []; + lines.push( + `${headers + .map((h, i) => cellXml(`${columnName(i)}1`, h, 1)) + .join("")}`, + ); + rows.forEach((row, r) => { + const n = r + 2; + lines.push( + `${row + .map((cell, i) => cellXml(`${columnName(i)}${n}`, cell, 0)) + .join("")}`, + ); + }); + + return ( + `` + + `` + + // rightToLeft so the sheet opens in RTL orientation, and a frozen header row. + `` + + `` + + `` + + cols + + `${lines.join("")}` + + `` + + `` + ); +} + +// Two cell formats: 0 = body, 1 = bold header on a grey fill. +const STYLES_XML = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + +/* -------------------------------- Public -------------------------------- */ + +export function exportToExcel( + filename: string, + headers: string[], + rows: Cell[][], + sheetName = "Sheet1", +) { + const safeSheet = escapeXml(sheetName.replace(/[[\]:*?/\\]/g, "").slice(0, 31)) || "Sheet1"; + + const blob = zip([ + { + name: "[Content_Types].xml", + content: + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, + }, + { + name: "_rels/.rels", + content: + `` + + `` + + `` + + ``, + }, + { + name: "xl/workbook.xml", + content: + `` + + `` + + ``, + }, + { + name: "xl/_rels/workbook.xml.rels", + content: + `` + + `` + + `` + + `` + + ``, + }, + { name: "xl/styles.xml", content: STYLES_XML }, + { name: "xl/worksheets/sheet1.xml", content: sheetXml(headers, rows) }, + ]); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +// Runs `worker` over `items` with a bounded number of in-flight promises so a +// few thousand rows don't open a few thousand sockets at once. +export async function mapWithConcurrency( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise, + onProgress?: (done: number, total: number) => void, +): Promise { + const results = new Array(items.length); + let cursor = 0; + let done = 0; + + async function run() { + while (cursor < items.length) { + const index = cursor++; + results[index] = await worker(items[index], index); + done++; + onProgress?.(done, items.length); + } + } + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, run), + ); + return results; +}