chore: merge admin repo history into monorepo at admin/
git-subtree-dir: admin git-subtree-mainline:35f5084005git-subtree-split:9f1eccd2f7
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { Switch } from "@/components/ui/Controls";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import {
|
||||
ProfileQuestion,
|
||||
listQuestions,
|
||||
createQuestion,
|
||||
updateQuestion,
|
||||
deleteQuestion,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function QuestionsPage() {
|
||||
const toast = useToast();
|
||||
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formModal, setFormModal] = useState<{ mode: "create" | "edit"; q?: ProfileQuestion } | null>(
|
||||
null
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProfileQuestion | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setQuestions(await listQuestions());
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (error && !questions) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!questions) return <FullScreenLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
سوالات پروفایل 💬
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
کاربر توی مرحلهی سوم آنبوردینگ دقیقاً ۳ تا از این سوالات رو انتخاب و
|
||||
جواب میده.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" icon={<Plus size={16} />} onClick={() => setFormModal({ mode: "create" })}>
|
||||
افزودن سوال
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
|
||||
هنوز سوالی اضافه نشده.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||
<th className="text-right font-semibold px-4 py-3">متن سوال</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...questions]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((q) => (
|
||||
<tr key={q.id} className="border-b border-(--color-border-subtle) last:border-0">
|
||||
<td className="px-4 py-3 font-medium text-(--color-content-primary)">
|
||||
{q.text}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-(--color-content-tertiary)">{q.sort_order}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Switch
|
||||
defaultChecked={q.is_active}
|
||||
onChange={async (next) => {
|
||||
try {
|
||||
await updateQuestion(q.id, { is_active: next });
|
||||
setQuestions((prev) =>
|
||||
prev!.map((x) => (x.id === q.id ? { ...x, is_active: next } : x))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFormModal({ mode: "edit", q })}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(q)}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<QuestionFormModal
|
||||
open={!!formModal}
|
||||
mode={formModal?.mode ?? "create"}
|
||||
question={formModal?.q}
|
||||
onClose={() => setFormModal(null)}
|
||||
onSaved={(q, mode) => {
|
||||
if (mode === "create") {
|
||||
setQuestions((prev) => [...(prev ?? []), q]);
|
||||
toast.showSuccess("سوال اضافه شد");
|
||||
} else {
|
||||
setQuestions((prev) => prev!.map((x) => (x.id === q.id ? q : x)));
|
||||
toast.showSuccess("سوال ویرایش شد");
|
||||
}
|
||||
setFormModal(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف سوال؟"
|
||||
description="این سوال از لیست سوالات قابلانتخاب حذف میشه. پاسخهایی که کاربرها قبلاً بهش دادن دستنخورده میمونه."
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteQuestion(deleteTarget.id);
|
||||
setQuestions((prev) => prev!.filter((x) => x.id !== deleteTarget.id));
|
||||
toast.showSuccess("سوال حذف شد");
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionFormModal({
|
||||
open,
|
||||
mode,
|
||||
question,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
question?: ProfileQuestion;
|
||||
onClose: () => void;
|
||||
onSaved: (q: ProfileQuestion, mode: "create" | "edit") => void;
|
||||
}) {
|
||||
const [text, setText] = useState(question?.text ?? "");
|
||||
const [sortOrder, setSortOrder] = useState(String(question?.sort_order ?? 0));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setText(question?.text ?? "");
|
||||
setSortOrder(String(question?.sort_order ?? 0));
|
||||
setError(null);
|
||||
}, [question, open]);
|
||||
|
||||
async function handleSave() {
|
||||
if (text.trim().length < 3) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const created = await createQuestion({ text: text.trim(), sort_order: parseInt(sortOrder, 10) || 0 });
|
||||
onSaved(created, "create");
|
||||
} else if (question) {
|
||||
const updated = await updateQuestion(question.id, {
|
||||
text: text.trim(),
|
||||
sort_order: parseInt(sortOrder, 10) || 0,
|
||||
});
|
||||
onSaved(updated, "edit");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن سوال" : "ویرایش سوال"}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
متن سوال
|
||||
</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="ترتیب نمایش"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button size="sm" isLoading={loading} disabled={text.trim().length < 3} onClick={handleSave}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user