info.md 23 KB

Piratezone — 프로젝트 정보

선상 낚시 예약 서비스의 사이트 관리자(Admin) 와 사용자 사이트.


🏗️ 기술 스택

프론트엔드 Nuxt 3 (Vue 3), SCSS, flatpickr (DatePicker)
백엔드 CodeIgniter 4 (PHP)
DB MySQL / MariaDB
폰트 Pretendard (로컬, woff2 + woff)
외부 API Daum 우편번호, Google Geocoding
암호화 CI4 Encryption (AES-256-CTR + HMAC-SHA512)

✅ 완료된 기능 (메뉴별)

🔐 인증 / 로그인

  • 페이지: /site-manager (로그인 폼, 좌우 50/50 레이아웃)
  • API: POST /api/auth/login, POST /api/auth/logout, GET /api/auth/check
  • 토큰 기반 인증, 비밀번호 변경 권장 모달, 계정 잠금 모달
  • 로그인 후 /site-manager/dashboard 로 이동

👤 관리자 관리

  • 테이블:
    • admin_users (id, username, password, password_changed_at, name, email, phone, role, status, login_attempts, last_failed_login, last_login, deleted_YN, created_at, updated_at)
    • admin_permissions (id, admin_id FK CASCADE, permission VARCHAR, UNIQUE(admin_id, permission)) — 1:N 메뉴 권한
  • 페이지: list / create / detail / edit (/site-manager/admin/...)
  • API:
    • GET /api/admin (검색 search_field=username/name/email + role/status 필터 + deleted=1로 삭제 관리자만 분기)
    • GET /api/admin/check-username (아이디 중복, soft 삭제된 계정 제외 → 재사용 가능)
    • GET /api/admin/:id (permissions 같이 응답)
    • POST /api/admin (생성 + permissions 동기화)
    • PUT /api/admin/:id (role/permissions 변경은 슈퍼관리자만 가드)
    • DELETE /api/admin/:id (soft delete + 토큰 무효화, permissions는 보존)
    • POST /api/admin/:id/restore (복구 — username/email 충돌 검사)
    • DELETE /api/admin/:id/hard (영구 삭제 — 이미 soft 삭제된 계정만, 본인 차단)
    • POST /api/admin/:id/password, POST /api/admin/:id/unlock
  • role enum: super_admin, admin (디폴트 admin)
  • status enum: active, inactive, suspended
  • 권한(permissions) enum: admin / field / fishing / challenge / quest / item / species / user (= admin.vue menuItems id와 동일)
    • 슈퍼관리자는 row 박지 않고 응답에 'all' 문자열로 반환
    • 일반 admin은 메뉴 id 배열로 저장/응답 (1개 이상 필수)
  • 주요 기능:
    • 검색(아이디/이름/이메일) + 권한·상태 필터 + 페이지네이션
    • 체크박스 일괄 액션: 선택 삭제 / 선택 활성 / 선택 휴면 / 선택 정지
    • 삭제 관리자 관리 모드 (슈퍼관리자 전용): 같은 페이지 토글 → deleted_YN='Y'만 보여줌 → 선택 복구 / 선택 영구 삭제
    • CSV 내보내기 (현재 필터 그대로 또는 선택된 행만, UTF-8 BOM)
    • 권한 칩 표시: 슈퍼관리자는 골드 뱃지, 일반 admin은 메뉴 칩 + +N 더보기 (list 최대 2개, detail 전체 표시)
    • 권한 기반 메뉴 분기 (useAuth) — 사이드바 메뉴 + URL 미들웨어 가드 (middleware/auth.js)
    • 본인/슈퍼관리자 보호: 체크박스 disabled, 수정/삭제 버튼 숨김, edit 페이지 진입 차단 (일반 admin이 슈퍼관리자 수정 시도 시 detail로 redirect)
    • 비밀번호 변경 모달 (8자+, 일치 검증)
    • 5회 실패 시 잠금 + 슈퍼관리자가 해제, 24시간 토큰
    • 본인 계정 삭제 방지, 본인 계정 edit 시 상태 row 숨김

🎣 낚시분야 관리 (Field)

  • 테이블: fishing_field (id, name, weight, status_YN, deleted_YN, created_at, updated_at)
  • 페이지: list / create / detail / edit
  • API: GET /api/field/list, GET/POST/PUT/DELETE /api/field/:id
  • 분야명 1~30자, 중복 불가
  • 가중치 0.0~1.0 (소수점 1자리 검증)
  • 상태(사용중/미사용) 토글, soft delete

🗺️ 낚시지역 관리 (Area)

  • 테이블: fishing_area (id, name, deleted_YN, created_at, updated_at)
  • 페이지: list / create / detail / edit / places (전체보기)
  • API:
    • GET /api/area/list, GET/POST/PUT/DELETE /api/area/:id
    • GET /api/area/:id/places — 해당 지역에 속한 onboard + fishing UNION ALL (필드명 JOIN), limit 모드(detail 미리보기 8개) / page 모드(전체보기 페이지네이션)
  • 지역명 1~20자, 중복 불가, soft delete
  • 상세 페이지 — 해당 지역의 낚시어선/낚시터 미니 테이블: 카운트 표시(🎣 낚시터 N / 🚢 낚시어선 M), 최근 등록순 8개 + 전체보기 버튼, 행 클릭 시 onboard/fishing 상세로 이동
  • 삭제 차단: 해당 지역에 연결된 onboard/fishing이 1건이라도 있으면 백엔드/프론트 모두에서 삭제 거부 (409 Conflict)
  • 전체보기 페이지 (/area/places/:id): 페이지네이션 + 6컬럼(번호/구분/이름/주소/상태/등록일) + 역순 번호 표시

⚓ 선상 관리 (Onboard)

  • 테이블: onboard (id, field_id, area_id, name, area_detail, tonnage, capacity, zip_code, address, address_detail, address_refer, lat, lng, bank_code, account_number(암호화), account_holder, partnership_YN, status_YN, deleted_YN, created_at, updated_at)
  • 테이블: onboard_photos (FK → onboard, ON DELETE CASCADE, hard delete)
  • 페이지: list / create / detail / edit
  • API:
    • GET /api/onboard/list (분야·지역 JOIN, 검색·필터·페이지네이션)
    • GET /api/onboard/:id (사진 목록 포함, 계좌번호 복호화)
    • POST /api/onboard (계좌번호 암호화)
    • PUT /api/onboard/:id (계좌번호 재암호화)
    • DELETE /api/onboard/:id (soft delete)
    • POST /api/onboard/:id/photos (다중 사진 업로드, MIME 검증)
    • DELETE /api/onboard/photo/:photoId (파일 + DB hard delete)
  • 주요 기능:
    • 분야/지역 select (옵션 역순 정렬 — 수도권 등 초기 등록이 위로)
    • Daum 우편번호 검색 + 선택 시 Google Geocoding 좌표 자동 입력
    • 계좌번호 양방향 암호화: 저장 시 암호화, 상세/수정에서 복호화된 평문 표시
    • 비제휴 선택 시 계좌 행 자동 숨김 + 값 초기화
    • 다중 사진 업로드: 미리보기, 개별 삭제, 첫 장 "대표" 뱃지, 10MB 제한
    • 수정에서 기존 사진 삭제 (즉시 서버 반영, 파일 + DB)
    • 검색 4종 (검색대상 select + 검색어 + 제휴 필터 + 상태 필터)
    • 기간 검색 (등록일, DatePicker + 빠른 버튼: 오늘/7일/15일/1개월/3개월/1년)
    • 행 클릭 → 상세, "수정" 버튼 → edit

⛵ 낚시터 관리 (Fishing)

  • 테이블: fishing (id, field_id, area_id, name, operating_hours, fish_species, zip_code, address, address_detail, address_refer, lat, lng, bank_code, account_number(암호화), account_holder, partnership_YN, status_YN, deleted_YN, created_at, updated_at)
  • 테이블: fishing_photos (FK → fishing, ON DELETE CASCADE, hard delete)
  • 페이지: list / create / detail / edit
  • API:
    • GET /api/fishing/list (분야·지역 JOIN, 검색·필터·페이지네이션)
    • GET /api/fishing/:id (사진 목록 포함, 계좌번호 복호화)
    • POST /api/fishing (계좌번호 암호화)
    • PUT /api/fishing/:id (계좌번호 재암호화)
    • DELETE /api/fishing/:id (soft delete)
    • POST /api/fishing/:id/photos (다중 사진 업로드, MIME 검증)
    • DELETE /api/fishing/photo/:photoId (파일 + DB hard delete)
  • 주요 기능:
    • 선상 관리와 동일한 패턴 (분야/지역 select, 우편번호 검색, 좌표 자동, 계좌 암호화, 사진 다중 업로드, 검색 4종 + 기간검색)
    • 낚시터 전용 컬럼: operating_hours (운영시간 자유 텍스트), fish_species (주요 어종, 콤마 구분 VARCHAR)
    • 어종 검색 지원: 검색대상 select에 "주요어종" 옵션 추가, fs.fish_species LIKE 매칭
    • 비제휴 선택 시 계좌 행 자동 숨김
    • 행 클릭 → 상세, "수정" 버튼 → edit

🎁 아이템 관리 (Item)

  • 테이블: item (id, name, type, point, file_name, file_path, status_YN, deleted_YN, created_at, updated_at)
  • 페이지: list / create / detail / edit
  • API:
    • GET /api/item/list (검색·구분 필터·페이지네이션)
    • GET /api/item/:id
    • POST /api/item (multipart — 이미지와 텍스트 한 번에)
    • PUT /api/item/:id (텍스트 필드 수정, JSON)
    • POST /api/item/:id/image (이미지 교체, multipart, 기존 파일 자동 삭제)
    • DELETE /api/item/:id/image (이미지 제거)
    • DELETE /api/item/:id (soft delete)
  • 주요 기능:
    • 구분 3종: T(진출권) / P(포인트) / B(뱃지)
    • 동적 폼: 진출권 선택 시 포인트 행 자동 숨김 + 값 초기화
    • 포인트 검증: P 필수, B 선택 입력, T 미사용
    • 단일 이미지 업로드: 미리보기 + 교체/제거 분리 처리 (수정 시 새 이미지 > 제거 > 변경없음 우선순위)
    • 검색: 아이템명 입력 + 구분 필터 라디오 버튼 그룹(전체/진출권/포인트/뱃지)
    • 행 클릭 → 상세, "수정" 버튼 → edit, soft delete

🐟 어종 관리 (Species)

세 개의 1뎁스 메뉴(어종구분 / 챌린지 어종관리 / 퀘스트 어종관리), 같은 코드 패턴 공유.

🐠 어종구분 (Species Type)

  • 테이블: species_type (id, name, sort_order, status_YN, deleted_YN, created_at)
  • 페이지: 단일 list (인라인 추가/수정/삭제 + 일괄 저장)
  • API: GET /api/species/list, POST /api/species/bulk-save (creates + updates + deletes 트랜잭션)
  • 구분명 1~30자, 정렬순서 INT

🏆 챌린지 어종관리 (Species Challenge)

  • 테이블: species_challenge (id, type_id NULL, name, min, max, round1_min/max ~ round5_min/max, deleted_YN, created_at)
  • 페이지: 단일 list (인라인 추가/수정/삭제 + 일괄 저장)
  • API: GET /api/species-challenge/list (species_type JOIN, 구분 필터 + 어종명 검색 + 기간 검색), POST /api/species-challenge/bulk-save
  • 검증 (프론트 + 백엔드 양방향):
    • 어종명 필수 50자 이내
    • 구분(type_id) 선택사항 — 미선택 행 있으면 일괄저장 시 확인 모달
    • 최소금지 / 최대길이 입력, max ≥ min
    • 각 라운드 min ≤ max, 모든 라운드 max ≤ 최대길이
    • 1라운드 min ≥ 최소금지 (사용자 명시 핵심 룰)
  • 검색 구분 select에 "구분 없음" 옵션 (type_id IS NULL)

🏅 퀘스트 어종관리 (Species Quest)

  • 테이블: species_quest (구조 동일)
  • 페이지/API: 챌린지와 100% 동일 코드 패턴 (테이블만 다름)

공통 패턴 (3개 메뉴 공통)

  • 인라인 추가/수정/삭제 + 일괄 저장 — 행 단위로 즉시 서버 호출 안 하고 변경 카운트 모아 한 번에 트랜잭션 처리
  • 변경 카운트 표시 (일괄 저장 (3)), 변경 없으면 버튼 숨김
  • SVG 체크박스 (흰 바탕 + primary 컬러 체크/대시) — 테이블 전체 통일
  • 토스트 알림 (Teleport to="body" + Transition, 하단 중앙, 자동 dismiss)
  • 삭제 표시 모드: 즉시 삭제 X, 화면에서 사라지고 일괄 저장 시 함께 처리
  • number input 4자리 제한 (maxlength는 number에서 무먹어서 @input 핸들러로 자르기) + spinner 화살표 숨김

🏆 챌린지 관리 (Challenge)

📊 테이블 관계도 (⚠️ 6개 테이블 복합 구조)

challenge  (마스터)
   │   ├ fee, start_date, end_date(등록 후 수정 가능), max_participants(100~999999)
   │   ├ total_rounds, closed_at, closed_by(NULL=자동, admin_id=수동)
   │   ├ status_YN(노출여부), deleted_YN(soft delete)
   │   └ description(TEXT, SunEditor HTML), file_name/file_path
   │
   ├──── challenge_round  (라운드, 2~5개)
   │       │   ├ round_no, place_mode('all'|'specific'), qualified, closed_at
   │       │
   │       ├──── challenge_round_item       ← all 모드: 라운드 단위 아이템
   │       │       └ (round_id + item_id) UNIQUE
   │       │
   │       ├──── challenge_round_place      ← specific 모드: 묶음의 장소들 (1선상=1row)
   │       │       └ group_no(묶음 marker), place_type('onboard'|'fishing'), place_id
   │       │
   │       └──── challenge_round_group_item ← specific 모드: 묶음별 아이템
   │               └ (round_id, group_no)로 묶음 매칭, item_id FK
   │
   └ FK: closed_by → admin_users.id (ON DELETE SET NULL)

challenge_draft  (별개, 1인당 1개 임시저장)
   └ admin_id UNIQUE FK → admin_users (ON DELETE CASCADE)
   └ data JSON (폼 전체 통째로 저장)

🔑 group_no 패턴 (specific 모드의 핵심)

프론트의 "장소 1, 장소 2"는 묶음(group) — 같은 분야/지역/제휴 조건으로 묶인 여러 선상/낚시터가 동일 아이템을 공유.

DB 저장:

  • 묶음 1번에 선상 3개 + 아이템 2개면 → challenge_round_place 3 row + challenge_round_group_item 2 row
  • group_no로 묶음 식별 (등록 시 1, 2, 3... 순차 부여)
  • ⚠️ group_noFK 아닌 의미적 markerchallenge_round_place(round_id, group_no)가 UNIQUE 아니라서 FK 불가
  • 정합성은 백엔드 코드가 보장 (ChallengeController::create()/update()에서 같은 group_no로 묶어 INSERT)

조회 시 묶음 복원:

  • challenge_round_placegroup_no로 PHP foreach 그룹화
  • 각 그룹의 아이템은 challenge_round_group_item WHERE round_id=? AND group_no=?로 가져옴

📄 페이지

경로 역할
/site-manager/challenge/list 목록 (상태 필터/검색/기간)
/site-manager/challenge/create 등록 (라운드/장소/아이템 다 입력)
/site-manager/challenge/detail/[id] 상세 (메인 탭 3종 + 라운드 탭)
/site-manager/challenge/edit/[id] 수정 (기존 데이터 폼 채움)

🔌 API

메서드/경로 설명
GET /api/challenge/list 목록 + 상태 카운트 5종 (recruiting/running/ended/hidden/all)
GET /api/challenge/:id 상세 (rounds + items + places + group_items 전체 트리)
POST /api/challenge 등록 (트랜잭션, 5개 테이블 INSERT)
PUT /api/challenge/:id 수정 (자식 DELETE 후 재INSERT)
POST /api/challenge/:id/image 타이틀 이미지 업로드
DELETE /api/challenge/:id/image 이미지 제거
POST /api/challenge/round/:round_id/close 라운드 마감 (마지막 라운드면 challenge.closed_at 자동 설정)
DELETE /api/challenge/:id soft delete (deleted_YN='Y')

🚦 상태 계산 (derived_status — SQL CASE)

list/detail 둘 다 SQL에서 실시간 계산 (별도 cron 불필요).

우선순위:

CASE
  WHEN status_YN = 'N' THEN 'hidden'           -- list만 (detail은 제외)
  WHEN closed_at IS NOT NULL THEN 'ended'      -- 명시적 마감 (관리자 강제 or 자동 종료)
  WHEN end_date < NOW() THEN 'ended'           -- 종료일 경과 ⏰
  WHEN start_date > NOW() THEN 'recruiting'    -- 모집중
  ELSE 'running'                                -- 진행중
END
  • list: 4종(hidden/recruiting/running/ended) — 비노출 표시
  • detail: 3종(recruiting/running/ended) — 노출여부는 별도 row로 표시되므로 hidden 제외
  • 카운트 5종 응답 (counts.all/recruiting/running/ended/hidden)

🔢 현재 라운드 (current_round)

list의 R{현재}/{총} 표시용. SQL subquery로 계산:

CASE
  WHEN closed_at IS NOT NULL THEN total_rounds                       -- 챌린지 종료
  WHEN end_date < NOW() THEN total_rounds                            -- 종료일 경과
  ELSE COALESCE(
    (SELECT MIN(round_no) FROM challenge_round
      WHERE challenge_id = challenge.id AND closed_at IS NULL),
    total_rounds
  )
END AS current_round

🏁 라운드 마감 → 자동 챌린지 종료

closeRound() 트랜잭션:

  1. challenge_round.closed_at = NOW()
  2. 남은 미마감 라운드 0개면 → challenge.closed_at = NOW(), closed_by = NULL (자동)
  3. detail에서 "라운드 마감" 버튼은 현재 라운드(currentRoundIdx) + 챌린지 미종료일 때만 노출

🎨 detail 페이지 UI 구조

  • 메인 탭 (.admin--main-tabs): 챌린지관리 / 신청자관리(준비중) / 참가자관리(준비중)
  • 라운드 탭 (.admin--round--tabs): 라운드별 콘텐츠 전환 + 마감된 라운드는 "종료" 뱃지
  • 활성 탭 자동 설정: 페이지 진입 시 activeRoundIdx = 현재 라운드
  • 라운드 마감/챌린지 삭제 모두 AdminAlertModal(type="confirm") 사용

🖼️ edit 페이지 — 기존 이미지 처리

  • existingImagePath/existingImageName ref로 기존 이미지 분리
  • 신규 선택(image) 시 미리보기 우선, 없으면 기존 표시
  • "이미지 제거" 버튼 → 즉시 DELETE /api/challenge/:id/image 호출

💰 참가비 (fee) — 무료 체크박스

  • type="number" min="0" + <span>원</span>
  • "무료" 체크박스 → isFree=true 시 input disabled + fee='0' 자동 입력
  • edit 진입 시 fee === 0isFree 자동 ON

📋 등록/수정 검증 (백엔드 + 프론트 양방향)

  • 챌린지명 1~255자
  • 참가비 필수 (0 허용 = 무료)
  • 시작일/종료일 YYYY-MM-DD, end_date ≥ start_date
  • max_participants 100 ~ 999,999 (1라운드 정원)
  • 라운드 2~5개, 각 라운드 qualified > 0
  • specific 모드: 장소(묶음) 최소 1개, 각 묶음에 선상/낚시터 최소 1개
  • 아이템 0개 허용

📝 임시저장 (challenge_draft) — 1인 1개

  • admin_id UNIQUE FK (DB 레벨 강제)
  • data JSON — 폼 전체(rounds 포함) 통째로 저장
  • 등록 페이지 진입 시 모달로 "임시저장 불러올래?" 처리 예정 (현재 테이블만 준비)

🟡 작업 예정 (메뉴)

  • 📊 대시보드 (현재 빈 페이지)
  • 🏆 챌린지 신청자관리/참가자관리 탭 콘텐츠
  • 🎯 퀘스트 관리 (생성 페이지 작업중 — 단독진행/챌린지 연동 토글)
  • 👥 회원 관리

⚙️ 환경 설정 메모

프론트 .env (Nuxt 루트)

NUXT_PUBLIC_API_BASE=...           # 백엔드 base URL
NUXT_PUBLIC_IMAGE_BASE=...         # 이미지 호스트 (운영 도메인)
NUXT_PUBLIC_MEDIA_BASE=...
NUXT_PUBLIC_GOOGLE_MAP_KEY=...     # Google Geocoding 키 (위도, 경도 가져오는 데에 사용[타 API 이용시 별도 키 필요하여 구 피싱포엠 형식 따름])

백엔드 .env (backend/)

encryption.key = hex2bin:...       # 계좌번호 암호화 키 (분실 시 복호화 불가)

백엔드 타임존

backend/app/Config/App.phpappTimezone = 'Asia/Seoul'

업로드 위치

backend/public/uploads/{도메인}/ (기존 패턴과 통일, 웹 직접 서빙)

  • 선상 사진: backend/public/uploads/onboard/
  • 낚시터 사진: backend/public/uploads/fishing/
  • 아이템 이미지: backend/public/uploads/item/

이미지 URL 생성 (useImage.js)

  • 1순위: NUXT_PUBLIC_IMAGE_BASE 환경변수
  • 2순위(fallback): NUXT_PUBLIC_API_BASE의 origin 자동 추출 → 로컬에서 imageBase 비워도 이미지 표시됨

🗑️ 정리한 컨트롤러 / 라우트

ERD 새로 짜면서 기존 비즈니스 컨트롤러(Branch, Showroom, Service, Brochure, Event, News, Notice, IR, Advisor, Popup, Basic, SalesStaff, BranchManager, Test) 전부 삭제. 남긴 것: Auth / Admin / BaseApi / Ping / Dashboard / Upload / Home.


📌 디자인 정책

  • soft delete vs hard delete
    • 마스터성 데이터(분야/지역/선상/낚시터/아이템/어종/관리자): soft delete (deleted_YN)
    • 종속 데이터(선상/낚시터 사진): hard delete + 파일 직접 삭제
    • 관리자 영구 삭제(hardDelete)는 슈퍼관리자가 "삭제 관리자 관리" 모드에서만 수행
  • TIMESTAMP 사용 시 주의 — MySQL이 첫 TIMESTAMP에 자동으로 ON UPDATE CURRENT_TIMESTAMP 부여하므로 created_atDEFAULT CURRENT_TIMESTAMP만, updated_atNULL DEFAULT NULL로 정의
  • 계좌번호 컬럼 길이 — 암호화하면 base64로 ~135자 → VARCHAR(255) 권장
  • 신규 SCSS 추가 시 — 어드민 페이지의 <style scoped> 안 만들고 app/assets/scss/admin.scss 에 통합 (일관성 + 관리 단순화)
  • 외래키 제약 — 부모/자식 컬럼 타입(INT/INT UNSIGNED/BIGINT)이 정확히 같아야 함. CHARSET/COLLATE도 동일해야 함
  • 모달 디자인.admin--modal-overlay + .admin--alert-modal admin--form-modal 조합. AdminAlertModal/비밀번호 변경 모달 등 전부 같은 톤(헤더 primary 네이비 배경, 본문 라운드 input, footer .admin--btn 체계)
  • <Teleport to="body"> — 부모 컨테이너의 transform/overflowposition: fixed를 가두는 경우 대비. 알림 모달은 .admin--alert-overlay { z-index: 10010 }로 다른 모달 위에 표시
  • 권한 분기 (useAuth composable + middleware/auth.js)
    • localStorage.admin_user.permissions 기반으로 사이드바 메뉴 필터링
    • URL 직접 입력 시 미들웨어가 권한 확인 후 dashboard로 redirect
    • 백엔드 가드도 동시 적용 (UI 우회 차단)
  • 시간 기반 상태 자동 계산 — 챌린지처럼 시작일/종료일/마감일에 따라 상태가 결정되는 경우 별도 cron 안 쓰고 SQL CASE WHEN ... NOW() ...로 매 조회마다 실시간 계산 (derived_status, current_round). escape 이슈 때문에 select 두 번째 인자 false 필수
  • 자식 데이터 재구성 패턴 (수정 시) — 부모-자식 다단계 트리 구조(챌린지 라운드/장소/아이템)는 자식 모두 DELETE 후 재INSERT가 가장 단순. CASCADE FK로 한 줄에 정리 (DELETE FROM challenge_round WHERE challenge_id=?)
  • 묶음(group) marker 패턴 — UNIQUE 제약 못 거는 묶음 정보는 group_no 같은 의미적 INT marker로. FK 강제 못 하므로 백엔드 코드가 정합성 보장
  • PHP foreach 변수 재사용 주의$row 같은 변수명을 outer/inner 양쪽에 쓰면 outer 변수가 덮어쓰여 응답이 잘못 나감 (자주 발생한 버그). inner는 $pr, $placeRows 등 다른 이름 사용
  • 이미지 분리 패턴 (수정 페이지) — existingImagePath(기존) vs image(신규) ref 분리해서 미리보기 우선순위 처리. 신규 있으면 신규, 없으면 기존 표시. "이미지 제거"는 즉시 DELETE API 호출
  • 무료/0원 처리INT 컬럼에 0 허용. 프론트는 무료 체크박스 추가 → input disabled + '0' 자동 입력. edit 진입 시 fee === 0 → 체크박스 자동 ON
  • 탭 vs 페이지 분리 판단 — 동일 부모(챌린지)의 여러 관리 화면(신청자/참가자)은 권장 (컨텍스트 공유, 빠른 전환). 페이지 분리는 SEO/북마크가 필요한 공개 화면에만