"use client"; import { useState } from "react"; import { apiFetch, getApproToken } from "@/lib/api"; import { APPRO_BASE_URL, MEDITATION_BASE_URL } from "@/lib/config"; import { usePaginated } from "@/lib/useResource"; import { DataTable, type Column } from "@/components/DataTable"; import { Badge, Button, Card, Modal, PageHeader, Field, Input, Pagination, Select, Spinner, } from "@/components/ui"; import { DownloadIcon, EyeIcon } from "@/components/icons"; import { useToast } from "@/components/toast"; import { exportToExcel, mapWithConcurrency } from "@/lib/excel"; import { toFa } from "@/lib/utils"; interface MeditationUser { id: number; full_name?: string; first_name?: string; last_name?: string; age?: number | null; gender?: number | null; email?: string | null; mobile?: string | null; identifier?: string | null; created_at?: string; answers_count?: number; questions_answered?: number; } interface ApproUser { id: number; uuid?: string; first_name?: string; last_name?: string; full_name?: string; age?: number | null; gender?: number | null; email?: string | null; mobile?: string | null; birthday?: string | null; created_at?: string; } interface SurveyAnswerItem { question_id: number; question?: string; type?: string; answers: { option_id: number; label: string }[]; } interface DetailData { appro: ApproUser | null; survey_answers: SurveyAnswerItem[]; answers_count?: number; 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: "زن", }; function faDate(value?: string): string { if (!value) return "—"; try { return toFa(new Date(value).toLocaleDateString("fa-IR")); } catch { return "—"; } } export default function UsersPage() { const [answered, setAnswered] = useState(""); const { data, meta, page, setPage, loading, error, reload } = usePaginated("/admin/survey-users", { perPage: 20, 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); const [detailLoading, setDetailLoading] = useState(false); async function openDetail(user: MeditationUser) { setDetailOpen(true); setDetailLoading(true); setDetailUser(user); setDetailData(null); const [appro, survey] = await Promise.all([ user.mobile ? apiFetch<{ data: ApproUser[] }>("/admin/users", { query: { mobile: user.mobile, per_page: 1 }, baseUrl: APPRO_BASE_URL, headers: { Authorization: `Bearer ${getApproToken()}` }, }).then((r) => r.data?.[0] ?? null) : Promise.resolve(null), apiFetch<{ survey_answers: SurveyAnswerItem[]; answers_count?: number; questions_answered?: number }>( `/admin/survey-users/${user.identifier || user.id}`, { baseUrl: MEDITATION_BASE_URL }, ).catch(() => ({ survey_answers: [], answers_count: undefined, questions_answered: undefined })), ]); // Sync approagency data back to meditation DB if user has null fields if (appro && user.mobile && (!user.first_name || user.age == null || user.gender == null)) { apiFetch(`/admin/survey-users/${user.identifier || user.id}`, { method: "PATCH", baseUrl: MEDITATION_BASE_URL, body: { first_name: appro.first_name, last_name: appro.last_name, age: appro.age, gender: appro.gender, birthday: appro.birthday, identifier: appro.uuid, email: appro.email, }, }).catch(() => {}); // fire-and-forget } setDetailData({ appro, survey_answers: survey.survey_answers ?? [], answers_count: survey.answers_count, questions_answered: survey.questions_answered, }); 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" }, { key: "full_name", header: "نام", render: (r) => r.full_name || [r.first_name, r.last_name].filter(Boolean).join(" ") || "—", }, { key: "questions_answered", header: "پاسخ به سوالات", className: "w-32", render: (r) => r.questions_answered != null ? toFa(r.questions_answered) : "—", }, { key: "created_at", header: "تاریخ ثبت‌نام", className: "w-32", render: (r) => faDate(r.created_at), }, ]; const a = detailData?.appro; const s = detailData; const userName = a ? a.full_name || [a.first_name, a.last_name].filter(Boolean).join(" ") || "" : detailUser ? detailUser.full_name || [detailUser.first_name, detailUser.last_name].filter(Boolean).join(" ") || "" : ""; return (
{exporting ? ( <> در حال آماده‌سازی… {toFa(exportProgress)}٪ ) : ( <> خروجی اکسل )} } />
( )} /> {meta && ( )} 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 && (
)} {s && !detailLoading && (

نام

{userName || "—"}

سن

{(a?.age ?? detailUser?.age) != null ? toFa(a?.age ?? detailUser!.age!) : "—"}

جنسیت

{(a?.gender ?? detailUser?.gender) != null ? GENDER_LABELS[a?.gender ?? detailUser!.gender!] ?? "—" : "—"}

تاریخ تولد

{a?.birthday ? faDate(a.birthday) : "—"}

ایمیل

{(a?.email ?? detailUser?.email) || "—"}

موبایل

{detailUser?.mobile || "—"}

تعداد پاسخ‌ها

{s.answers_count != null ? toFa(s.answers_count) : "—"}

سوالات پاسخ داده شده

{s.questions_answered != null ? toFa(s.questions_answered) : "—"}

{s.survey_answers.length > 0 ? (

پاسخ‌های نظرسنجی

{s.survey_answers.map((item) => (

{item.question}

{item.answers.map((a) => ( {a.label} ))} {item.answers.length === 0 && ( بدون پاسخ )}
))}
) : (

این کاربر هنوز به هیچ سوالی پاسخ نداده است.

)}
)}
); }