feaT: add excel
This commit is contained in:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user