feaT: add excel

This commit is contained in:
2026-08-22 16:39:16 +03:30
parent fee184c0e6
commit 1898dbb322
3 changed files with 561 additions and 4 deletions
+249 -4
View File
@@ -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<number, string> = {
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<string | null>(null);
const [detailUser, setDetailUser] = useState<MeditationUser | null>(null);
const [detailData, setDetailData] = useState<DetailData | null>(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<number, string>();
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<MeditationUser>[] = [
{ key: "id", header: "شناسه", render: (r) => toFa(r.id), className: "w-20" },
{
@@ -169,6 +344,21 @@ export default function UsersPage() {
<PageHeader
title="کاربران نظرسنجی"
subtitle="فهرست کاربران و پاسخ‌های نظرسنجی آن‌ها"
action={
<Button onClick={openGate} disabled={exporting}>
{exporting ? (
<>
<Spinner className="h-4 w-4" />
<span>در حال آماده‌سازی… {toFa(exportProgress)}٪</span>
</>
) : (
<>
<DownloadIcon className="h-4 w-4" />
<span>خروجی اکسل</span>
</>
)}
</Button>
}
/>
<div className="mb-4 flex items-center gap-3">
@@ -204,14 +394,69 @@ export default function UsersPage() {
/>
)}
<Modal
open={gateOpen}
onClose={() => setGateOpen(false)}
title="تایید خروجی اکسل"
footer={
<>
<Button onClick={submitGate} disabled={!gatePassword}>
تایید و شروع خروجی
</Button>
<Button variant="secondary" onClick={() => setGateOpen(false)}>
انصراف
</Button>
</>
}
>
<form
onSubmit={(e) => {
e.preventDefault();
submitGate();
}}
className="flex flex-col gap-3"
>
<p className="text-sm text-muted">
این عملیات اطلاعات همه کاربران و پاسخ‌های نظرسنجی را از سرور
می‌خواند و ممکن است بار زیادی روی سرور بگذارد. برای ادامه رمز عبور
را وارد کنید.
</p>
<Field label="رمز عبور" required>
<Input
type="password"
autoFocus
value={gatePassword}
onChange={(e) => {
setGatePassword(e.target.value);
setGateError(null);
}}
placeholder="رمز عبور خروجی"
dir="ltr"
/>
</Field>
{gateError && <p className="text-sm text-danger">{gateError}</p>}
{/* Lets Enter submit the form even though the button lives in the footer. */}
<button type="submit" className="hidden" aria-hidden />
</form>
</Modal>
<Modal
open={detailOpen}
onClose={() => setDetailOpen(false)}
title={userName ? `جزئیات کاربر — ${userName}` : "جزئیات کاربر"}
footer={
<Button variant="secondary" onClick={() => setDetailOpen(false)}>
بستن
</Button>
<>
<Button
onClick={exportDetail}
disabled={detailLoading || !detailData}
>
<DownloadIcon className="h-4 w-4" />
<span>خروجی اکسل</span>
</Button>
<Button variant="secondary" onClick={() => setDetailOpen(false)}>
بستن
</Button>
</>
}
>
{detailLoading && (