Compare commits
70 Commits
0827352e6b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc714022f | ||
|
|
c85450ce37 | ||
|
|
c5bc8f5b49 | ||
|
|
14c80baeec | ||
|
|
5287611bf7 | ||
|
|
cb2d1f34d3 | ||
|
|
97c8e1c9fb | ||
|
|
5485da4029 | ||
|
|
b579b32138 | ||
|
|
4337a8f69a | ||
|
|
a007ac11ce | ||
|
|
34e831f738 | ||
|
|
cfbb3d50ee | ||
|
|
1c2222da67 | ||
|
|
bb71b892ca | ||
|
|
ab81a3da3d | ||
|
|
5f72e6ce7c | ||
|
|
1ec2df27b0 | ||
|
|
1fb859fdf9 | ||
|
|
e91085b4ed | ||
|
|
2d722a89c7 | ||
|
|
c348fb55fa | ||
|
|
c7d5679788 | ||
|
|
a646dfd09f | ||
|
|
4d1e7343c3 | ||
|
|
808fe5fc68 | ||
|
|
7d9c241d17 | ||
|
|
5b8749d11f | ||
|
|
e518c988b2 | ||
|
|
12044587c9 | ||
| ecf2dab35c | |||
| 1016d8717c | |||
| e7dd2cca00 | |||
| 34f9f0cb32 | |||
| 30c66158d5 | |||
| 69c54fad1d | |||
| c20599164e | |||
| 664c72fa41 | |||
| 486a18451b | |||
|
|
9bfbafcec1 | ||
|
|
2c9898be90 | ||
| 34b2739405 | |||
| 4aaa542ded | |||
| 1049d029b4 | |||
|
|
f4e46c39fb | ||
|
|
fadd402e63 | ||
|
|
b10d41532b | ||
|
|
2047e044d5 | ||
|
|
2c86f2d515 | ||
|
|
870da3838a | ||
|
|
11726858cc | ||
|
|
1ced95f218 | ||
|
|
9e02aa3a88 | ||
|
|
9ff08d3e58 | ||
|
|
e0aacab1d1 | ||
|
|
58af463585 | ||
|
|
d54ad82095 | ||
|
|
9c28d50890 | ||
|
|
0bf270d884 | ||
|
|
c6e60cd34d | ||
|
|
c7f7492b9e | ||
|
|
4d310346c1 | ||
|
|
cc373f53fe | ||
|
|
d057ebef4a | ||
|
|
0bf18968ad | ||
|
|
27cf98eef2 | ||
|
|
f84111b9cc | ||
|
|
187cb9b283 | ||
|
|
a52db0a2be | ||
|
|
9f12aac46b |
18
.cursor/.prompt/1101.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
|
||||||
|
x 메인 게시판 일반
|
||||||
|
메인 게시판 프리뷰
|
||||||
|
x 메인 게시판 스페셜랭크
|
||||||
|
|
||||||
|
기본 리스트
|
||||||
|
스페셜_랭크
|
||||||
|
스페셜_출석
|
||||||
|
스페셜_제휴업체
|
||||||
|
스페셜_제휴업체지도
|
||||||
|
|
||||||
|
게시글 뷰 + 댓글
|
||||||
|
|
||||||
|
로그인관련
|
||||||
|
회원가입 페이지
|
||||||
|
회원쪽지
|
||||||
|
링크로들어오면 보이고 거기서 페이지 이동하면 안보이게
|
||||||
17
.cursor/.prompt/done/search.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
검색어 입력 폼은
|
||||||
|
|
||||||
|
|
||||||
|
width: 384px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--gray-20, #D5D5D5);
|
||||||
|
background: var(--gray-00, #FFF);
|
||||||
|
|
||||||
|
이 모양이며 좌측에
|
||||||
|
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z" stroke="#707070" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
이 아이콘이 들어감 placeholder는 검색어를 입력해 주세요.
|
||||||
37
.cursor/.prompt/new.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
|
||||||
|
8.글삭제기능 어드민추가
|
||||||
|
|
||||||
|
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
|
||||||
|
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
|
||||||
|
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
|
||||||
|
제휴문의 게시판 ( 관리자 승인후) → 이동
|
||||||
|
제휴업소 리스트 게시판 (노출됨)"
|
||||||
|
|
||||||
|
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
|
||||||
|
11,게시판, 주간인기글 , 일간인기글 추가 요청
|
||||||
|
|
||||||
|
12.https://search.google.com/search-console/welcome 등록요청
|
||||||
|
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
|
||||||
|
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
|
||||||
|
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
|
||||||
|
16. 게시글 상단고정 기능
|
||||||
|
17. 메인화면 큰 카테고리 빼기
|
||||||
|
18. google SEO: header, meta, description 동적설정
|
||||||
|
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
|
||||||
|
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
|
||||||
|
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
|
||||||
|
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
|
||||||
|
2. 게시글에서 제목, 내용, 댓글 디자인 다름
|
||||||
|
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
|
||||||
|
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
|
||||||
|
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
|
||||||
|
|
||||||
|
9. 게시글 이미지 배치사이즈
|
||||||
|
10. 외부접속 가입없어도 가능
|
||||||
|
11. 글뷰에서 게시글 리스트로
|
||||||
|
12. 게시글 리스트 디자인
|
||||||
|
13. 포인트 규칙
|
||||||
|
14. 게시판권한 확인
|
||||||
|
15.메인뷰 3열 사이즈 변경
|
||||||
|
16. 로그인 안됐을때 카드 휑함
|
||||||
|
17.이미지 사이즈 미리불러오기
|
||||||
36
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: deploy-on-main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: [ self-hosted, linux_amd64 ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Node / PM2 / Prisma 확인
|
||||||
|
run: |
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
pm2 -v || true
|
||||||
|
npx prisma --version || true
|
||||||
|
|
||||||
|
- name: 배포
|
||||||
|
env:
|
||||||
|
APP_DIR: /root/msgapp
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd "$APP_DIR"
|
||||||
|
# 최신 main 코드 반영
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
npm run dbforce
|
||||||
|
npm run build
|
||||||
|
pm2 reload ecosystem.config.js --env production || (pm2 start ecosystem.config.js --env production && pm2 save)
|
||||||
|
pm2 list
|
||||||
1
.gitignore
vendored
@@ -46,3 +46,4 @@ next-env.d.ts
|
|||||||
/prisma/prisma/dev.db
|
/prisma/prisma/dev.db
|
||||||
|
|
||||||
*.ignore
|
*.ignore
|
||||||
|
/logs
|
||||||
|
|||||||
13
.runner
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||||
|
"id": 2,
|
||||||
|
"uuid": "7dc76cf1-fcf2-48ba-a97a-73fb5d661049",
|
||||||
|
"name": "msgapp",
|
||||||
|
"token": "f4391f32ea51657c4366698db1b8333b5d5e1b83",
|
||||||
|
"address": "https://www.plubu.com/",
|
||||||
|
"labels": [
|
||||||
|
"linux_amd64:host",
|
||||||
|
"self-hosted:host"
|
||||||
|
],
|
||||||
|
"ephemeral": false
|
||||||
|
}
|
||||||
6
deploytest.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
test
|
||||||
|
new address check
|
||||||
|
new address check2
|
||||||
|
deploy test2dfdf213
|
||||||
|
deploy test001
|
||||||
|
deploy test002
|
||||||
18
ecosystem.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'assm',
|
||||||
|
cwd: '/root/msgapp',
|
||||||
|
script: 'npm',
|
||||||
|
args: 'start',
|
||||||
|
interpreter: 'none',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
3
middleware.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { middleware, config } from "./src/middleware";
|
||||||
|
|
||||||
|
|
||||||
12
package.json
@@ -5,15 +5,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start --port 3100",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"prisma:generate": "prisma generate",
|
"migrate": "prisma migrate dev",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"studio": "prisma studio",
|
||||||
"prisma:studio": "prisma studio",
|
"seed": "node prisma/seed.js",
|
||||||
"prisma:db:push": "prisma db push",
|
"dbforce": "prisma migrate reset --force"
|
||||||
"prisma:seed": "node prisma/seed.js",
|
|
||||||
"prisma:erd": "prisma generate"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.17.0",
|
"@prisma/client": "^6.17.0",
|
||||||
|
|||||||
18
prisma/migrations/20251101152100_/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "partner_shops" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"region" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "partner_shops_active_sortOrder_idx" ON "partner_shops"("active", "sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "partner_shops_name_region_key" ON "partner_shops"("name", "region");
|
||||||
2
prisma/migrations/20251101161632_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT;
|
||||||
23
prisma/migrations/20251101162445_/migration.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_partners" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"latitude" REAL NOT NULL,
|
||||||
|
"longitude" REAL NOT NULL,
|
||||||
|
"address" TEXT,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_partners" ("address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt") SELECT "address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt" FROM "partners";
|
||||||
|
DROP TABLE "partners";
|
||||||
|
ALTER TABLE "new_partners" RENAME TO "partners";
|
||||||
|
CREATE UNIQUE INDEX "partners_name_key" ON "partners"("name");
|
||||||
|
CREATE INDEX "partners_category_idx" ON "partners"("category");
|
||||||
|
CREATE INDEX "partners_sortOrder_idx" ON "partners"("sortOrder");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add points/level/grade columns to users
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- points
|
||||||
|
ALTER TABLE "users" ADD COLUMN "points" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- level
|
||||||
|
ALTER TABLE "users" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- grade (0~10 권장, DB 레벨에서는 정수 제약만, 범위는 앱에서 검증)
|
||||||
|
ALTER TABLE "users" ADD COLUMN "grade" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
|
|
||||||
43
prisma/migrations/20251102030623_/migration.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `requiresApproval` on the `boards` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `type` on the `boards` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_boards" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"allowAnonymousPost" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"allowSecretComment" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isAdultOnly" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"requiredTags" JSONB,
|
||||||
|
"requiredFields" JSONB,
|
||||||
|
"readLevel" TEXT NOT NULL DEFAULT 'public',
|
||||||
|
"writeLevel" TEXT NOT NULL DEFAULT 'member',
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"mainPageViewTypeId" TEXT,
|
||||||
|
"listViewTypeId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "boards_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "board_categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "boards_mainPageViewTypeId_fkey" FOREIGN KEY ("mainPageViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "boards_listViewTypeId_fkey" FOREIGN KEY ("listViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_boards" ("allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel" FROM "boards";
|
||||||
|
DROP TABLE "boards";
|
||||||
|
ALTER TABLE "new_boards" RENAME TO "boards";
|
||||||
|
CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug");
|
||||||
|
CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder");
|
||||||
|
CREATE INDEX "boards_categoryId_idx" ON "boards"("categoryId");
|
||||||
|
CREATE INDEX "boards_mainPageViewTypeId_idx" ON "boards"("mainPageViewTypeId");
|
||||||
|
CREATE INDEX "boards_listViewTypeId_idx" ON "boards"("listViewTypeId");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_comments" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"postId" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"depth" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"authorId" TEXT,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isSecret" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"secretPasswordHash" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "comments_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_comments" ("authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt") SELECT "authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt" FROM "comments";
|
||||||
|
DROP TABLE "comments";
|
||||||
|
ALTER TABLE "new_comments" RENAME TO "comments";
|
||||||
|
CREATE INDEX "comments_postId_createdAt_idx" ON "comments"("postId", "createdAt");
|
||||||
|
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -5,10 +5,12 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
generator erd {
|
generator erd {
|
||||||
provider = "prisma-erd-generator"
|
provider = "prisma-erd-generator"
|
||||||
output = "../public/erd.svg"
|
output = "../public/erd.svg"
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
@@ -22,10 +24,6 @@ enum BoardStatus {
|
|||||||
archived
|
archived
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BoardType {
|
|
||||||
general
|
|
||||||
special
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AccessLevel {
|
enum AccessLevel {
|
||||||
public // 비회원도 접근
|
public // 비회원도 접근
|
||||||
@@ -126,8 +124,6 @@ model Board {
|
|||||||
description String?
|
description String?
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
status BoardStatus @default(active)
|
status BoardStatus @default(active)
|
||||||
type BoardType @default(general) // 일반/특수
|
|
||||||
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
|
|
||||||
allowAnonymousPost Boolean @default(false) // 익명 글 허용
|
allowAnonymousPost Boolean @default(false) // 익명 글 허용
|
||||||
allowSecretComment Boolean @default(false) // 비밀댓글 허용
|
allowSecretComment Boolean @default(false) // 비밀댓글 허용
|
||||||
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
|
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
|
||||||
@@ -154,7 +150,6 @@ model Board {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([status, sortOrder])
|
@@index([status, sortOrder])
|
||||||
@@index([type, requiresApproval])
|
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([mainPageViewTypeId])
|
@@index([mainPageViewTypeId])
|
||||||
@@index([listViewTypeId])
|
@@index([listViewTypeId])
|
||||||
@@ -222,6 +217,7 @@ model Post {
|
|||||||
reports Report[]
|
reports Report[]
|
||||||
stat PostStat?
|
stat PostStat?
|
||||||
viewLogs PostViewLog[]
|
viewLogs PostViewLog[]
|
||||||
|
dailyViews DailyPostView[]
|
||||||
|
|
||||||
@@index([boardId, status, createdAt])
|
@@index([boardId, status, createdAt])
|
||||||
@@index([boardId, isPinned, pinnedOrder])
|
@@index([boardId, isPinned, pinnedOrder])
|
||||||
@@ -230,13 +226,17 @@ model Post {
|
|||||||
|
|
||||||
// 사용자
|
// 사용자
|
||||||
model User {
|
model User {
|
||||||
userId String @id @default(cuid())
|
userId String @id
|
||||||
nickname String @unique
|
nickname String @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
name String
|
name String
|
||||||
birth DateTime
|
birth DateTime?
|
||||||
phone String @unique
|
phone String? @unique
|
||||||
rank Int @default(0)
|
rank Int @default(0)
|
||||||
|
// 누적 포인트, 레벨, 등급(0~10)
|
||||||
|
points Int @default(0)
|
||||||
|
level Int @default(1)
|
||||||
|
grade Int @default(0)
|
||||||
|
|
||||||
status UserStatus @default(active)
|
status UserStatus @default(active)
|
||||||
authLevel AuthLevel @default(USER)
|
authLevel AuthLevel @default(USER)
|
||||||
@@ -260,6 +260,7 @@ model User {
|
|||||||
blocksInitiated Block[] @relation("Blocker")
|
blocksInitiated Block[] @relation("Blocker")
|
||||||
blocksReceived Block[] @relation("Blocked")
|
blocksReceived Block[] @relation("Blocked")
|
||||||
pointTxns PointTransaction[]
|
pointTxns PointTransaction[]
|
||||||
|
attendances Attendance[]
|
||||||
sanctions Sanction[]
|
sanctions Sanction[]
|
||||||
nicknameChanges NicknameChange[]
|
nicknameChanges NicknameChange[]
|
||||||
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
||||||
@@ -325,6 +326,8 @@ model UserRole {
|
|||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
postId String
|
postId String
|
||||||
|
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
|
||||||
|
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
|
||||||
authorId String?
|
authorId String?
|
||||||
content String
|
content String
|
||||||
isAnonymous Boolean @default(false)
|
isAnonymous Boolean @default(false)
|
||||||
@@ -333,11 +336,14 @@ model Comment {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
reports Report[]
|
replies Comment[] @relation("CommentReplies")
|
||||||
|
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
||||||
|
reports Report[]
|
||||||
|
|
||||||
@@index([postId, createdAt])
|
@@index([postId, createdAt])
|
||||||
|
@@index([parentId])
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +439,21 @@ model PostStat {
|
|||||||
@@map("post_stats")
|
@@map("post_stats")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일일 게시글 조회수 (날짜별 집계)
|
||||||
|
model DailyPostView {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
postId String
|
||||||
|
date DateTime // 날짜만 사용 (시간은 00:00:00)
|
||||||
|
viewCount Int @default(0)
|
||||||
|
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([postId, date])
|
||||||
|
@@index([date, viewCount])
|
||||||
|
@@index([postId, date])
|
||||||
|
@@map("daily_post_views")
|
||||||
|
}
|
||||||
|
|
||||||
// 신고(게시글/댓글 대상)
|
// 신고(게시글/댓글 대상)
|
||||||
model Report {
|
model Report {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -502,6 +523,20 @@ model PointTransaction {
|
|||||||
@@map("point_transactions")
|
@@map("point_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 출석부 기록 (사용자별 일자 단위 출석)
|
||||||
|
model Attendance {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
date DateTime // 자정 기준 날짜만 사용
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, date])
|
||||||
|
@@index([userId, date])
|
||||||
|
@@map("attendance")
|
||||||
|
}
|
||||||
|
|
||||||
// 레벨 임계치(선택)
|
// 레벨 임계치(선택)
|
||||||
model LevelThreshold {
|
model LevelThreshold {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -670,16 +705,36 @@ model Partner {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
category String
|
category String
|
||||||
|
categoryId String?
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
|
imageUrl String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
@@index([category])
|
@@index([category])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([sortOrder])
|
||||||
@@map("partners")
|
@@map("partners")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 제휴업체 카테고리(관리자 생성/삭제)
|
||||||
|
model PartnerCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
partners Partner[]
|
||||||
|
|
||||||
|
@@index([sortOrder])
|
||||||
|
@@map("partner_categories")
|
||||||
|
}
|
||||||
|
|
||||||
// 배너/공지 노출용
|
// 배너/공지 노출용
|
||||||
model Banner {
|
model Banner {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -722,6 +777,10 @@ model PartnerRequest {
|
|||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
contact String?
|
contact String?
|
||||||
|
region String?
|
||||||
|
imageUrl String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
status String @default("pending") // pending/approved/rejected
|
status String @default("pending") // pending/approved/rejected
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
approvedAt DateTime?
|
approvedAt DateTime?
|
||||||
@@ -740,3 +799,6 @@ model Setting {
|
|||||||
|
|
||||||
@@map("settings")
|
@@map("settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||||
|
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)
|
||||||
|
|||||||
512
prisma/seed.js
@@ -1,17 +1,185 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const { createHash } = require("crypto");
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function hashPassword(plain) {
|
||||||
|
return createHash("sha256").update(plain, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomDate(startYear, endYear) {
|
||||||
|
const start = new Date(`${startYear}-01-01`).getTime();
|
||||||
|
const end = new Date(`${endYear}-12-31`).getTime();
|
||||||
|
return new Date(randomInt(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomKoreanName() {
|
||||||
|
const lastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"];
|
||||||
|
const firstParts = ["민", "서", "도", "현", "지", "아", "윤", "준", "하", "유", "채", "은", "수", "태", "나"];
|
||||||
|
const secondParts = ["우", "영", "민", "서", "진", "현", "빈", "율", "솔", "연", "환", "호", "린", "훈", "경"];
|
||||||
|
const last = lastNames[randomInt(0, lastNames.length - 1)];
|
||||||
|
const first = firstParts[randomInt(0, firstParts.length - 1)] + secondParts[randomInt(0, secondParts.length - 1)];
|
||||||
|
return last + first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUniquePhone(i) {
|
||||||
|
const mid = String(2000 + (i % 8000)).padStart(4, "0");
|
||||||
|
const last = String(3000 + i).padStart(4, "0");
|
||||||
|
return `010-${mid}-${last}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNickname(i) {
|
||||||
|
const suffix = Math.random().toString(36).slice(2, 4);
|
||||||
|
return `user${String(i + 1).padStart(3, "0")}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 랜덤 제목/문장/이미지 도우미
|
||||||
|
const TITLE_FRAGMENTS = [
|
||||||
|
// 아주 짧은 키워드
|
||||||
|
"공지", "업뎃", "버그", "요청", "후기", "정보", "TIP", "사진", "잡담", "나눔",
|
||||||
|
"질문", "헬프", "리뷰", "이슈", "주의", "긴급", "정리", "모음", "요약", "스샷",
|
||||||
|
// 짧은 구문
|
||||||
|
"오늘의 이슈", "핫 토픽", "소소한 일상", "정보 공유", "꿀팁 모음",
|
||||||
|
"개발 노트", "버그 리포트", "아이디어 제안", "함께 보아요",
|
||||||
|
];
|
||||||
|
const SENTENCES = [
|
||||||
|
"안녕하세요, 간단히 공유 드립니다.",
|
||||||
|
"도움이 되셨다면 댓글로 알려주세요.",
|
||||||
|
"의견이나 질문은 언제든 환영입니다.",
|
||||||
|
"테스트로 작성된 시드 데이터입니다.",
|
||||||
|
"참고용 스크린샷을 함께 첨부합니다.",
|
||||||
|
"관련 경험 있으시면 팁 부탁드려요.",
|
||||||
|
"문서화가 필요해 간단히 정리했습니다.",
|
||||||
|
"링크와 자료를 함께 첨부합니다.",
|
||||||
|
"개선 제안은 자유롭게 남겨주세요.",
|
||||||
|
"읽어주셔서 감사합니다.",
|
||||||
|
];
|
||||||
|
const TITLE_SUBS = [
|
||||||
|
"지금", "방금", "오늘", "금일", "v2", "2025", "베타", "테스트",
|
||||||
|
"임시", "간단히", "빠르게", "짧게", "새로", "업데이트", "정리", "공유",
|
||||||
|
];
|
||||||
|
const TITLE_EMOJIS = ["🔥", "📌", "✅", "❗", "💡", "🆕", "🔧", "📝", "📷"];
|
||||||
|
|
||||||
|
function clampTitle(s, max = 60) {
|
||||||
|
return s.length <= max ? s : s.slice(0, max).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(arr) { return arr[randomInt(0, arr.length - 1)]; }
|
||||||
|
function coin(p = 0.5) { return Math.random() < p; }
|
||||||
|
|
||||||
|
function randomTitle(boardName, index) {
|
||||||
|
// 다양한 템플릿으로 제목 생성 (짧은 것도, 긴 것도 포함)
|
||||||
|
const a = pick(TITLE_FRAGMENTS);
|
||||||
|
const b = pick(TITLE_FRAGMENTS);
|
||||||
|
const sub = pick(TITLE_SUBS);
|
||||||
|
const emoji = pick(TITLE_EMOJIS);
|
||||||
|
const num = (index % 99) + 1;
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
() => `${a}`,
|
||||||
|
() => `${a} ${emoji}`,
|
||||||
|
() => `${a} #${num}`,
|
||||||
|
() => `${a} ${sub}`,
|
||||||
|
() => `${a} · ${b}`,
|
||||||
|
() => `[${a}] ${b}`,
|
||||||
|
() => `${a}: ${b}`,
|
||||||
|
() => `${a} ${b} ${emoji}`,
|
||||||
|
// 가끔만 보드명 포함
|
||||||
|
() => `${boardName} ${a}`,
|
||||||
|
() => `${boardName} ${a} · ${b}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 짧은 제목 확률을 높이기 위해 템플릿 선택 가중치 없이 랜덤
|
||||||
|
const title = pick(templates)();
|
||||||
|
return clampTitle(title, 60);
|
||||||
|
}
|
||||||
|
function randomSentence() {
|
||||||
|
return SENTENCES[randomInt(0, SENTENCES.length - 1)];
|
||||||
|
}
|
||||||
|
function randomImageUrl(seedKey, w = 800, h = 450) {
|
||||||
|
// 외부 랜덤 이미지. 네트워크가 제한되면 /sample.jpg로 대체 가능
|
||||||
|
const seed = encodeURIComponent(String(seedKey));
|
||||||
|
return `https://picsum.photos/seed/${seed}/${w}/${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRandomUsers(count = 100) {
|
||||||
|
const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
|
||||||
|
// 사용되지 않은 전화번호를 찾는 보조 함수
|
||||||
|
async function findAvailablePhone(startIndex) {
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const candidate = generateUniquePhone(startIndex + offset);
|
||||||
|
const exists = await prisma.user.findUnique({ where: { phone: candidate } });
|
||||||
|
if (!exists) return candidate;
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdUsers = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// 고정 ID: user001, user002, ...
|
||||||
|
const userId = `user${String(i + 1).padStart(3, "0")}`;
|
||||||
|
const existing = await prisma.user.findUnique({ where: { userId } });
|
||||||
|
let user = existing;
|
||||||
|
if (!existing) {
|
||||||
|
const name = generateRandomKoreanName();
|
||||||
|
const birth = randomDate(1975, 2005);
|
||||||
|
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
|
||||||
|
// 닉네임: 중복 없는 랜덤 한글 생성
|
||||||
|
let nickname = generateRandomKoreanName();
|
||||||
|
for (let tries = 0; tries < 10; tries++) {
|
||||||
|
const dup = await prisma.user.findUnique({ where: { nickname } });
|
||||||
|
if (!dup) break;
|
||||||
|
nickname = generateRandomKoreanName();
|
||||||
|
}
|
||||||
|
// 그래도 중복이면 희귀 조합 한 번 더 시도
|
||||||
|
const finalDup = await prisma.user.findUnique({ where: { nickname } });
|
||||||
|
if (finalDup) {
|
||||||
|
nickname = generateRandomKoreanName();
|
||||||
|
}
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
name,
|
||||||
|
birth,
|
||||||
|
phone,
|
||||||
|
passwordHash: hashPassword("12341234"),
|
||||||
|
agreementTermsAt: new Date(),
|
||||||
|
authLevel: "USER",
|
||||||
|
isAdultVerified: Math.random() < 0.6,
|
||||||
|
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 기존 사용자도 패스워드를 1234로 업데이트
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { userId: user.userId },
|
||||||
|
data: { passwordHash: hashPassword("12341234") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roleUser && user) {
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { userId_roleId: { userId: user.userId, roleId: roleUser.roleId } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.userId, roleId: roleUser.roleId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (user) createdUsers.push(user);
|
||||||
|
}
|
||||||
|
return createdUsers;
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertCategories() {
|
async function upsertCategories() {
|
||||||
// 카테고리 트리 (projectmemo 기준 상위 그룹)
|
// 카테고리 트리 (projectmemo 기준 상위 그룹)
|
||||||
const categories = [
|
const categories = [
|
||||||
{ name: "암실소문", slug: "main", sortOrder: 1, status: "active" },
|
{ name: "메인", slug: "main", sortOrder: 1, status: "active" },
|
||||||
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
||||||
{ name: "주변 제휴업체", slug: "nearby-partners", sortOrder: 3, status: "active" },
|
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
|
||||||
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
|
|
||||||
{ name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" },
|
|
||||||
{ name: "소통방", slug: "community", sortOrder: 6, status: "active" },
|
|
||||||
{ name: "광고/제휴", slug: "ads-affiliates", sortOrder: 7, status: "active" },
|
|
||||||
];
|
];
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const c of categories) {
|
for (const c of categories) {
|
||||||
@@ -84,14 +252,24 @@ async function upsertRoles() {
|
|||||||
async function upsertAdmin() {
|
async function upsertAdmin() {
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { nickname: "admin" },
|
where: { nickname: "admin" },
|
||||||
update: {},
|
update: {
|
||||||
|
passwordHash: hashPassword("12341234"),
|
||||||
|
grade: 7,
|
||||||
|
points: 1650000,
|
||||||
|
level: 200,
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
|
userId: "admin",
|
||||||
nickname: "admin",
|
nickname: "admin",
|
||||||
name: "Administrator",
|
name: "Administrator",
|
||||||
birth: new Date("1990-01-01"),
|
birth: new Date("1990-01-01"),
|
||||||
phone: "010-0000-0001",
|
phone: "010-0000-0001",
|
||||||
|
passwordHash: hashPassword("12341234"),
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
authLevel: "ADMIN",
|
authLevel: "ADMIN",
|
||||||
|
grade: 7,
|
||||||
|
points: 1650000,
|
||||||
|
level: 200,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,34 +286,82 @@ async function upsertAdmin() {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedAdminAttendance(admin) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getUTCFullYear();
|
||||||
|
const days = [1, 2, 5, 6]; // 11월 1,2,5,6일
|
||||||
|
for (const d of days) {
|
||||||
|
const date = new Date(Date.UTC(year, 10, d, 0, 0, 0, 0)); // 10 = November (0-based)
|
||||||
|
// @@unique([userId, date]) 기준으로 업서트
|
||||||
|
await prisma.attendance.upsert({
|
||||||
|
where: { userId_date: { userId: admin.userId, date } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: admin.userId, date },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("seedAdminAttendance failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedRandomAttendanceForUsers(users, minDays = 10, maxDays = 20) {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const todayUtcMidnight = new Date(Date.UTC(
|
||||||
|
today.getUTCFullYear(),
|
||||||
|
today.getUTCMonth(),
|
||||||
|
today.getUTCDate(),
|
||||||
|
0, 0, 0, 0
|
||||||
|
));
|
||||||
|
for (const user of users) {
|
||||||
|
const count = randomInt(minDays, maxDays);
|
||||||
|
const used = new Set();
|
||||||
|
while (used.size < count) {
|
||||||
|
const offsetDays = randomInt(0, 120); // 최근 120일 범위에서 랜덤
|
||||||
|
const date = new Date(todayUtcMidnight.getTime() - offsetDays * 24 * 60 * 60 * 1000);
|
||||||
|
const key = date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
if (used.has(key)) continue;
|
||||||
|
used.add(key);
|
||||||
|
await prisma.attendance.upsert({
|
||||||
|
where: { userId_date: { userId: user.userId, date } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.userId, date },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("seedRandomAttendanceForUsers failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertBoards(admin, categoryMap) {
|
async function upsertBoards(admin, categoryMap) {
|
||||||
const boards = [
|
const boards = [
|
||||||
// 일반
|
// 일반
|
||||||
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1, writeLevel: "moderator" },
|
{ name: "공지사항", slug: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" },
|
||||||
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 },
|
{ name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 },
|
||||||
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 },
|
{ name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 },
|
||||||
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
|
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
|
||||||
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 },
|
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
|
||||||
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
|
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
|
||||||
{ name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 },
|
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
|
||||||
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
|
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
|
||||||
{ name: "관리사찾아요", slug: "find-therapist", description: "구인/구직", type: "general", sortOrder: 9 },
|
|
||||||
{ name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" },
|
|
||||||
{ name: "방문후기", slug: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true, requiredTags: { anyOf: ["업체명", "지역"] } },
|
|
||||||
// 특수
|
// 특수
|
||||||
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
|
{ name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 },
|
||||||
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
|
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
|
||||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
|
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
|
||||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
|
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
|
||||||
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 },
|
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
|
||||||
// 제휴업소 일반
|
|
||||||
{ name: "제휴업소", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
|
||||||
// 광고/제휴
|
|
||||||
{ name: "제휴문의", slug: "partner-contact", description: "제휴문의", type: "general", sortOrder: 18, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
|
||||||
{ name: "제휴업소 요청", slug: "partner-req", description: "제휴업소 요청", type: "general", sortOrder: 19, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const created = [];
|
const created = [];
|
||||||
|
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||||
|
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||||
|
const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
|
||||||
|
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||||
|
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
||||||
|
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
|
||||||
|
|
||||||
for (const b of boards) {
|
for (const b of boards) {
|
||||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||||
const mapBySlug = {
|
const mapBySlug = {
|
||||||
@@ -149,22 +375,12 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
ranking: "hall-of-fame",
|
ranking: "hall-of-fame",
|
||||||
"free-coupons": "hall-of-fame",
|
"free-coupons": "hall-of-fame",
|
||||||
"monthly-stats": "hall-of-fame",
|
"monthly-stats": "hall-of-fame",
|
||||||
// 주변 제휴업체
|
|
||||||
"nearby-partners": "nearby-partners",
|
|
||||||
// 제휴업소 정보
|
|
||||||
"partners-photos": "partner-info",
|
|
||||||
// 방문후기
|
|
||||||
reviews: "reviews",
|
|
||||||
// 소통방(기본값 community로 처리)
|
// 소통방(기본값 community로 처리)
|
||||||
free: "community",
|
free: "community",
|
||||||
qna: "community",
|
qna: "community",
|
||||||
tips: "community",
|
tips: "community",
|
||||||
anonymous: "community",
|
anonymous: "community",
|
||||||
"find-therapist": "community",
|
|
||||||
"blue-house": "community",
|
|
||||||
// 광고/제휴
|
// 광고/제휴
|
||||||
"partner-contact": "ads-affiliates",
|
|
||||||
"partner-req": "ads-affiliates",
|
|
||||||
};
|
};
|
||||||
const categorySlug = mapBySlug[b.slug] || "community";
|
const categorySlug = mapBySlug[b.slug] || "community";
|
||||||
const category = categoryMap[categorySlug];
|
const category = categoryMap[categorySlug];
|
||||||
@@ -173,22 +389,30 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
update: {
|
update: {
|
||||||
description: b.description,
|
description: b.description,
|
||||||
sortOrder: b.sortOrder,
|
sortOrder: b.sortOrder,
|
||||||
type: b.type,
|
|
||||||
requiresApproval: !!b.requiresApproval,
|
|
||||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||||
readLevel: b.readLevel || undefined,
|
readLevel: b.readLevel || undefined,
|
||||||
categoryId: category ? category.id : undefined,
|
categoryId: category ? category.id : undefined,
|
||||||
|
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||||
|
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||||
|
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||||
|
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||||
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
|
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: b.name,
|
name: b.name,
|
||||||
slug: b.slug,
|
slug: b.slug,
|
||||||
description: b.description,
|
description: b.description,
|
||||||
sortOrder: b.sortOrder,
|
sortOrder: b.sortOrder,
|
||||||
type: b.type,
|
|
||||||
requiresApproval: !!b.requiresApproval,
|
|
||||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||||
readLevel: b.readLevel || undefined,
|
readLevel: b.readLevel || undefined,
|
||||||
categoryId: category ? category.id : undefined,
|
categoryId: category ? category.id : undefined,
|
||||||
|
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||||
|
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||||
|
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||||
|
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||||
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
|
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
created.push(board);
|
created.push(board);
|
||||||
@@ -209,16 +433,15 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
|
|
||||||
async function upsertViewTypes() {
|
async function upsertViewTypes() {
|
||||||
const viewTypes = [
|
const viewTypes = [
|
||||||
// main scope
|
// main scope (기본/없음 제거, 텍스트 중심)
|
||||||
{ key: "main_default", name: "기본", scope: "main" },
|
|
||||||
{ key: "main_text", name: "텍스트", scope: "main" },
|
{ key: "main_text", name: "텍스트", scope: "main" },
|
||||||
{ key: "main_preview", name: "미리보기", scope: "main" },
|
{ key: "main_preview", name: "미리보기", scope: "main" },
|
||||||
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
||||||
// list scope
|
// list scope (기본/없음 제거, 텍스트 중심)
|
||||||
{ key: "list_default", name: "기본", scope: "list" },
|
|
||||||
{ key: "list_text", name: "텍스트", scope: "list" },
|
{ key: "list_text", name: "텍스트", scope: "list" },
|
||||||
{ key: "list_preview", name: "미리보기", scope: "list" },
|
{ key: "list_preview", name: "미리보기", scope: "list" },
|
||||||
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
||||||
|
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
|
||||||
];
|
];
|
||||||
for (const vt of viewTypes) {
|
for (const vt of viewTypes) {
|
||||||
await prisma.boardViewType.upsert({
|
await prisma.boardViewType.upsert({
|
||||||
@@ -229,6 +452,42 @@ async function upsertViewTypes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeNonPrimaryBoards() {
|
||||||
|
// 메인/명예의전당/소통방에 해당하지 않는 게시판(광고/제휴 계열) 정리
|
||||||
|
await prisma.board.deleteMany({
|
||||||
|
where: { slug: { in: ["partners-photos", "partner-contact", "partner-req"] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
|
||||||
|
const users = await prisma.user.findMany({ select: { userId: true } });
|
||||||
|
const userIds = users.map((u) => u.userId);
|
||||||
|
for (const board of boards) {
|
||||||
|
// 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다.
|
||||||
|
if (board.slug === "ranking") continue;
|
||||||
|
const data = [];
|
||||||
|
for (let i = 0; i < countPerBoard; i++) {
|
||||||
|
const authorId = ["notice", "bug-report"].includes(board.slug)
|
||||||
|
? admin.userId
|
||||||
|
: userIds[randomInt(0, userIds.length - 1)];
|
||||||
|
const title = randomTitle(board.name, i);
|
||||||
|
const img = randomImageUrl(`${board.slug}-${i}`);
|
||||||
|
const p1 = randomSentence();
|
||||||
|
const p2 = randomSentence();
|
||||||
|
const p3 = randomSentence();
|
||||||
|
data.push({
|
||||||
|
boardId: board.id,
|
||||||
|
authorId,
|
||||||
|
title,
|
||||||
|
content: `<p>${p1}</p>\n<figure><img src="${img}" alt="seed image" /></figure>\n<p>${p2}</p>\n<p>${p3}</p>`,
|
||||||
|
status: "published",
|
||||||
|
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.post.createMany({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function seedPolicies() {
|
async function seedPolicies() {
|
||||||
// 금칙어 예시
|
// 금칙어 예시
|
||||||
const banned = [
|
const banned = [
|
||||||
@@ -259,41 +518,160 @@ async function seedPolicies() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedPartnerShops() {
|
||||||
|
const items = [
|
||||||
|
{ region: "경기도", name: "test1", address: "수원시 팔달구 매산로 45", imageUrl: "/sample.jpg", sortOrder: 1 },
|
||||||
|
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
|
||||||
|
];
|
||||||
|
for (const it of items) {
|
||||||
|
await prisma.partnerRequest.upsert({
|
||||||
|
where: { id: `${it.region}-${it.name}` },
|
||||||
|
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" },
|
||||||
|
create: { id: `${it.region}-${it.name}`, region: it.region, name: it.name, address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved", category: "misc", latitude: 37.5, longitude: 127.0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 표시 토글 기본값 보장
|
||||||
|
const SETTINGS_KEY = "mainpage_settings";
|
||||||
|
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||||
|
const current = setting ? JSON.parse(setting.value) : {};
|
||||||
|
const next = { showPartnerShops: true, ...current };
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: SETTINGS_KEY },
|
||||||
|
update: { value: JSON.stringify(next) },
|
||||||
|
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedBanners() {
|
||||||
|
// 기존 배너 정리 후 5개만 채움
|
||||||
|
await prisma.banner.deleteMany({});
|
||||||
|
const items = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
title: `메인 배너 ${i + 1}`,
|
||||||
|
imageUrl: "/sample.jpg",
|
||||||
|
linkUrl: "/",
|
||||||
|
sortOrder: i + 1,
|
||||||
|
active: true,
|
||||||
|
}));
|
||||||
|
await prisma.banner.createMany({ data: items });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMainpageVisibleBoards(boards) {
|
||||||
|
const SETTINGS_KEY = "mainpage_settings";
|
||||||
|
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||||
|
const current = setting ? JSON.parse(setting.value) : {};
|
||||||
|
const wantSlugs = new Set(["notice", "free", "ranking"]);
|
||||||
|
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
|
||||||
|
const next = { ...current, visibleBoardIds };
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: SETTINGS_KEY },
|
||||||
|
update: { value: JSON.stringify(next) },
|
||||||
|
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
console.log("DATABASE_URL:", process.env.DATABASE_URL);
|
||||||
|
try {
|
||||||
|
const tables = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table'`;
|
||||||
|
console.log("SQLite tables:", tables.map((t) => t.name || t.NAME || JSON.stringify(t)));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// SQLite 수동 보정: partner_categories 테이블과 partners.categoryId 컬럼 보장
|
||||||
|
try {
|
||||||
|
const rows = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='partner_categories'`;
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
console.log("Creating missing table: partner_categories");
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
"CREATE TABLE IF NOT EXISTS partner_categories (\n" +
|
||||||
|
"id TEXT PRIMARY KEY,\n" +
|
||||||
|
"name TEXT NOT NULL UNIQUE,\n" +
|
||||||
|
"sortOrder INTEGER NOT NULL DEFAULT 0,\n" +
|
||||||
|
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)");
|
||||||
|
}
|
||||||
|
// Attendance 테이블 보장 (마이그레이션 미실행 환경 대응)
|
||||||
|
const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`;
|
||||||
|
if (!Array.isArray(att) || att.length === 0) {
|
||||||
|
console.log("Creating missing table: attendance");
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
"CREATE TABLE IF NOT EXISTS attendance (\n" +
|
||||||
|
"id TEXT PRIMARY KEY,\n" +
|
||||||
|
"userId TEXT NOT NULL,\n" +
|
||||||
|
"date DATETIME NOT NULL,\n" +
|
||||||
|
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)");
|
||||||
|
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)");
|
||||||
|
}
|
||||||
|
const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`;
|
||||||
|
const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId');
|
||||||
|
if (!hasCategoryId) {
|
||||||
|
console.log("Adding missing column: partners.categoryId");
|
||||||
|
await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT");
|
||||||
|
// 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움)
|
||||||
|
}
|
||||||
|
// partner_requests 확장 컬럼 보장 (region, imageUrl, sortOrder, active)
|
||||||
|
const prCols = await prisma.$queryRaw`PRAGMA table_info('partner_requests')`;
|
||||||
|
const colHas = (name) => Array.isArray(prCols) && prCols.some((c) => (c.name || c.COLUMN_NAME) === name);
|
||||||
|
if (!colHas('region')) {
|
||||||
|
console.log("Adding missing column: partner_requests.region");
|
||||||
|
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN region TEXT");
|
||||||
|
}
|
||||||
|
if (!colHas('imageUrl')) {
|
||||||
|
console.log("Adding missing column: partner_requests.imageUrl");
|
||||||
|
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN imageUrl TEXT");
|
||||||
|
}
|
||||||
|
if (!colHas('sortOrder')) {
|
||||||
|
console.log("Adding missing column: partner_requests.sortOrder");
|
||||||
|
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN sortOrder INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!colHas('active')) {
|
||||||
|
console.log("Adding missing column: partner_requests.active");
|
||||||
|
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("SQLite schema ensure failed:", e);
|
||||||
|
}
|
||||||
await upsertRoles();
|
await upsertRoles();
|
||||||
const admin = await upsertAdmin();
|
const admin = await upsertAdmin();
|
||||||
const categoryMap = await upsertCategories();
|
const categoryMap = await upsertCategories();
|
||||||
await upsertViewTypes();
|
await upsertViewTypes();
|
||||||
|
const randomUsers = await createRandomUsers(3);
|
||||||
|
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
|
||||||
|
await removeNonPrimaryBoards();
|
||||||
const boards = await upsertBoards(admin, categoryMap);
|
const boards = await upsertBoards(admin, categoryMap);
|
||||||
|
await seedAdminAttendance(admin);
|
||||||
// 샘플 글 하나
|
await seedMainpageVisibleBoards(boards);
|
||||||
const free = boards.find((b) => b.slug === "free") || boards[0];
|
await createPostsForAllBoards(boards, 100, admin);
|
||||||
const post = await prisma.post.create({
|
await seedPartnerShops();
|
||||||
data: {
|
await seedBanners();
|
||||||
boardId: free.id,
|
|
||||||
authorId: admin.userId,
|
|
||||||
title: "첫 글",
|
|
||||||
content: "메시지 앱 초기 설정 완료",
|
|
||||||
status: "published",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.comment.createMany({
|
|
||||||
data: [
|
|
||||||
{ postId: post.id, authorId: admin.userId, content: "환영합니다!" },
|
|
||||||
{ postId: post.id, authorId: admin.userId, content: "댓글 테스트" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await seedPolicies();
|
await seedPolicies();
|
||||||
|
|
||||||
// 제휴업체 예시 데이터
|
// 제휴업체 예시 데이터
|
||||||
const partners = [
|
const partners = [
|
||||||
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 중구" },
|
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
||||||
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
|
||||||
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
|
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
|
||||||
];
|
];
|
||||||
|
// 파트너 카테고리(PartnerCategory) 생성 및 매핑
|
||||||
|
const partnerCategoryNames = Array.from(new Set(partners.map((p) => p.category).filter(Boolean)));
|
||||||
|
const partnerCategoryMap = {};
|
||||||
|
for (let i = 0; i < partnerCategoryNames.length; i++) {
|
||||||
|
const name = partnerCategoryNames[i];
|
||||||
|
const created = await prisma.partnerCategory.upsert({
|
||||||
|
where: { name },
|
||||||
|
update: { sortOrder: i + 1 },
|
||||||
|
create: { name, sortOrder: i + 1 },
|
||||||
|
});
|
||||||
|
partnerCategoryMap[name] = created;
|
||||||
|
}
|
||||||
for (const p of partners) {
|
for (const p of partners) {
|
||||||
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p });
|
const categoryRef = p.category ? partnerCategoryMap[p.category] : null;
|
||||||
|
const data = { ...p, categoryId: categoryRef ? categoryRef.id : null };
|
||||||
|
await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 836 KiB |
9
public/svgs/01_bronze.svg
Normal file
|
After Width: | Height: | Size: 99 KiB |
9
public/svgs/02_silver.svg.svg
Normal file
|
After Width: | Height: | Size: 69 KiB |
9
public/svgs/03_gold.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
9
public/svgs/04_platinum.svg
Normal file
|
After Width: | Height: | Size: 84 KiB |
9
public/svgs/05_diamond.svg
Normal file
|
After Width: | Height: | Size: 92 KiB |
9
public/svgs/06_master.svg
Normal file
|
After Width: | Height: | Size: 96 KiB |
9
public/svgs/07_grandmaster.svg
Normal file
|
After Width: | Height: | Size: 95 KiB |
9
public/svgs/08_god.svg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/uploads/1761998092426-cdxtisa8vi6.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762011428045-b2uxup0646v.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762011528689-r5qbw3daoq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762012666810-zhoeib9y8we.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013315968-jent9fluatl.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013865590-izqoqn8qgbm.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014531262-2vmcxdk945u.jpg
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/uploads/1762014639578-9e1067twpw.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/uploads/1762014695925-jmes4cxd0vd.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014716631-fgq5a179wwr.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762014821297-9qbwphmxm05.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762015671690-p67kkblxdml.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762015830912-1wsv0cfchd8.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762016086149-vcxoon8tg8.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762016548518-0d2zhs3f44bq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762017507500-08hp85ex35v.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017553592-w9qnbapfb2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017624031-rni0unzdl6c.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762025382776-83vifeqk7rk.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762049641372-u0moc5pbmuj.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/uploads/1762049648293-vgw2mda8rx7.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/uploads/1762049842993-8hlvwqbkgo8.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/uploads/1762049846758-2begnvv4hxd.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/uploads/1762049851828-lvzzq326a1.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/uploads/1762052938422-has0h33j4x6.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/uploads/1762053004600-0ewlk5af03i.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/uploads/1762059929501-cxe093gyep7.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/uploads/1762060014485-401jthedn6x.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/uploads/1762060051441-h7kz3p9myc6.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762060249110-ut9vnyatzc.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/uploads/1762060270463-di302vcwbg.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/uploads/1762060431071-kpio217ffh7.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/uploads/1762349790327-56eucbsdkiy.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762349798482-lg2199h4w0h.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/uploads/1762439529544-pfdpsiv372l.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762439795788-41zbv74p6l9.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762444179265-fuj8zoahblc.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762520156525-1dqijvt0rge.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762521903585-d2gxpaoocil.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762694665640-mepawpoqguh.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694722874-13r02smuxh0n.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694815229-575v2kyj72x.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695071878-s0a8nautp7d.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695378037-rbj4gzlxveq.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762696083342-gwebeuwl0q4.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/uploads/1762696149731-1fom3wudm94.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/uploads/1762701326416-gknp8r0e4af.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762702099453-si2e8ubylu9.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/1762703335687-i85lpr0bgo.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/uploads/1762704770941-j2nzhl8ww1.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
@@ -4,18 +4,18 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/admin/menus", label: "메뉴 관리" },
|
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
||||||
{ href: "/admin/boards", label: "게시판" },
|
{ href: "/admin/boards", label: "게시판" },
|
||||||
|
{ href: "/admin/banners", label: "배너" },
|
||||||
|
{ href: "/admin/partners", label: "제휴업체" },
|
||||||
{ href: "/admin/users", label: "사용자" },
|
{ href: "/admin/users", label: "사용자" },
|
||||||
{ href: "/admin/logs", label: "로그" },
|
{ href: "/admin/logs", label: "로그" },
|
||||||
{ href: "/admin/banners", label: "배너" },
|
|
||||||
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminSidebar() {
|
export default function AdminSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/80 backdrop-blur h-full">
|
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/90 backdrop-blur h-full">
|
||||||
<div className="px-4 py-4 border-b border-neutral-200">
|
<div className="px-4 py-4 border-b border-neutral-200">
|
||||||
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { Modal } from "@/app/components/ui/Modal";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
|
|||||||
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
|
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
|
||||||
const banners = data?.banners ?? [];
|
const banners = data?.banners ?? [];
|
||||||
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
|
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const inputEl = e.currentTarget; // 이벤트 풀링 대비 사본 보관
|
||||||
|
const file = inputEl.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||||
|
const json = await r.json();
|
||||||
|
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
||||||
|
setForm((f) => ({ ...f, imageUrl: json.url }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("이미지 업로드 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (inputEl) inputEl.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
async function create() {
|
async function create() {
|
||||||
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
||||||
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); }
|
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); setShowCreateModal(false); }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>배너 관리</h1>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<h1>배너 관리</h1>
|
||||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
<button
|
||||||
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
type="button"
|
||||||
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
|
onClick={() => setShowCreateModal(true)}
|
||||||
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성</label>
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
|
>
|
||||||
<button onClick={create}>추가</button>
|
추가
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
|
||||||
|
<div className="w-[720px] max-w-[90vw]">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-4">배너 추가</h2>
|
||||||
|
{(() => {
|
||||||
|
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>제목</label>
|
||||||
|
<input style={inputStyle} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>링크 URL(선택)</label>
|
||||||
|
<input style={inputStyle} value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
업로드
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
|
||||||
|
{uploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>활성</label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm"><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성화</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>정렬</label>
|
||||||
|
<input type="number" style={inputStyle} value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
{form.imageUrl && (
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
||||||
|
<img src={form.imageUrl} alt="미리보기" style={{ width: 320, height: 160, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={create}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{banners.map((b) => (
|
{banners.map((b) => (
|
||||||
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
@@ -31,8 +123,8 @@ export default function AdminBannersPage() {
|
|||||||
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}>링크</a>}</div>
|
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}>링크</a>}</div>
|
||||||
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
|
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }}>{b.active ? "비활성" : "활성"}</button>
|
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }} className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition">{b.active ? "비활성" : "활성"}</button>
|
||||||
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }}>삭제</button>
|
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }} className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition">삭제</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export default function AdminBoardsPage() {
|
|||||||
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
|
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
|
||||||
const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]);
|
const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]);
|
||||||
const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
|
const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
|
||||||
|
const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
|
||||||
|
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]);
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
@@ -87,6 +89,21 @@ export default function AdminBoardsPage() {
|
|||||||
mutateBoards();
|
mutateBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 버튼으로 카테고리 순서 이동 (↑/↓)
|
||||||
|
function moveCategory(catId: string, delta: number) {
|
||||||
|
const baseIds = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
|
||||||
|
const idx = baseIds.indexOf(catId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const to = idx + delta;
|
||||||
|
if (to < 0 || to >= baseIds.length) return;
|
||||||
|
const nextIds = [...baseIds];
|
||||||
|
const [moved] = nextIds.splice(idx, 1);
|
||||||
|
nextIds.splice(to, 0, moved);
|
||||||
|
setCatOrder(nextIds);
|
||||||
|
const nextCats = nextIds.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
|
||||||
|
reorderCategories(nextCats);
|
||||||
|
}
|
||||||
|
|
||||||
// DnD: 카테고리 순서 변경 (저장 시 반영)
|
// DnD: 카테고리 순서 변경 (저장 시 반영)
|
||||||
function reorderCategories(next: any[]) {
|
function reorderCategories(next: any[]) {
|
||||||
setDirtyCats((prev) => {
|
setDirtyCats((prev) => {
|
||||||
@@ -222,7 +239,7 @@ export default function AdminBoardsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortOrder = (currentItems?.length ?? 0) + 1;
|
const sortOrder = (currentItems?.length ?? 0) + 1;
|
||||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) });
|
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
|
||||||
await mutateBoards();
|
await mutateBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,56 +292,9 @@ export default function AdminBoardsPage() {
|
|||||||
onClick={createCategory}
|
onClick={createCategory}
|
||||||
>대분류 추가</button>
|
>대분류 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul className="divide-y-2 divide-neutral-100">
|
||||||
className="divide-y-2 divide-neutral-100"
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
if (draggingCatIndex === null) return;
|
|
||||||
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
|
|
||||||
// 현재 마우스 Y에 해당하는 행 인덱스 계산
|
|
||||||
let overIdx = -1;
|
|
||||||
for (let i = 0; i < ids.length; i++) {
|
|
||||||
const el = catRefs.current[ids[i]];
|
|
||||||
if (!el) continue;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const mid = rect.top + rect.height / 2;
|
|
||||||
if (e.clientY < mid) { overIdx = i; break; }
|
|
||||||
}
|
|
||||||
if (overIdx === -1) overIdx = ids.length - 1;
|
|
||||||
if (overIdx === draggingCatIndex) return;
|
|
||||||
setCatOrder((order) => {
|
|
||||||
const base = order.length ? order : categories.map((c: any) => c.id);
|
|
||||||
const next = [...base];
|
|
||||||
const [moved] = next.splice(draggingCatIndex, 1);
|
|
||||||
next.splice(overIdx, 0, moved);
|
|
||||||
setDraggingCatIndex(overIdx);
|
|
||||||
const nextCats = next.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
|
|
||||||
reorderCategories(nextCats);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onDragEnter={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
|
|
||||||
const nextCats = ids.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
|
|
||||||
reorderCategories(nextCats);
|
|
||||||
setDraggingCatIndex(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groups.map((g, idx) => (
|
{groups.map((g, idx) => (
|
||||||
<li
|
<li key={g.id} className="select-none">
|
||||||
key={g.id}
|
|
||||||
className="select-none"
|
|
||||||
ref={(el) => { catRefs.current[g.id] = el; }}
|
|
||||||
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDraggingCatIndex(null);
|
|
||||||
}}
|
|
||||||
onDragEnd={() => { setDraggingCatIndex(null); }}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-3 flex items-center gap-3">
|
<div className="px-4 py-3 flex items-center gap-3">
|
||||||
<div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div>
|
<div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div>
|
||||||
{g.id === 'uncat' ? (
|
{g.id === 'uncat' ? (
|
||||||
@@ -333,9 +303,25 @@ export default function AdminBoardsPage() {
|
|||||||
<div className="text-sm font-medium text-neutral-800">미분류 (카테고리 없음)</div>
|
<div className="text-sm font-medium text-neutral-800">미분류 (카테고리 없음)</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => {
|
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
|
||||||
setDraggingCatIndex(idx);
|
)}
|
||||||
}} />
|
{g.id !== 'uncat' && (
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
||||||
|
aria-label="대분류 위로"
|
||||||
|
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === 0}
|
||||||
|
onClick={() => moveCategory(g.id, -1)}
|
||||||
|
>↑</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
||||||
|
aria-label="대분류 아래로"
|
||||||
|
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === ( (catOrder.length ? catOrder.length : categories.length) - 1)}
|
||||||
|
onClick={() => moveCategory(g.id, 1)}
|
||||||
|
>↓</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -374,8 +360,8 @@ export default function AdminBoardsPage() {
|
|||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-2 w-8"></th>
|
|
||||||
<th className="px-2 py-2 w-8 text-center">#</th>
|
<th className="px-2 py-2 w-8 text-center">#</th>
|
||||||
|
<th className="px-2 py-2 w-16 text-center">정렬</th>
|
||||||
<th className="px-3 py-2 text-left">이름</th>
|
<th className="px-3 py-2 text-left">이름</th>
|
||||||
<th className="px-3 py-2 text-left">slug</th>
|
<th className="px-3 py-2 text-left">slug</th>
|
||||||
<th className="px-3 py-2">메인뷰</th>
|
<th className="px-3 py-2">메인뷰</th>
|
||||||
@@ -384,58 +370,32 @@ export default function AdminBoardsPage() {
|
|||||||
<th className="px-3 py-2">쓰기</th>
|
<th className="px-3 py-2">쓰기</th>
|
||||||
<th className="px-3 py-2">익명</th>
|
<th className="px-3 py-2">익명</th>
|
||||||
<th className="px-3 py-2">비밀댓</th>
|
<th className="px-3 py-2">비밀댓</th>
|
||||||
<th className="px-3 py-2">승인</th>
|
|
||||||
<th className="px-3 py-2">유형</th>
|
|
||||||
<th className="px-3 py-2">성인</th>
|
<th className="px-3 py-2">성인</th>
|
||||||
<th className="px-3 py-2">대분류 이동</th>
|
<th className="px-3 py-2">대분류 이동</th>
|
||||||
<th className="px-3 py-2">활성</th>
|
<th className="px-3 py-2">활성</th>
|
||||||
<th className="px-3 py-2">삭제</th>
|
<th className="px-3 py-2">삭제</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody className="divide-y divide-neutral-100">
|
||||||
className="divide-y divide-neutral-100"
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!draggingBoard || draggingBoard.catId !== g.id) return;
|
|
||||||
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
|
|
||||||
let overIdx = -1;
|
|
||||||
for (let i = 0; i < ids.length; i++) {
|
|
||||||
const el = boardRefs.current[ids[i]];
|
|
||||||
if (!el) continue;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const mid = rect.top + rect.height / 2;
|
|
||||||
if (e.clientY < mid) { overIdx = i; break; }
|
|
||||||
}
|
|
||||||
if (overIdx === -1) overIdx = ids.length - 1;
|
|
||||||
if (overIdx === draggingBoard.index) return;
|
|
||||||
setBoardOrderByCat((prev) => {
|
|
||||||
const base = (prev[g.id]?.length ? prev[g.id] : ids);
|
|
||||||
const next = [...base];
|
|
||||||
const [moved] = next.splice(draggingBoard.index, 1);
|
|
||||||
next.splice(overIdx, 0, moved);
|
|
||||||
setDraggingBoard({ catId: g.id, index: overIdx });
|
|
||||||
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
|
|
||||||
reorderBoards(g.id, nextItems);
|
|
||||||
return { ...prev, [g.id]: next };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
|
|
||||||
const nextItems = ids.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
|
|
||||||
reorderBoards(g.id, nextItems);
|
|
||||||
setDraggingBoard(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.items.map((b, i) => (
|
{g.items.map((b, i) => (
|
||||||
<DraggableRow
|
<DraggableRow
|
||||||
key={b.id}
|
key={b.id}
|
||||||
catId={g.id}
|
catId={g.id}
|
||||||
boardId={b.id}
|
boardId={b.id}
|
||||||
index={i}
|
index={i}
|
||||||
setRef={(el) => { boardRefs.current[b.id] = el; }}
|
totalCount={g.items.length}
|
||||||
onStart={() => setDraggingBoard({ catId: g.id, index: i })}
|
onMoveIndex={(delta) => {
|
||||||
onEnd={() => setDraggingBoard(null)}
|
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
|
||||||
|
const from = i;
|
||||||
|
const to = from + delta;
|
||||||
|
if (to < 0 || to >= ids.length) return;
|
||||||
|
const next = [...ids];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
setBoardOrderByCat((prev) => ({ ...prev, [g.id]: next }));
|
||||||
|
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
|
||||||
|
reorderBoards(g.id, nextItems);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<BoardRowCells
|
<BoardRowCells
|
||||||
b={b}
|
b={b}
|
||||||
@@ -478,12 +438,20 @@ export default function AdminBoardsPage() {
|
|||||||
|
|
||||||
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
|
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
|
||||||
const [edit, setEdit] = useState(b);
|
const [edit, setEdit] = useState(b);
|
||||||
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
|
// 선택 가능 옵션에서 '기본' 타입은 제외
|
||||||
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
|
const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
|
||||||
|
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
|
||||||
|
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
|
||||||
|
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
|
||||||
|
? edit.mainPageViewTypeId
|
||||||
|
: (selectableMainTypes[0]?.id ?? '');
|
||||||
|
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
|
||||||
|
? edit.listViewTypeId
|
||||||
|
: (selectableListTypes[0]?.id ?? '');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||||
@@ -495,12 +463,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
e.currentTarget.value = id ?? '';
|
e.currentTarget.value = id ?? '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
|
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
||||||
setEdit(v); onDirty(b.id, v);
|
setEdit(v); onDirty(b.id, v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">(없음)</option>
|
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||||
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
|
||||||
<option value="__add__">+ 새 타입 추가…</option>
|
<option value="__add__">+ 새 타입 추가…</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -515,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
e.currentTarget.value = id ?? '';
|
e.currentTarget.value = id ?? '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const v = { ...edit, listViewTypeId: e.target.value || null };
|
const v = { ...edit, listViewTypeId: e.target.value };
|
||||||
setEdit(v); onDirty(b.id, v);
|
setEdit(v); onDirty(b.id, v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">(없음)</option>
|
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||||
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
|
||||||
<option value="__add__">+ 새 타입 추가…</option>
|
<option value="__add__">+ 새 타입 추가…</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -542,13 +508,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.type} onChange={(e) => { const v = { ...edit, type: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
|
||||||
<option value="general">general</option>
|
|
||||||
<option value="special">special</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
{allowMove && categories && onMove ? (
|
{allowMove && categories && onMove ? (
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
@@ -568,44 +527,40 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableRow({ catId, boardId, index, children, setRef, onStart, onEnd }: { catId: string; boardId: string; index: number; children: React.ReactNode; setRef: (el: HTMLTableRowElement | null) => void; onStart: () => void; onEnd: () => void }) {
|
function DraggableRow({ catId, boardId, index, totalCount, children, onMoveIndex }: { catId: string; boardId: string; index: number; totalCount: number; children: React.ReactNode; onMoveIndex: (delta: number) => void }) {
|
||||||
return (
|
return (
|
||||||
<tr ref={setRef} className="align-middle select-none">
|
<tr className="align-middle select-none">
|
||||||
<td
|
|
||||||
className="px-2 py-2 w-8 text-xl text-neutral-500 cursor-grab"
|
|
||||||
title="드래그하여 순서 변경"
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => {
|
|
||||||
e.dataTransfer.setData("text/plain", String(index));
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
|
|
||||||
onStart();
|
|
||||||
}}
|
|
||||||
onDragEnd={() => { onEnd(); }}
|
|
||||||
>≡</td>
|
|
||||||
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
||||||
|
<td className="px-2 py-2 w-16 text-center">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
||||||
|
aria-label="위로"
|
||||||
|
onClick={() => onMoveIndex(-1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>↑</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
||||||
|
aria-label="아래로"
|
||||||
|
onClick={() => onMoveIndex(1)}
|
||||||
|
disabled={index === (totalCount - 1)}
|
||||||
|
>↓</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
{children}
|
{children}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryHeaderContent({ g, onDirty, onDragStart }: { g: any; onDirty: (payload: any) => void; onDragStart?: () => void }) {
|
function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any) => void }) {
|
||||||
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className="w-10" />
|
||||||
className="w-10 text-xl text-neutral-500 cursor-grab select-none"
|
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||||
draggable
|
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||||
onDragStart={(e) => {
|
|
||||||
e.dataTransfer.setData("text/plain", "category-drag");
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
|
|
||||||
onDragStart && onDragStart();
|
|
||||||
}}
|
|
||||||
title="드래그하여 순서 변경"
|
|
||||||
>≡</div>
|
|
||||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
|
||||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import AdminSidebar from "@/app/admin/AdminSidebar";
|
import AdminSidebar from "@/app/admin/AdminSidebar";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Admin | ASSM",
|
title: "Admin | ASSM",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
// 서버에서 쿠키 기반 접근 제어 (미들웨어 보조)
|
||||||
|
const h = await headers();
|
||||||
|
const cookieHeader = h.get("cookie") || "";
|
||||||
|
const uid = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.find((pair) => pair.startsWith("uid="))
|
||||||
|
?.split("=")[1];
|
||||||
|
const isAdmin = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.find((pair) => pair.startsWith("isAdmin="))
|
||||||
|
?.split("=")[1];
|
||||||
|
if (!uid) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
if (isAdmin !== "1") {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-0px)] flex">
|
<div className="min-h-[calc(100vh-0px)] flex">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export default function MainPageSettingsPage() {
|
|||||||
visibleBoardIds: settings.visibleBoardIds ?? [],
|
visibleBoardIds: settings.visibleBoardIds ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function moveVisibleBoard(index: number, delta: number) {
|
||||||
|
setDraft((d) => {
|
||||||
|
const arr = [...(d.visibleBoardIds as string[])];
|
||||||
|
const nextIndex = index + delta;
|
||||||
|
if (nextIndex < 0 || nextIndex >= arr.length) return d;
|
||||||
|
const [item] = arr.splice(index, 1);
|
||||||
|
arr.splice(nextIndex, 0, item);
|
||||||
|
return { ...d, visibleBoardIds: arr };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// settings가 로드되면 draft 동기화
|
// settings가 로드되면 draft 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsResp?.settings) {
|
if (settingsResp?.settings) {
|
||||||
@@ -110,7 +121,7 @@ export default function MainPageSettingsPage() {
|
|||||||
{draft.visibleBoardIds.length === 0 ? (
|
{draft.visibleBoardIds.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-400 py-4 text-center">선택된 게시판이 없습니다.</p>
|
<p className="text-sm text-neutral-400 py-4 text-center">선택된 게시판이 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
draft.visibleBoardIds.map((boardId: string) => {
|
draft.visibleBoardIds.map((boardId: string, idx: number) => {
|
||||||
const board = boards.find((b: any) => b.id === boardId);
|
const board = boards.find((b: any) => b.id === boardId);
|
||||||
if (!board) return null;
|
if (!board) return null;
|
||||||
const category = categories.find((c: any) => c.id === board.categoryId);
|
const category = categories.find((c: any) => c.id === board.categoryId);
|
||||||
@@ -122,15 +133,37 @@ export default function MainPageSettingsPage() {
|
|||||||
<span className="text-xs text-neutral-500">({category.name})</span>
|
<span className="text-xs text-neutral-500">({category.name})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => {
|
<button
|
||||||
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) });
|
type="button"
|
||||||
}}
|
onClick={() => moveVisibleBoard(idx, -1)}
|
||||||
className="text-xs text-red-600 hover:text-red-800"
|
disabled={idx === 0}
|
||||||
>
|
className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
|
||||||
삭제
|
aria-label="위로"
|
||||||
</button>
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveVisibleBoard(idx, 1)}
|
||||||
|
disabled={idx === (draft.visibleBoardIds.length - 1)}
|
||||||
|
className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
|
||||||
|
aria-label="아래로"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) });
|
||||||
|
}}
|
||||||
|
className="h-7 px-3 rounded-md border border-red-200 text-xs text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type MenuItem = { id: string; label: string; path: string; visible: boolean; order: number };
|
|
||||||
|
|
||||||
const initialMenus: MenuItem[] = [
|
|
||||||
{ id: "1", label: "홈", path: "/", visible: true, order: 1 },
|
|
||||||
{ id: "2", label: "게시판", path: "/boards", visible: true, order: 2 },
|
|
||||||
{ id: "3", label: "쿠폰", path: "/coupons", visible: false, order: 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminMenusPage() {
|
|
||||||
const [menus, setMenus] = useState<MenuItem[]>(initialMenus);
|
|
||||||
const [form, setForm] = useState<{ label: string; path: string; visible: boolean }>({ label: "", path: "", visible: true });
|
|
||||||
|
|
||||||
function addMenu() {
|
|
||||||
if (!form.label.trim() || !form.path.trim()) return;
|
|
||||||
const next: MenuItem = { id: crypto.randomUUID(), label: form.label, path: form.path, visible: form.visible, order: menus.length + 1 };
|
|
||||||
setMenus((m) => [...m, next]);
|
|
||||||
setForm({ label: "", path: "", visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMenu(id: string) {
|
|
||||||
setMenus((m) => m.filter((x) => x.id !== id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVisible(id: string) {
|
|
||||||
setMenus((m) => m.map((x) => (x.id === id ? { ...x, visible: !x.visible } : x)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<header className="flex items-center justify-between">
|
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">메뉴 관리</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 추가 폼 */}
|
|
||||||
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
|
|
||||||
<div className="px-4 py-3 border-b border-neutral-200 text-sm font-medium">메뉴 추가</div>
|
|
||||||
<div className="p-4 grid grid-cols-1 md:grid-cols-[240px_1fr_auto] gap-3 items-center">
|
|
||||||
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="이름" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} />
|
|
||||||
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="경로 (/path)" value={form.path} onChange={(e) => setForm({ ...form, path: e.target.value })} />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="flex items-center gap-1 text-sm text-neutral-700">
|
|
||||||
<input type="checkbox" checked={form.visible} onChange={(e) => setForm({ ...form, visible: e.target.checked })} /> 표시
|
|
||||||
</label>
|
|
||||||
<button className="h-10 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800" onClick={addMenu}>추가</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 목록 */}
|
|
||||||
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
|
|
||||||
<div className="px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200 grid grid-cols-[60px_1fr_1fr_120px_120px]">
|
|
||||||
<div>#</div>
|
|
||||||
<div>이름</div>
|
|
||||||
<div>경로</div>
|
|
||||||
<div className="text-center">표시</div>
|
|
||||||
<div className="text-right">관리</div>
|
|
||||||
</div>
|
|
||||||
<ul className="divide-y divide-neutral-100">
|
|
||||||
{menus.sort((a, b) => a.order - b.order).map((m, idx) => (
|
|
||||||
<li key={m.id} className="px-4 py-3 grid grid-cols-[60px_1fr_1fr_120px_120px] items-center">
|
|
||||||
<div className="text-sm text-neutral-500">{idx + 1}</div>
|
|
||||||
<div className="truncate text-sm">{m.label}</div>
|
|
||||||
<div className="truncate text-sm text-neutral-700">{m.path}</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<button onClick={() => toggleVisible(m.id)} className={`h-7 px-3 rounded-full text-xs border ${m.visible ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"}`}>{m.visible ? "표시" : "숨김"}</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<button onClick={() => removeMenu(m.id)} className="h-7 px-3 rounded-md border border-neutral-300 text-xs hover:bg-neutral-100">삭제</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
45
src/app/admin/partner-shops/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function AdminPartnerShopsPage() {
|
||||||
|
const { data, mutate } = useSWR<{ items: any[] }>("/api/admin/partner-shops", fetcher);
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const [form, setForm] = useState({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 });
|
||||||
|
async function create() {
|
||||||
|
const r = await fetch("/api/admin/partner-shops", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
||||||
|
if (r.ok) { setForm({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 }); mutate(); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>제휴 샵 관리</h1>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
|
||||||
|
<input placeholder="지역" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||||
|
<input placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<input placeholder="주소" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} style={{ width: 320 }} />
|
||||||
|
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} />
|
||||||
|
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성</label>
|
||||||
|
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
|
||||||
|
<button onClick={create}>추가</button>
|
||||||
|
</div>
|
||||||
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
|
<img src={it.imageUrl} alt={it.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div><strong>{it.name}</strong> <span style={{ marginLeft: 8, fontSize: 12, opacity: .7 }}>{it.region}</span></div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.7 }}>{it.address}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {it.sortOrder} · {it.active ? "활성" : "비활성"}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !it.active }) }); mutate(); }}>{it.active ? "비활성" : "활성"}</button>
|
||||||
|
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "DELETE" }); mutate(); }}>삭제</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
373
src/app/admin/partners/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"use client";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Modal } from "@/app/components/ui/Modal";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function AdminPartnersPage() {
|
||||||
|
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
||||||
|
const { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher);
|
||||||
|
const partners = data?.partners ?? [];
|
||||||
|
const categories = catData?.categories ?? [];
|
||||||
|
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [editUploading, setEditUploading] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editDraft, setEditDraft] = useState<any>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
|
||||||
|
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const inputEl = e.currentTarget;
|
||||||
|
const file = inputEl.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||||
|
const json = await r.json();
|
||||||
|
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
||||||
|
setForm((f) => ({ ...f, imageUrl: json.url }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("이미지 업로드 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (inputEl) inputEl.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelectEditFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const inputEl = e.currentTarget;
|
||||||
|
const file = inputEl.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setEditUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||||
|
const json = await r.json();
|
||||||
|
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
||||||
|
setEditDraft((d: any) => ({ ...(d || {}), imageUrl: json.url }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("이미지 업로드 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setEditUploading(false);
|
||||||
|
if (inputEl) inputEl.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
// 필수값 검증: 이름/카테고리/위도/경도
|
||||||
|
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||||
|
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lat = Number(form.latitude);
|
||||||
|
const lon = Number(form.longitude);
|
||||||
|
if (!isFinite(lat) || !isFinite(lon)) {
|
||||||
|
alert("위도/경도는 숫자여야 합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
|
||||||
|
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
|
if (r.ok) {
|
||||||
|
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||||
|
mutate();
|
||||||
|
setShowCreateModal(false);
|
||||||
|
} else {
|
||||||
|
let msg = "저장에 실패했습니다.";
|
||||||
|
try {
|
||||||
|
const j = await r.json();
|
||||||
|
msg = j?.message || j?.error || msg;
|
||||||
|
if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다.";
|
||||||
|
if (r.status === 400) msg = msg || "입력값을 확인해 주세요.";
|
||||||
|
} catch {}
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
|
<h1>제휴업체 관리</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
|
||||||
|
<div className="w-[720px] max-w-[90vw]">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-4">제휴업체 추가</h2>
|
||||||
|
{(() => {
|
||||||
|
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이름</label>
|
||||||
|
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||||
|
<select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
|
||||||
|
<option value="">(없음)</option>
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||||
|
<input style={inputStyle} value={form.latitude} onChange={(e) => setForm({ ...form, latitude: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>경도</label>
|
||||||
|
<input style={inputStyle} value={form.longitude} onChange={(e) => setForm({ ...form, longitude: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
업로드
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
|
||||||
|
{uploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>주소(선택)</label>
|
||||||
|
<input style={inputStyle} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{form.imageUrl && (
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
||||||
|
<img src={form.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={create}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal open={showEditModal} onClose={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}>
|
||||||
|
<div className="w-[720px] max-w-[90vw]">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-4">제휴업체 수정</h2>
|
||||||
|
{(() => {
|
||||||
|
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이름</label>
|
||||||
|
<input style={inputStyle} value={editDraft?.name ?? ""} onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||||
|
<select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
|
||||||
|
<option value="">(없음)</option>
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||||
|
<input style={inputStyle} value={editDraft?.latitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, latitude: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>경도</label>
|
||||||
|
<input style={inputStyle} value={editDraft?.longitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, longitude: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input style={inputStyle} value={editDraft?.imageUrl ?? ""} onChange={(e) => setEditDraft({ ...editDraft, imageUrl: e.target.value })} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editFileInputRef.current?.click()}
|
||||||
|
disabled={editUploading}
|
||||||
|
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
업로드
|
||||||
|
</button>
|
||||||
|
<input ref={editFileInputRef} type="file" accept="image/*" onChange={onSelectEditFile} style={{ display: "none" }} />
|
||||||
|
{editUploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>주소</label>
|
||||||
|
<input style={inputStyle} value={editDraft?.address ?? ""} onChange={(e) => setEditDraft({ ...editDraft, address: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{editDraft?.imageUrl && (
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
||||||
|
<img src={editDraft.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
|
||||||
|
if (!resp.ok) {
|
||||||
|
let msg = "저장에 실패했습니다.";
|
||||||
|
try {
|
||||||
|
const j = await resp.json();
|
||||||
|
msg = j?.message || j?.error || msg;
|
||||||
|
} catch {}
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
setEditDraft(null);
|
||||||
|
setShowEditModal(false);
|
||||||
|
mutate();
|
||||||
|
}}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{partners.map((p) => (
|
||||||
|
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr auto", gap: 8, alignItems: "center" }}>
|
||||||
|
<div style={{ width: 80 }}>
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img src={p.imageUrl} alt={p.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 80, height: 48, border: "1px solid #eee", borderRadius: 6, background: "#fafafa" }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{false ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||||
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "DELETE" }); mutate(); }}
|
||||||
|
className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="위로"
|
||||||
|
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) - 1 }) }); mutate(); }}
|
||||||
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="아래로"
|
||||||
|
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) + 1 }) }); mutate(); }}
|
||||||
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<hr style={{ margin: "24px 0" }} />
|
||||||
|
<h2 className="text-lg font-bold mb-2">카테고리 관리</h2>
|
||||||
|
<CategoryManager categories={categories} onChanged={mutateCategories} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
return (
|
||||||
|
<div className="border border-neutral-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
placeholder="카테고리 이름"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((c: any) => (
|
||||||
|
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
|
||||||
|
<span>{c.name}</span>
|
||||||
|
<button
|
||||||
|
title="삭제"
|
||||||
|
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
|
||||||
|
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,36 +1,81 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const { data, mutate } = useSWR<{ users: any[] }>(`/api/admin/users?q=${encodeURIComponent(q)}`, fetcher);
|
const [queryDraft, setQueryDraft] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const key = useMemo(() => (
|
||||||
|
`/api/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${pageSize}`
|
||||||
|
),[q, page, pageSize]);
|
||||||
|
const { data, mutate } = useSWR<{ total: number; page: number; pageSize: number; users: any[] }>(key, fetcher);
|
||||||
const users = data?.users ?? [];
|
const users = data?.users ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const handleSearch = () => {
|
||||||
|
const term = queryDraft.trim();
|
||||||
|
if (!term) { setQ(""); setPage(1); return; }
|
||||||
|
setQ(term);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>사용자 관리</h1>
|
<h1>사용자 관리</h1>
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<div className="mb-3 rounded-xl border border-neutral-300 bg-white p-3 flex items-center gap-2">
|
||||||
<input placeholder="검색(nickname/phone/name)" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input
|
||||||
|
className="h-9 w-full max-w-[360px] px-3 rounded-md border border-neutral-300 bg-white text-sm"
|
||||||
|
placeholder="검색 (닉네임/전화/이름)"
|
||||||
|
value={queryDraft}
|
||||||
|
onChange={(e) => setQueryDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={false}
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-xs text-neutral-600">총 {total.toLocaleString()}명</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">닉네임</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">이름</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">전화</th>
|
||||||
|
<th className="px-4 py-2 text-right text-[12px] text-[#8c8c8c]">포인트</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">레벨</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">등급</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">상태</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">권한</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#ececec] bg-white">
|
||||||
|
{users.map((u) => (
|
||||||
|
<Row key={u.userId} u={u} onChanged={mutate} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>이전</button>
|
||||||
|
<span style={{ fontSize: 12 }}>페이지 {page} / {totalPages}</span>
|
||||||
|
<button disabled={page >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>다음</button>
|
||||||
|
<span style={{ marginLeft: 12, fontSize: 12 }}>페이지 크기</span>
|
||||||
|
<select value={pageSize} onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}>
|
||||||
|
{[10, 20, 50, 100].map((s) => (<option key={s} value={s}>{s}</option>))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>닉네임</th>
|
|
||||||
<th>이름</th>
|
|
||||||
<th>전화</th>
|
|
||||||
<th>상태</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<Row key={u.userId} u={u} onChanged={mutate} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,25 +90,31 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
|||||||
}
|
}
|
||||||
const allRoles = ["admin", "editor", "user"] as const;
|
const allRoles = ["admin", "editor", "user"] as const;
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr className="hover:bg-neutral-50">
|
||||||
<td>{u.nickname}</td>
|
<td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
|
||||||
<td>{u.name}</td>
|
<td className="px-4 py-2 text-left">{u.nickname}</td>
|
||||||
<td>{u.phone}</td>
|
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||||
<td>
|
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
<td className="px-4 py-2 text-right tabular-nums">{(u.points ?? 0).toLocaleString()}</td>
|
||||||
<option value="active">active</option>
|
<td className="px-4 py-2 text-center">{u.level ?? 1}</td>
|
||||||
<option value="suspended">suspended</option>
|
<td className="px-4 py-2 text-center">{u.grade ?? 0}</td>
|
||||||
<option value="withdrawn">withdrawn</option>
|
<td className="px-4 py-2 text-center">
|
||||||
|
<select className="h-8 px-2 border border-neutral-300 rounded-md bg-white text-sm" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
|
<option value="active">활성</option>
|
||||||
|
<option value="suspended">정지</option>
|
||||||
|
<option value="withdrawn">탈퇴</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="px-4 py-2">
|
||||||
{allRoles.map((r) => (
|
{allRoles.map((r) => (
|
||||||
<label key={r} style={{ marginRight: 8 }}>
|
<label key={r} className="mr-2">
|
||||||
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
|
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td><button onClick={save}>저장</button></td>
|
<td className="px-4 py-2 text-center">
|
||||||
|
<button className="h-8 px-3 rounded-md bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95" onClick={save}>저장</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -9,8 +10,11 @@ export async function GET() {
|
|||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
imageUrl: z.string().url(),
|
// 절대 URL 또는 /로 시작하는 상대경로 허용
|
||||||
linkUrl: z.string().url().optional(),
|
imageUrl: z.string().refine((v) => /^https?:\/\//.test(v) || v.startsWith("/"), {
|
||||||
|
message: "imageUrl must be absolute URL or start with /",
|
||||||
|
}),
|
||||||
|
linkUrl: z.string().refine((v) => !v || /^https?:\/\//.test(v), { message: "linkUrl must be http(s) URL" }).optional(),
|
||||||
active: z.boolean().optional(),
|
active: z.boolean().optional(),
|
||||||
sortOrder: z.coerce.number().int().optional(),
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
startAt: z.coerce.date().optional(),
|
startAt: z.coerce.date().optional(),
|
||||||
@@ -21,7 +25,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const banner = await prisma.banner.create({ data: parsed.data });
|
const banner = await prisma.banner.create({ data: parsed.data as Prisma.BannerCreateInput });
|
||||||
return NextResponse.json({ banner }, { status: 201 });
|
return NextResponse.json({ banner }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
|
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "status", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
|
||||||
if (k in body) data[k] = body[k];
|
if (k in body) data[k] = body[k];
|
||||||
}
|
}
|
||||||
if ("requiredTags" in body) {
|
if ("requiredTags" in body) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -16,8 +17,6 @@ export async function GET() {
|
|||||||
writeLevel: true,
|
writeLevel: true,
|
||||||
allowAnonymousPost: true,
|
allowAnonymousPost: true,
|
||||||
allowSecretComment: true,
|
allowSecretComment: true,
|
||||||
requiresApproval: true,
|
|
||||||
type: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
categoryId: true,
|
categoryId: true,
|
||||||
mainPageViewTypeId: true,
|
mainPageViewTypeId: true,
|
||||||
@@ -37,9 +36,7 @@ const createSchema = z.object({
|
|||||||
writeLevel: z.string().optional(),
|
writeLevel: z.string().optional(),
|
||||||
allowAnonymousPost: z.boolean().optional(),
|
allowAnonymousPost: z.boolean().optional(),
|
||||||
allowSecretComment: z.boolean().optional(),
|
allowSecretComment: z.boolean().optional(),
|
||||||
requiresApproval: z.boolean().optional(),
|
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
type: z.string().optional(),
|
|
||||||
isAdultOnly: z.boolean().optional(),
|
isAdultOnly: z.boolean().optional(),
|
||||||
categoryId: z.string().nullable().optional(),
|
categoryId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
@@ -58,7 +55,13 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
sortOrder = (max._max.sortOrder ?? 0) + 1;
|
sortOrder = (max._max.sortOrder ?? 0) + 1;
|
||||||
}
|
}
|
||||||
const created = await prisma.board.create({ data: { ...data, sortOrder } });
|
const { categoryId, sortOrder: _ignored, ...rest } = data;
|
||||||
|
const createData: Prisma.BoardCreateInput = {
|
||||||
|
...(rest as any),
|
||||||
|
sortOrder,
|
||||||
|
...(categoryId ? { category: { connect: { id: categoryId } } } : {}),
|
||||||
|
};
|
||||||
|
const created = await prisma.board.create({ data: createData });
|
||||||
return NextResponse.json({ board: created }, { status: 201 });
|
return NextResponse.json({ board: created }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const categories = await prisma.boardCategory.findMany({
|
const categories = await prisma.boardCategory.findMany({
|
||||||
@@ -20,7 +21,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const category = await prisma.boardCategory.create({ data: parsed.data });
|
const category = await prisma.boardCategory.create({ data: parsed.data as Prisma.BoardCategoryCreateInput });
|
||||||
return NextResponse.json({ category }, { status: 201 });
|
return NextResponse.json({ category }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/app/api/admin/partner-categories/[id]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const data: any = {};
|
||||||
|
for (const k of ["name", "sortOrder"]) if (k in body) data[k] = body[k];
|
||||||
|
try {
|
||||||
|
const category = await prisma.partnerCategory.update({ where: { id }, data });
|
||||||
|
return NextResponse.json({ category });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||||
|
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
try {
|
||||||
|
await prisma.partnerCategory.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
src/app/api/admin/partner-categories/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
|
||||||
|
return NextResponse.json({ categories });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({ name: z.string().min(1), sortOrder: z.coerce.number().int().optional() });
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } });
|
||||||
|
return NextResponse.json({ category }, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||||
|
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
21
src/app/api/admin/partner-shops/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const data: any = {};
|
||||||
|
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||||
|
if (k in body) data[k] = body[k];
|
||||||
|
}
|
||||||
|
const item = await prisma.partnerRequest.update({ where: { id }, data });
|
||||||
|
return NextResponse.json({ item });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
await prisma.partnerRequest.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
33
src/app/api/admin/partner-shops/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const rows = await prisma.partnerRequest.findMany({
|
||||||
|
where: { status: "approved" },
|
||||||
|
orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
|
||||||
|
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ items: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
region: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
address: z.string().min(1),
|
||||||
|
imageUrl: z.string().min(1),
|
||||||
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
// 통합: 승인된 레코드로 등록
|
||||||
|
const item = await prisma.partnerRequest.create({ data: { ...(parsed.data as any), status: "approved" } });
|
||||||
|
return NextResponse.json({ item }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
41
src/app/api/admin/partners/[id]/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const data: any = {};
|
||||||
|
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) {
|
||||||
|
if (k in body) data[k] = body[k];
|
||||||
|
}
|
||||||
|
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||||
|
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||||
|
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
||||||
|
// categoryId가 들어왔고 category 문자열이 비어있으면 카테고리명으로 채움
|
||||||
|
if (typeof data.categoryId !== "undefined" && (typeof data.category === "undefined" || data.category === null)) {
|
||||||
|
if (data.categoryId) {
|
||||||
|
const cat = await prisma.partnerCategory.findUnique({ where: { id: String(data.categoryId) } });
|
||||||
|
if (cat) data.category = cat.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const partner = await prisma.partner.update({ where: { id }, data });
|
||||||
|
return NextResponse.json({ partner });
|
||||||
|
} catch (e) {
|
||||||
|
// DB에 sortOrder 컬럼이 아직 없는 환경 대비: 해당 키 제거 후 재시도
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, "sortOrder")) {
|
||||||
|
const { sortOrder, ...rest } = data;
|
||||||
|
const partner = await prisma.partner.update({ where: { id }, data: rest });
|
||||||
|
return NextResponse.json({ partner });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
await prisma.partner.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
54
src/app/api/admin/partners/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 카테고리 조인 포함
|
||||||
|
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } });
|
||||||
|
return NextResponse.json({ partners });
|
||||||
|
} catch (_) {
|
||||||
|
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
||||||
|
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } });
|
||||||
|
return NextResponse.json({ partners });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
category: z.string().min(1).optional(),
|
||||||
|
latitude: z.coerce.number(),
|
||||||
|
longitude: z.coerce.number(),
|
||||||
|
address: z.string().min(1).optional(),
|
||||||
|
imageUrl: z
|
||||||
|
.string()
|
||||||
|
.refine((v) => !v || /^https?:\/\//.test(v) || v.startsWith("/"), {
|
||||||
|
message: "imageUrl must be http(s) URL or start with /",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
|
categoryId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const { categoryId } = parsed.data as any;
|
||||||
|
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId } });
|
||||||
|
if (!cat) return NextResponse.json({ error: 'invalid_category', message: '유효하지 않은 카테고리입니다.' }, { status: 400 });
|
||||||
|
const data: any = { ...parsed.data };
|
||||||
|
if (!data.category) data.category = cat.name;
|
||||||
|
const partner = await prisma.partner.create({ data });
|
||||||
|
return NextResponse.json({ partner }, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
// Unique name 에러 처리
|
||||||
|
if (e?.code === 'P2002') {
|
||||||
|
return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 업체명입니다.' }, { status: 409 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,34 +4,46 @@ import prisma from "@/lib/prisma";
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = searchParams.get("q") || "";
|
const q = searchParams.get("q") || "";
|
||||||
const users = await prisma.user.findMany({
|
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||||
where: q
|
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10)));
|
||||||
? {
|
const where = q
|
||||||
OR: [
|
? {
|
||||||
{ nickname: { contains: q } },
|
OR: [
|
||||||
{ phone: { contains: q } },
|
{ nickname: { contains: q } },
|
||||||
{ name: { contains: q } },
|
{ phone: { contains: q } },
|
||||||
],
|
{ name: { contains: q } },
|
||||||
}
|
],
|
||||||
: {},
|
}
|
||||||
orderBy: { createdAt: "desc" },
|
: {};
|
||||||
select: {
|
|
||||||
userId: true,
|
const [total, users] = await Promise.all([
|
||||||
nickname: true,
|
prisma.user.count({ where }),
|
||||||
name: true,
|
prisma.user.findMany({
|
||||||
phone: true,
|
where,
|
||||||
status: true,
|
orderBy: { createdAt: "desc" },
|
||||||
authLevel: true,
|
skip: (page - 1) * pageSize,
|
||||||
createdAt: true,
|
take: pageSize,
|
||||||
userRoles: { select: { role: { select: { name: true } } } },
|
select: {
|
||||||
},
|
userId: true,
|
||||||
take: 100,
|
nickname: true,
|
||||||
});
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
status: true,
|
||||||
|
authLevel: true,
|
||||||
|
createdAt: true,
|
||||||
|
points: true,
|
||||||
|
level: true,
|
||||||
|
grade: true,
|
||||||
|
userRoles: { select: { role: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const items = users.map((u) => ({
|
const items = users.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
roles: u.userRoles.map((r) => r.role.name),
|
roles: u.userRoles.map((r) => r.role.name),
|
||||||
}));
|
}));
|
||||||
return NextResponse.json({ users: items });
|
return NextResponse.json({ total, page, pageSize, users: items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||