From 96c74601c861d0f5b9f4261d550a3c00c92405c7 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Thu, 20 Aug 2026 18:48:22 +0330 Subject: [PATCH] feat: add users question --- app/dashboard/users/page.tsx | 317 +++++++++++++++++++++++++++++++++++ components/icons.tsx | 7 + lib/api.ts | 29 +++- lib/auth.tsx | 14 +- lib/config.ts | 3 +- lib/nav.ts | 6 + 6 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 app/dashboard/users/page.tsx diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx new file mode 100644 index 0000000..8e0cf64 --- /dev/null +++ b/app/dashboard/users/page.tsx @@ -0,0 +1,317 @@ +"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, + Pagination, + Select, + Spinner, +} from "@/components/ui"; +import { EyeIcon } from "@/components/icons"; +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; +} + +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 [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: [] } as { survey_answers: SurveyAnswerItem[] })), + ]); + + // 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); + } + + 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 ( +
+ + +
+ + +
+ + ( + + )} + /> + + {meta && ( + + )} + + setDetailOpen(false)} + title={userName ? `جزئیات کاربر — ${userName}` : "جزئیات کاربر"} + 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 && ( + بدون پاسخ + )} +
+
+ ))} +
+
+ ) : ( +

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

+ )} +
+ )} +
+
+ ); +} diff --git a/components/icons.tsx b/components/icons.tsx index d3c59f4..d4bdb57 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -196,3 +196,10 @@ export const TagIcon = (p: IconProps) => ( ); + +export const UserIcon = (p: IconProps) => ( + + + + +); diff --git a/lib/api.ts b/lib/api.ts index 1bcda97..b93281b 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,7 +1,11 @@ // Thin fetch wrapper for the meditation API. The app is a static SPA, so every // call runs in the browser and carries the bearer token from localStorage. -import { MEDITATION_BASE_URL, TOKEN_STORAGE_KEY } from "./config"; +import { + APPRO_TOKEN_STORAGE_KEY, + MEDITATION_BASE_URL, + TOKEN_STORAGE_KEY, +} from "./config"; export class ApiError extends Error { status: number; @@ -29,6 +33,21 @@ export function clearToken() { window.localStorage.removeItem(TOKEN_STORAGE_KEY); } +export function getApproToken(): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(APPRO_TOKEN_STORAGE_KEY); +} + +export function setApproToken(token: string) { + if (typeof window === "undefined") return; + window.localStorage.setItem(APPRO_TOKEN_STORAGE_KEY, token); +} + +export function clearApproToken() { + if (typeof window === "undefined") return; + window.localStorage.removeItem(APPRO_TOKEN_STORAGE_KEY); +} + type Query = Record; interface RequestOptions { @@ -40,6 +59,8 @@ interface RequestOptions { baseUrl?: string; // Skip attaching the bearer token (used by the login calls). auth?: boolean; + // Extra headers to merge into the request. + headers?: Record; } function buildUrl(path: string, query?: Query, baseUrl = MEDITATION_BASE_URL) { @@ -60,7 +81,7 @@ export async function apiFetch( path: string, options: RequestOptions = {}, ): Promise { - const { method = "GET", body, query, baseUrl, auth = true } = options; + const { method = "GET", body, query, baseUrl, auth = true, headers: extraHeaders } = options; const headers: Record = { Accept: "application/json" }; @@ -69,6 +90,10 @@ export async function apiFetch( if (token) headers.Authorization = `Bearer ${token}`; } + if (extraHeaders) { + Object.assign(headers, extraHeaders); + } + let payload: BodyInit | undefined; if (body instanceof FormData) { payload = body; // browser sets multipart Content-Type + boundary diff --git a/lib/auth.tsx b/lib/auth.tsx index 8f98b02..077d5b3 100644 --- a/lib/auth.tsx +++ b/lib/auth.tsx @@ -13,7 +13,6 @@ import { createContext, useCallback, useContext, - useEffect, useState, } from "react"; import { APPRO_BASE_URL, MEDITATION_BASE_URL, PACKAGE_NAME } from "./config"; @@ -21,8 +20,10 @@ import { ApiError, apiFetch, clearToken, + clearApproToken, getToken, setToken, + setApproToken, toFormData, } from "./api"; @@ -73,16 +74,12 @@ async function meditationLogin(approoToken: string): Promise { } export function AuthProvider({ children }: { children: React.ReactNode }) { - const [token, setTokenState] = useState(null); - const [ready, setReady] = useState(false); - - useEffect(() => { - setTokenState(getToken()); - setReady(true); - }, []); + const [token, setTokenState] = useState(() => getToken()); + const [ready] = useState(true); const login = useCallback(async (identifier: string, password: string) => { const approoToken = await approAgencyLogin(identifier, password); + setApproToken(approoToken); const medToken = await meditationLogin(approoToken); setToken(medToken); setTokenState(medToken); @@ -90,6 +87,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(() => { clearToken(); + clearApproToken(); setTokenState(null); }, []); diff --git a/lib/config.ts b/lib/config.ts index 4a91773..aed6d1e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -19,5 +19,6 @@ export const ASSET_BASE_URL = process.env.NEXT_PUBLIC_ASSET_BASE_URL ?? MEDITATION_BASE_URL.replace(/\/api\/?$/, ""); -// localStorage key for the meditation bearer token. +// localStorage keys for the meditation bearer token and approagency token. export const TOKEN_STORAGE_KEY = "meditation_admin_token"; +export const APPRO_TOKEN_STORAGE_KEY = "approagency_admin_token"; diff --git a/lib/nav.ts b/lib/nav.ts index b7969b0..4f05a24 100644 --- a/lib/nav.ts +++ b/lib/nav.ts @@ -14,6 +14,7 @@ import { TagIcon, TimerIcon, TrophyIcon, + UserIcon, WorryIcon, } from "@/components/icons"; @@ -113,6 +114,11 @@ export const NAV: NavSection[] = [ icon: SurveyIcon, items: [{ href: "/dashboard/surveys", label: "پرسش‌های نظرسنجی" }], }, + { + label: "کاربران", + icon: UserIcon, + items: [{ href: "/dashboard/users", label: "کاربران نظرسنجی" }], + }, { label: "گفتگو با مشاور", icon: ChatIcon,