Compare commits
65 Commits
27cf98eef2
...
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 |
@@ -1,17 +1,18 @@
|
||||
배너 디테일
|
||||
카드 디테일
|
||||
메인 디테일 프리뷰, 글, 스페셜_랭크
|
||||
|
||||
기본 리스트 , 글이 없습니다.
|
||||
글쓰기
|
||||
글뷰, 댓글 +리스트
|
||||
|
||||
x 메인 게시판 일반
|
||||
메인 게시판 프리뷰
|
||||
x 메인 게시판 스페셜랭크
|
||||
|
||||
기본 리스트
|
||||
스페셜_랭크
|
||||
스페셜_출석
|
||||
스페셜_제휴업체
|
||||
스페셜_제휴업체지도
|
||||
|
||||
게시글 뷰 + 댓글
|
||||
|
||||
로그인관련
|
||||
회원가입 페이지
|
||||
회원쪽지
|
||||
링크로들어오면 보이고 거기서 페이지 이동하면 안보이게
|
||||
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
|
||||
|
||||
*.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": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"start": "next start --port 3100",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:db:push": "prisma db push",
|
||||
"prisma:seed": "node prisma/seed.js",
|
||||
"prisma:erd": "prisma generate"
|
||||
"migrate": "prisma migrate dev",
|
||||
"studio": "prisma studio",
|
||||
"seed": "node prisma/seed.js",
|
||||
"dbforce": "prisma migrate reset --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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;
|
||||
@@ -24,10 +24,6 @@ enum BoardStatus {
|
||||
archived
|
||||
}
|
||||
|
||||
enum BoardType {
|
||||
general
|
||||
special
|
||||
}
|
||||
|
||||
enum AccessLevel {
|
||||
public // 비회원도 접근
|
||||
@@ -128,8 +124,6 @@ model Board {
|
||||
description String?
|
||||
sortOrder Int @default(0)
|
||||
status BoardStatus @default(active)
|
||||
type BoardType @default(general) // 일반/특수
|
||||
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
|
||||
allowAnonymousPost Boolean @default(false) // 익명 글 허용
|
||||
allowSecretComment Boolean @default(false) // 비밀댓글 허용
|
||||
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
|
||||
@@ -156,7 +150,6 @@ model Board {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, sortOrder])
|
||||
@@index([type, requiresApproval])
|
||||
@@index([categoryId])
|
||||
@@index([mainPageViewTypeId])
|
||||
@@index([listViewTypeId])
|
||||
@@ -224,6 +217,7 @@ model Post {
|
||||
reports Report[]
|
||||
stat PostStat?
|
||||
viewLogs PostViewLog[]
|
||||
dailyViews DailyPostView[]
|
||||
|
||||
@@index([boardId, status, createdAt])
|
||||
@@index([boardId, isPinned, pinnedOrder])
|
||||
@@ -232,13 +226,17 @@ model Post {
|
||||
|
||||
// 사용자
|
||||
model User {
|
||||
userId String @id @default(cuid())
|
||||
userId String @id
|
||||
nickname String @unique
|
||||
passwordHash String?
|
||||
name String
|
||||
birth DateTime
|
||||
phone String @unique
|
||||
birth DateTime?
|
||||
phone String? @unique
|
||||
rank Int @default(0)
|
||||
// 누적 포인트, 레벨, 등급(0~10)
|
||||
points Int @default(0)
|
||||
level Int @default(1)
|
||||
grade Int @default(0)
|
||||
|
||||
status UserStatus @default(active)
|
||||
authLevel AuthLevel @default(USER)
|
||||
@@ -262,6 +260,7 @@ model User {
|
||||
blocksInitiated Block[] @relation("Blocker")
|
||||
blocksReceived Block[] @relation("Blocked")
|
||||
pointTxns PointTransaction[]
|
||||
attendances Attendance[]
|
||||
sanctions Sanction[]
|
||||
nicknameChanges NicknameChange[]
|
||||
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
||||
@@ -327,6 +326,8 @@ model UserRole {
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
|
||||
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
|
||||
authorId String?
|
||||
content String
|
||||
isAnonymous Boolean @default(false)
|
||||
@@ -336,10 +337,13 @@ model Comment {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
replies Comment[] @relation("CommentReplies")
|
||||
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
||||
reports Report[]
|
||||
|
||||
@@index([postId, createdAt])
|
||||
@@index([parentId])
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
@@ -435,6 +439,21 @@ model PostStat {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
@@ -504,6 +523,20 @@ model PointTransaction {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
@@ -672,16 +705,36 @@ model Partner {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
category String
|
||||
categoryId String?
|
||||
latitude Float
|
||||
longitude Float
|
||||
address String?
|
||||
imageUrl String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@index([category])
|
||||
@@index([categoryId])
|
||||
@@index([sortOrder])
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
@@ -724,6 +777,10 @@ model PartnerRequest {
|
||||
longitude Float
|
||||
address String?
|
||||
contact String?
|
||||
region String?
|
||||
imageUrl String?
|
||||
sortOrder Int @default(0)
|
||||
active Boolean @default(true)
|
||||
status String @default("pending") // pending/approved/rejected
|
||||
createdAt DateTime @default(now())
|
||||
approvedAt DateTime?
|
||||
@@ -742,3 +799,6 @@ model Setting {
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)
|
||||
|
||||
512
prisma/seed.js
@@ -1,17 +1,185 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { createHash } = require("crypto");
|
||||
|
||||
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() {
|
||||
// 카테고리 트리 (projectmemo 기준 상위 그룹)
|
||||
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: "nearby-partners", 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" },
|
||||
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
|
||||
];
|
||||
const map = {};
|
||||
for (const c of categories) {
|
||||
@@ -84,14 +252,24 @@ async function upsertRoles() {
|
||||
async function upsertAdmin() {
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { nickname: "admin" },
|
||||
update: {},
|
||||
update: {
|
||||
passwordHash: hashPassword("12341234"),
|
||||
grade: 7,
|
||||
points: 1650000,
|
||||
level: 200,
|
||||
},
|
||||
create: {
|
||||
userId: "admin",
|
||||
nickname: "admin",
|
||||
name: "Administrator",
|
||||
birth: new Date("1990-01-01"),
|
||||
phone: "010-0000-0001",
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "ADMIN",
|
||||
grade: 7,
|
||||
points: 1650000,
|
||||
level: 200,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,34 +286,82 @@ async function upsertAdmin() {
|
||||
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) {
|
||||
const boards = [
|
||||
// 일반
|
||||
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1, writeLevel: "moderator" },
|
||||
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 },
|
||||
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 },
|
||||
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
|
||||
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 },
|
||||
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
|
||||
{ name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 },
|
||||
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
|
||||
{ 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: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" },
|
||||
{ name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 },
|
||||
{ name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 },
|
||||
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
|
||||
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
|
||||
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
|
||||
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
|
||||
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
|
||||
// 특수
|
||||
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
|
||||
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
|
||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
|
||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
|
||||
{ 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 } },
|
||||
{ name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 },
|
||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
|
||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
|
||||
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
|
||||
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
|
||||
];
|
||||
|
||||
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) {
|
||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||
const mapBySlug = {
|
||||
@@ -149,22 +375,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
ranking: "hall-of-fame",
|
||||
"free-coupons": "hall-of-fame",
|
||||
"monthly-stats": "hall-of-fame",
|
||||
// 주변 제휴업체
|
||||
"nearby-partners": "nearby-partners",
|
||||
// 제휴업소 정보
|
||||
"partners-photos": "partner-info",
|
||||
// 방문후기
|
||||
reviews: "reviews",
|
||||
// 소통방(기본값 community로 처리)
|
||||
free: "community",
|
||||
qna: "community",
|
||||
tips: "community",
|
||||
anonymous: "community",
|
||||
"find-therapist": "community",
|
||||
"blue-house": "community",
|
||||
// 광고/제휴
|
||||
"partner-contact": "ads-affiliates",
|
||||
"partner-req": "ads-affiliates",
|
||||
};
|
||||
const categorySlug = mapBySlug[b.slug] || "community";
|
||||
const category = categoryMap[categorySlug];
|
||||
@@ -173,22 +389,30 @@ async function upsertBoards(admin, categoryMap) {
|
||||
update: {
|
||||
description: b.description,
|
||||
sortOrder: b.sortOrder,
|
||||
type: b.type,
|
||||
requiresApproval: !!b.requiresApproval,
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || 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: {
|
||||
name: b.name,
|
||||
slug: b.slug,
|
||||
description: b.description,
|
||||
sortOrder: b.sortOrder,
|
||||
type: b.type,
|
||||
requiresApproval: !!b.requiresApproval,
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || 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);
|
||||
@@ -209,16 +433,15 @@ async function upsertBoards(admin, categoryMap) {
|
||||
|
||||
async function upsertViewTypes() {
|
||||
const viewTypes = [
|
||||
// main scope
|
||||
{ key: "main_default", name: "기본", scope: "main" },
|
||||
// main scope (기본/없음 제거, 텍스트 중심)
|
||||
{ key: "main_text", name: "텍스트", scope: "main" },
|
||||
{ key: "main_preview", name: "미리보기", scope: "main" },
|
||||
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
||||
// list scope
|
||||
{ key: "list_default", name: "기본", scope: "list" },
|
||||
// list scope (기본/없음 제거, 텍스트 중심)
|
||||
{ key: "list_text", name: "텍스트", scope: "list" },
|
||||
{ key: "list_preview", name: "미리보기", scope: "list" },
|
||||
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
||||
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
|
||||
];
|
||||
for (const vt of viewTypes) {
|
||||
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() {
|
||||
// 금칙어 예시
|
||||
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() {
|
||||
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();
|
||||
const admin = await upsertAdmin();
|
||||
const categoryMap = await upsertCategories();
|
||||
await upsertViewTypes();
|
||||
const randomUsers = await createRandomUsers(3);
|
||||
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
|
||||
await removeNonPrimaryBoards();
|
||||
const boards = await upsertBoards(admin, categoryMap);
|
||||
|
||||
// 샘플 글 하나
|
||||
const free = boards.find((b) => b.slug === "free") || boards[0];
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
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 seedAdminAttendance(admin);
|
||||
await seedMainpageVisibleBoards(boards);
|
||||
await createPostsForAllBoards(boards, 100, admin);
|
||||
await seedPartnerShops();
|
||||
await seedBanners();
|
||||
|
||||
await seedPolicies();
|
||||
|
||||
// 제휴업체 예시 데이터
|
||||
const partners = [
|
||||
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 중구" },
|
||||
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
||||
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, 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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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/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 |
@@ -7,6 +7,7 @@ const navItems = [
|
||||
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
||||
{ href: "/admin/boards", label: "게시판" },
|
||||
{ href: "/admin/banners", label: "배너" },
|
||||
{ href: "/admin/partners", label: "제휴업체" },
|
||||
{ href: "/admin/users", label: "사용자" },
|
||||
{ href: "/admin/logs", label: "로그" },
|
||||
];
|
||||
@@ -14,7 +15,7 @@ const navItems = [
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
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">
|
||||
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
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());
|
||||
|
||||
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
|
||||
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
|
||||
const banners = data?.banners ?? [];
|
||||
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() {
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<h1>배너 관리</h1>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
||||
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
|
||||
<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>
|
||||
<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.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 }}>
|
||||
{banners.map((b) => (
|
||||
<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 style={{ fontSize: 12, opacity: 0.7 }}>정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}</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: "DELETE" }); mutate(); }}>삭제</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(); }} 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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function AdminBoardsPage() {
|
||||
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 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 merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
@@ -237,7 +239,7 @@ export default function AdminBoardsPage() {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -368,8 +370,6 @@ 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>
|
||||
@@ -438,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 }) {
|
||||
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 (
|
||||
<>
|
||||
<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 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-[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 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">
|
||||
<select
|
||||
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||
@@ -455,12 +463,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -475,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, listViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, listViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -502,13 +508,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
</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.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>
|
||||
{allowMove && categories && onMove ? (
|
||||
<td className="px-3 py-2 text-center">
|
||||
@@ -560,8 +559,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any)
|
||||
return (
|
||||
<>
|
||||
<div className="w-10" />
|
||||
<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); }} />
|
||||
<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); }} />
|
||||
<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); }} />
|
||||
<div className="flex-1" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import AdminSidebar from "@/app/admin/AdminSidebar";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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 (
|
||||
<div className="min-h-[calc(100vh-0px)] flex">
|
||||
<AdminSidebar />
|
||||
|
||||
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,37 +1,82 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
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 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 (
|
||||
<div>
|
||||
<h1>사용자 관리</h1>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input placeholder="검색(nickname/phone/name)" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="mb-3 rounded-xl border border-neutral-300 bg-white p-3 flex items-center gap-2">
|
||||
<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>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
|
||||
<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>닉네임</th>
|
||||
<th>이름</th>
|
||||
<th>전화</th>
|
||||
<th>상태</th>
|
||||
<th>권한</th>
|
||||
<th></th>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,25 +90,31 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
||||
}
|
||||
const allRoles = ["admin", "editor", "user"] as const;
|
||||
return (
|
||||
<tr>
|
||||
<td>{u.nickname}</td>
|
||||
<td>{u.name}</td>
|
||||
<td>{u.phone}</td>
|
||||
<td>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="active">active</option>
|
||||
<option value="suspended">suspended</option>
|
||||
<option value="withdrawn">withdrawn</option>
|
||||
<tr className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
|
||||
<td className="px-4 py-2 text-left">{u.nickname}</td>
|
||||
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{(u.points ?? 0).toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-center">{u.level ?? 1}</td>
|
||||
<td className="px-4 py-2 text-center">{u.grade ?? 0}</td>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<td className="px-4 py-2">
|
||||
{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}
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
@@ -9,8 +10,11 @@ export async function GET() {
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
imageUrl: z.string().url(),
|
||||
linkUrl: z.string().url().optional(),
|
||||
// 절대 URL 또는 /로 시작하는 상대경로 허용
|
||||
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(),
|
||||
sortOrder: z.coerce.number().int().optional(),
|
||||
startAt: z.coerce.date().optional(),
|
||||
@@ -21,7 +25,7 @@ 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 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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
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 ("requiredTags" in body) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
@@ -16,8 +17,6 @@ export async function GET() {
|
||||
writeLevel: true,
|
||||
allowAnonymousPost: true,
|
||||
allowSecretComment: true,
|
||||
requiresApproval: true,
|
||||
type: true,
|
||||
status: true,
|
||||
categoryId: true,
|
||||
mainPageViewTypeId: true,
|
||||
@@ -37,9 +36,7 @@ const createSchema = z.object({
|
||||
writeLevel: z.string().optional(),
|
||||
allowAnonymousPost: z.boolean().optional(),
|
||||
allowSecretComment: z.boolean().optional(),
|
||||
requiresApproval: z.boolean().optional(),
|
||||
status: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
isAdultOnly: z.boolean().optional(),
|
||||
categoryId: z.string().nullable().optional(),
|
||||
});
|
||||
@@ -58,7 +55,13 @@ export async function POST(req: Request) {
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 categories = await prisma.boardCategory.findMany({
|
||||
@@ -20,7 +21,7 @@ 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 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 });
|
||||
}
|
||||
|
||||
|
||||
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,8 +4,9 @@ import prisma from "@/lib/prisma";
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q") || "";
|
||||
const users = await prisma.user.findMany({
|
||||
where: q
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10)));
|
||||
const where = q
|
||||
? {
|
||||
OR: [
|
||||
{ nickname: { contains: q } },
|
||||
@@ -13,8 +14,15 @@ export async function GET(req: Request) {
|
||||
{ name: { contains: q } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
: {};
|
||||
|
||||
const [total, users] = await Promise.all([
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
userId: true,
|
||||
nickname: true,
|
||||
@@ -23,15 +31,19 @@ export async function GET(req: Request) {
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdAt: true,
|
||||
points: true,
|
||||
level: true,
|
||||
grade: true,
|
||||
userRoles: { select: { role: { select: { name: true } } } },
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const items = users.map((u) => ({
|
||||
...u,
|
||||
roles: u.userRoles.map((r) => r.role.name),
|
||||
}));
|
||||
return NextResponse.json({ users: items });
|
||||
return NextResponse.json({ total, page, pageSize, users: items });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] });
|
||||
@@ -19,7 +20,7 @@ export async function POST(req: Request) {
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } });
|
||||
if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 });
|
||||
const created = await prisma.boardViewType.create({ data: parsed.data });
|
||||
const created = await prisma.boardViewType.create({ data: parsed.data as Prisma.BoardViewTypeCreateInput });
|
||||
return NextResponse.json({ item: created }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
77
src/app/api/attendance/me-stats/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ total: 0, currentStreak: 0, maxStreak: 0 });
|
||||
|
||||
// 총 출석일
|
||||
const total = await prisma.attendance.count({ where: { userId } });
|
||||
|
||||
// 모든 출석일(UTC 자정 기준) 가져와서 streak 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: { userId },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = Array.from(new Set(rows.map((r) => toYmdUTC(new Date(r.date))))); // unique, asc
|
||||
|
||||
// 현재 연속 출석
|
||||
let currentStreak = 0;
|
||||
if (days.length > 0) {
|
||||
const set = new Set(days);
|
||||
const now = new Date();
|
||||
// 로컬 날짜(사용자 체감 날짜)를 UTC 자정으로 정규화하여 비교
|
||||
let cursor = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
let tried = 0;
|
||||
while (tried < 2 && currentStreak === 0) {
|
||||
const startYmd = toYmdUTC(cursor);
|
||||
if (!set.has(startYmd)) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
tried += 1;
|
||||
continue;
|
||||
}
|
||||
while (true) {
|
||||
const ymd = toYmdUTC(cursor);
|
||||
if (set.has(ymd)) {
|
||||
currentStreak += 1;
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 최대 연속 출석
|
||||
let maxStreak = 0;
|
||||
if (days.length > 0) {
|
||||
// scan through sorted list
|
||||
let localMax = 1;
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1] + "T00:00:00.000Z");
|
||||
const cur = new Date(days[i] + "T00:00:00.000Z");
|
||||
const diff = (cur.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000);
|
||||
if (diff === 1) {
|
||||
localMax += 1;
|
||||
} else if (diff > 1) {
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
localMax = 1;
|
||||
}
|
||||
}
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
}
|
||||
|
||||
return NextResponse.json({ total, currentStreak, maxStreak });
|
||||
}
|
||||
|
||||
|
||||
116
src/app/api/attendance/rankings/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const only = url.searchParams.get("only");
|
||||
|
||||
// 최대 연속 출석 상위 (Top 20, 전체 이력 기준)
|
||||
async function computeStreakTop() {
|
||||
// 전체 이력 기준으로 사용자별 최대 연속 출석을 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
select: { userId: true, date: true },
|
||||
orderBy: [{ userId: "asc" }, { date: "asc" }],
|
||||
});
|
||||
const maxByUser = new Map<string, number>();
|
||||
let currentUserId: string | null = null;
|
||||
let lastDayMs: number | null = null;
|
||||
let current = 0;
|
||||
let maxStreak = 0;
|
||||
const commit = () => {
|
||||
if (currentUserId) {
|
||||
maxByUser.set(currentUserId, Math.max(maxByUser.get(currentUserId) ?? 0, maxStreak));
|
||||
}
|
||||
};
|
||||
for (const r of rows) {
|
||||
if (r.userId !== currentUserId) {
|
||||
// flush previous
|
||||
if (currentUserId !== null) commit();
|
||||
// reset for new user
|
||||
currentUserId = r.userId;
|
||||
lastDayMs = null;
|
||||
current = 0;
|
||||
maxStreak = 0;
|
||||
}
|
||||
// UTC 일 단위로 비교
|
||||
const ymd = toYmdUTC(new Date(r.date));
|
||||
const ms = Date.parse(`${ymd}T00:00:00.000Z`);
|
||||
if (lastDayMs === null) {
|
||||
current = 1;
|
||||
} else {
|
||||
const diffDays = Math.round((ms - lastDayMs) / (24 * 60 * 60 * 1000));
|
||||
if (diffDays === 1) {
|
||||
current += 1;
|
||||
} else if (diffDays > 0) {
|
||||
current = 1;
|
||||
} else {
|
||||
// 동일/역순은 이례적이지만 안전하게 스킵
|
||||
current = Math.max(current, 1);
|
||||
}
|
||||
}
|
||||
if (current > maxStreak) maxStreak = current;
|
||||
lastDayMs = ms;
|
||||
}
|
||||
// flush last
|
||||
if (currentUserId !== null) commit();
|
||||
|
||||
const topStreak = Array.from(maxByUser.entries())
|
||||
.map(([userId, streak]) => ({ userId, streak }))
|
||||
.sort((a, b) => b.streak - a.streak)
|
||||
.slice(0, 20);
|
||||
const streakUserIds = topStreak.map((s) => s.userId);
|
||||
const streakUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: streakUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const streakMeta = new Map(streakUsers.map((u) => [u.userId, u]));
|
||||
const streak = topStreak.map((s) => ({
|
||||
userId: s.userId,
|
||||
nickname: streakMeta.get(s.userId)?.nickname ?? "회원",
|
||||
streak: s.streak,
|
||||
profileImage: streakMeta.get(s.userId)?.profileImage ?? null,
|
||||
grade: streakMeta.get(s.userId)?.grade ?? 0,
|
||||
}));
|
||||
return streak;
|
||||
}
|
||||
|
||||
if (only === "streak") {
|
||||
const streak = await computeStreakTop();
|
||||
return NextResponse.json({ streak });
|
||||
}
|
||||
|
||||
// 전체 출석 누적 상위 (Top 10)
|
||||
const overallGroups = await prisma.attendance.groupBy({
|
||||
by: ["userId"],
|
||||
_count: { date: true },
|
||||
orderBy: { _count: { date: "desc" } },
|
||||
take: 20,
|
||||
});
|
||||
const overallUserIds = overallGroups.map((g) => g.userId);
|
||||
const overallUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: overallUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
|
||||
const overall = overallGroups.map((g) => ({
|
||||
userId: g.userId,
|
||||
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
|
||||
count: g._count.date ?? 0,
|
||||
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
|
||||
grade: userMeta.get(g.userId)?.grade ?? 0,
|
||||
}));
|
||||
|
||||
const streak = await computeStreakTop();
|
||||
|
||||
return NextResponse.json({ overall, streak });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0 });
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0, days: [] });
|
||||
const url = new URL(req.url);
|
||||
const year = url.searchParams.get("year");
|
||||
const month = url.searchParams.get("month"); // 1-12
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const today = await prisma.pointTransaction.findFirst({
|
||||
where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } },
|
||||
const today = await prisma.attendance.findFirst({
|
||||
where: { userId, date: { gte: start, lte: end } },
|
||||
});
|
||||
const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } });
|
||||
const count = await prisma.attendance.count({ where: { userId } });
|
||||
// 월별 출석 일자 목록
|
||||
if (year && month) {
|
||||
const y = parseInt(year, 10);
|
||||
const m = parseInt(month, 10);
|
||||
if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) {
|
||||
const firstDay = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
|
||||
const lastDay = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
|
||||
const records = await prisma.attendance.findMany({
|
||||
where: { userId, date: { gte: firstDay, lte: lastDay } },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = records.map(r => {
|
||||
const d = new Date(r.date);
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
});
|
||||
return NextResponse.json({ today: !!today, count, days });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ today: !!today, count });
|
||||
}
|
||||
|
||||
@@ -19,9 +45,12 @@ export async function POST(req: Request) {
|
||||
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } });
|
||||
const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } });
|
||||
if (exists) return NextResponse.json({ ok: true, duplicated: true });
|
||||
await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } });
|
||||
// normalize to UTC midnight
|
||||
const now = new Date();
|
||||
const normalized = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
await prisma.attendance.create({ data: { userId, date: normalized } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||