# @sunkim4638/admin-modules

관리자 페이지 공통 UI 모듈 — 레이아웃, 테이블, 필터, 폼, API 훅, DB 유틸

---

## 배포

```bash
# 버전 올리기 (git 태그 없이)
npm version patch --no-git-tag-version

# 배포
npm publish --access public
```

---

## 새 프로젝트 설정 가이드

### 1. 패키지 설치

```bash
npm install @sunkim4638/admin-modules
npm install @tanstack/react-query axios lucide-react sonner
```

peer dependency 중 사용할 것만 설치:

```bash
# 테이블 쓸 경우
npm install @tanstack/react-table

# 유효성 검사 쓸 경우
npm install zod
```

---

### 2. globals.css 설정

`app/globals.css` 상단에 `@source` 추가 (Tailwind이 패키지 클래스를 스캔하도록):

> `app/globals.css` 기준 경로 — `app/` 한 단계 위가 프로젝트 루트

```css
@import "tailwindcss";
@source "../node_modules/@sunkim4638/admin-modules/dist";
```

그 아래에 CSS 변수 및 `@theme inline` 전체를 붙여넣는다.  
이 프로젝트의 `app/globals.css` 내용을 그대로 복사해서 사용:

```css
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --destructive-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --destructive-foreground: oklch(0.985 0 0);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.922 0 0);
  --sidebar-primary-foreground: oklch(0.205 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);
}

@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}

/*
 * 날짜 입력(<input type="date">) 표시 커스터마이징. (WebKit/Chromium 기준)
 * 네이티브 표시 형식은 브라우저 로케일이 강제(예: "2026. 06. 23.")하고 placeholder 도 없으므로,
 * 네이티브 텍스트를 숨기고 data-display(값 "YYYY-MM-DD" 또는 안내문구)를 직접 그린다.
 * → 형식을 하이픈(-)으로 고정하고, 빈 값일 땐 회색 placeholder 를 보여준다.
 * (AdminDateInput, DateRangeFilter 에서 사용 — 이 블록 없으면 네이티브 "연도.월.일" 형식이 그대로 노출됨)
 */
.admin-date-input {
  position: relative;
}
.admin-date-input {
  caret-color: transparent;
}
.admin-date-input::-webkit-datetime-edit,
.admin-date-input::-webkit-datetime-edit-fields-wrapper,
.admin-date-input::-webkit-datetime-edit-text,
.admin-date-input::-webkit-datetime-edit-year-field,
.admin-date-input::-webkit-datetime-edit-month-field,
.admin-date-input::-webkit-datetime-edit-day-field {
  opacity: 0;
  color: transparent;
}
.admin-date-input::before {
  content: attr(data-display);
  position: absolute;
  left: 0.75rem;
  right: 2.25rem;
  top: 50%;
  transform: translateY(-50%);
  color: var(--foreground);
  pointer-events: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.admin-date-input[data-empty="true"]::before {
  color: var(--muted-foreground);
}
.admin-date-input::-webkit-calendar-picker-indicator {
  position: absolute;
  right: 0.6rem;
  top: 50%;
  transform: translateY(-50%);
  margin: 0;
  cursor: pointer;
}
```

---

### 3. Providers 설정

`app/providers.tsx` 생성:

```tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import axios from "axios";
import { SessionProvider } from "next-auth/react";
import { configure, handleApiErr } from "@sunkim4638/admin-modules/api-hooks";
import { Toaster } from "sonner";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() =>
    new QueryClient({
      defaultOptions: {
        queries: {
          retry: 1,
          refetchOnWindowFocus: false,
          staleTime: 1000 * 30,
        },
        mutations: {
          onError: (error: unknown) => {
            handleApiErr(error);
          },
        },
      },
    })
  );

  useState(() => {
    const ax = axios.create({
      baseURL: "/api",
      timeout: 10000,
      headers: { "Content-Type": "application/json" },
    });

    // 401 처리
    ax.interceptors.response.use(
      (res) => res,
      (error) => {
        if (error.response?.status === 401) {
          window.location.href = "/login";
        }
        return Promise.reject(error);
      }
    );

    configure({ axios: ax });
  });

  return (
    <SessionProvider>
      <QueryClientProvider client={queryClient}>
        {children}
        <Toaster richColors position="top-right" />
      </QueryClientProvider>
    </SessionProvider>
  );
}
```

`app/layout.tsx` 에서 감싸기:

```tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

---

### 4. DB 설정 (서버 사이드 API 에서 DB 쓸 경우)

프로젝트 루트에 `instrumentation.ts` 생성:

```ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { configureDb } = await import("@sunkim4638/admin-modules/db");
    const { default: pool } = await import("./shared/config/db"); // 본인 mysql2 pool 경로
    configureDb(pool);
  }
}
```

이후 API route 어디서든 pool import 없이 사용 가능:

```ts
import { queryAll, queryOne, insert, update, remove } from "@sunkim4638/admin-modules/db";

// 전체 조회
const users = await queryAll("SELECT * FROM users WHERE status = ?", ["active"]);

// 단건 조회
const user = await queryOne("SELECT * FROM users WHERE id = ?", [id]);

// pool 직접 쓸 경우
import { getPool } from "@sunkim4638/admin-modules/db";
const pool = getPool();
const [rows] = await pool.query("SELECT ...");
```

---

### 5. HTTP 에러 핸들러 설정 (API route 서버 사이드)

```ts
import { errorHandler, throwHttpError } from "@sunkim4638/admin-modules/http";

// API route 에서 에러 처리
export const GET = apiWrapper(async (req) => {
  const user = await queryOne("SELECT * FROM users WHERE id = ?", [id]);
  if (!user) throwHttpError("NOT_FOUND");
  return Response.json(user);
});
```

---

### 6. 레이아웃 사용

```tsx
import { AdminContainer } from "@sunkim4638/admin-modules";

// 반응형 (데스크탑=사이드바, 모바일=드로어 자동 전환)
<AdminContainer nav={nav} pathname={pathname} LinkComponent={Link} user={user} onLogout={signOut}>
  {children}
</AdminContainer>

// 모바일 너비 제한 (루트 layout.tsx — 로그인 페이지 포함)
<AdminContainer maxWidth={430}>
  {children}
</AdminContainer>
```

---

## 서브패스 목록

| import 경로 | 내용 |
|---|---|
| `@sunkim4638/admin-modules` | UI 컴포넌트 (AdminContainer, AdminTable, AdminFilter 등) + usePageSearchState |
| `@sunkim4638/admin-modules/api-hooks` | useApiQuery, useApiMutation, configure, handleApiErr |
| `@sunkim4638/admin-modules/http` | errorHandler, throwHttpError, apiWrapper, authWrapper |
| `@sunkim4638/admin-modules/db` | queryOne, queryAll, insert, update, remove, configureDb, getPool |
| `@sunkim4638/admin-modules/validators` | baseFilterSchema, extractSearchParams, buildFilterSearchParams, 공통 Zod 필드 |
