swimminginthecode DIVE!

Dazz6/ABOUT

모각 프로젝트 - #2-6. 관리자 페이지

dazz6 2025. 8. 6. 18:00

관리자 페이지에서는 모각존, 모각챌, 뱃지, 고민 상담 게시판을 관리할 수 있다.

 

 

관리자 페이지는 크게 복잡한 것 없이 반환된 리스트 출력, 그리고 일부 추가 기능만 구현했다.

챌린지의 경우 '공식 챌린지' 기능을 위해 관리자가 챌린지를 생성할 수 있다.

그리고 뱃지 관리에서는 뱃지를 추가할 수 있는데, 여기서 뱃지의 조건을 설정하면 유저가 조건을 충족할 경우 자동으로 부여된다.

예를 들어 성공 횟수 3으로 생성할 경우, 유저가 챌린지를 세 번 정상적으로 완수할 경우 해당 유저에게 해당 뱃지가 부여되는 식이다.

 

관리자 페이지는 이벤트가 많고 유저 페이지처럼 성능이 크게 중요하지 않을 것이라고 판단되어 전부 client component 로 작성했다.

그리고 모든 페이지에 표가 필요하므로, 공통 component 로 분리하여 import 하여 사용했다.

 

import { AdminTableProps } from "@/types/admin.type";
import Link from "next/link";

export default function AdminTable<T extends object>({
  columns,
  data,
}: AdminTableProps<T>) {
  return (
    <table className="w-full border text-sm">
      <thead>
        <tr className="bg-borders text-text dark:bg-border-dark dark:text-text-dark">
          {columns.map((col) => (
            <th key={String(col.key)} className="px-4 py-2 border">
              {col.label}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row, i) => (
          <tr key={i} className="border-t">
            {columns.map((col) => {
              const isCustomKey =
                typeof col.key === "string" && !(col.key in row);
              const cellValue = isCustomKey
                ? undefined
                : row[col.key as keyof T];

              return (
                <td key={String(col.key)} className="px-4 py-2 text-center">
                  {col.render ? (
                    col.render(cellValue, row)
                  ) : col.linkTo && !isCustomKey ? (
                    <Link
                      href={col.linkTo(row)}
                      className="text-primary dark:text-primary-dark hover:underline"
                    >
                      {String(cellValue)}
                    </Link>
                  ) : (
                    String(cellValue ?? "")
                  )}
                </td>
              );
            })}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

 

그리고 사용할 때는 아래처럼 사용하였다. (모각존 관리 페이지)

 

"use client";

import { useEffect, useState } from "react";
import AdminTable from "@/app/components/admin/admin-table";
import ConfirmModal from "@/app/components/confirm-modal";
import { ZoneSearch } from "@/lib/shared/zone.api";
import { ZoneDelete } from "@/lib/client/zone.client.api";
import Loading from "@/app/loading";
import { ZoneMainProps } from "@/types/zone.type";
import { Column } from "@/types/admin.type";
import Pagination from "@/app/components/shared/paginaiton";

export default function AdminZonePage() {
  const [zones, setZones] = useState<ZoneMainProps[]>([]);
  const [loading, setLoading] = useState(true);

  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(1);

  const [showModal, setShowModal] = useState(false);
  const [targetId, setTargetId] = useState<number | null>(null);
  const [targetName, setTargetName] = useState<string | null>(null);

  const fetchZones = async (pageNumber = 0) => {
    setLoading(true);
    try {
      const res = await ZoneSearch({
        sort: "recent",
        page: pageNumber,
        size: 10,
      });

      setZones(res.data);
      setPage(pageNumber);
      setTotalPages(res.totalPages);
    } catch (e) {
      console.error("에러:", e);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchZones();
  }, []);

  const openModal = (id: number, name: string) => {
    setTargetId(id);
    setTargetName(name);
    setShowModal(true);
  };

  const confirmDelete = async () => {
    if (targetId !== null) {
      await ZoneDelete(targetId);
      await fetchZones(page);
    }
    setShowModal(false);
    setTargetId(null);
    setTargetName(null);
  };

  const columns: Column<ZoneMainProps>[] = [
    { key: "mogakZoneId", label: "ID" },
    {
      key: "title",
      label: "이름",
      linkTo: (row: ZoneMainProps) => `/zone/${row.mogakZoneId}`,
    },
    { key: "tag", label: "태그" },
    {
      key: "hasPwd",
      label: "공개 여부",
      render: (value) => (value ? "🔒" : ""),
    },
    {
      key: "actions",
      label: "관리",
      render: (_, row: ZoneMainProps) => (
        <button
          className="text-sm px-2 py-1 bg-error dark:bg-error-dark text-white rounded"
          onClick={() => openModal(row.mogakZoneId, row.title)}
        >
          삭제
        </button>
      ),
    },
  ];

  if (loading) {
    return <Loading />;
  }

  return (
    <div className="flex flex-col gap-4">
      <h1 className="text-lg font-bold text-primary dark:text-primary-dark">
        모각존 관리
      </h1>
      <AdminTable columns={columns} data={zones} />

      {totalPages > 1 && (
        <Pagination
          page={page}
          totalPages={totalPages}
          onPageChange={fetchZones}
        />
      )}

      {showModal && (
        <ConfirmModal
          message={`정말 '${targetName}' 존을 삭제하시겠습니까?`}
          onConfirm={confirmDelete}
          onCancel={() => setShowModal(false)}
        />
      )}
    </div>
  );
}