시설 운영시간·휴무·시설상품·슬롯 제어 — FE 설계 (웹 / Next.js 운영 포털)
Background
근거 PRD: 스포츠앱/모임·커뮤니티/20260706-모집-시설상품-소모임예약연동-prd.md (그룹 B — 시설상품·운영시간·자동슬롯 FR-7~12).
근거 BE TDD (API 계약 소비): 20260707-모집-시설상품-소모임예약연동-tdd.md “REST API 계약” 표 (facility 6 엔드포인트).
이 문서는 웹 운영 포털(web/ — Next.js App Router, 시설 운영자·관리자용) 의 FE 설계다. 소비자용 화면(모집·program 예약·게시글·소모임 예약)은 앱이 담당하며 20260707-...-design-fe-app.md가 다룬다. 이 문서는 운영자 전용 기능만 다룬다:
- 요일별 운영시간 등록 (FR-7)
- 휴무일 추가/해제 (FR-8)
- 시설상품(program) 등록·목록 (FR-11)
- 자동 생성된 슬롯 수동 마감/오픈 (FR-10)
자동 슬롯 생성(FR-9)은 BE
@Scheduled배치라 FE 화면 없음.
Overview
| 기능 | 담당 화면 | 재사용 기반 |
|---|---|---|
| 운영시간 등록 | 시설 상세 내 “운영시간” 탭/섹션 | app/portal/facilities/[id]/page.tsx 확장 + 신규 lib/portal/operatingHours.ts + /api/portal/facilities/[id]/operating-hours BFF |
| 휴무일 관리 | 시설 상세 내 “휴무일” 섹션 | 동상 + lib/portal/holidays.ts |
| 시설상품(program) 등록·목록 | 시설 상세 내 “시설상품” 탭 | 신규 lib/portal/programs.ts + /api/portal/facilities/[id]/programs BFF |
| 슬롯 open/close | 기존 app/portal/slots/page.tsx 확장 | lib/portal/slots.ts에 close/open 추가 |
Terminology
| 용어 | 정의 |
|---|---|
| OperatingHours | 요일별 운영시간 (오픈·마감 시각, 브레이크타임, 슬롯단위, 정원) |
| Holiday | 시설 휴무일 (특정 날짜, 슬롯 미생성) |
| Program | 시설상품 (PT·클래스) — 이름·설명·가격·정원·소요시간 |
| SlotStatus | 슬롯 예약 가능 상태 — OPEN(예약 허용) / CLOSED(신규 예약 차단, 기존 예약 유지) |
Define Problem
AS-IS (실제 코드 근거)
| 사실 | 근거 |
|---|---|
| 시설 CRUD·슬롯 CRUD 존재 | app/portal/facilities/{page,[id]/page,new/page}.tsx + lib/portal/facilities.ts(create/list/get/update/delete). app/portal/slots/page.tsx 슬롯 달력 + lib/portal/slots.ts(fetchSlots/createSlot/updateSlot/deleteSlot) |
| 운영시간·휴무·program·슬롯 상태 제어 UI 전무 | grep -rniE "operating|holiday|program|휴무|운영시간" web/{app,lib} → 0건. lib/portal/slots.ts에 open/close 없음 |
| BFF·훅·검증 패턴 정착 | 컴포넌트 → fetch('/api/portal/*')(Next Route Handler BFF) → lib/server/be-client.ts(Bearer 쿠키·5s 타임아웃). 도메인 함수는 lib/portal/*.ts, 응답은 zod 스키마(lib/portal/schemas.ts)로 파싱. 훅은 lib/portal/use*.ts의 useState/useEffect/fetch (TanStack Query 미도입 — 레포 정착 관례) |
| shadcn/ui + 시맨틱 토큰 정착 | components/ui(button/dialog/tabs/toast/input/badge…). 토큰은 app/globals.css CSS 변수(:root 라이트 / .dark 다크) + tailwind.config.ts darkMode:["class"] |
| 다크 모드 토큰은 있으나 토글 미배선 | .dark 블록·토큰 완비. 그러나 next-themes 없음, document.documentElement.classList에 dark를 세팅하는 코드 0건, layout.tsx가 클래스 로직 없이 <html lang="ko"> 렌더 |
| zod·strict 정착 | tsconfig strict+noUncheckedIndexedAccess. zod ^4 응답 검증 |
TO-BE
- 시설 상세(
facilities/[id])에 탭(정보 / 운영시간 / 휴무일 / 시설상품) 추가. - 슬롯 화면에 status 표시 + open/close 액션.
- 신규 BFF Route Handler 4종(operating-hours PUT / holidays POST·DELETE / programs POST·GET / slots close·open PATCH).
- 다크 모드 토글 배선 — 토큰은 있으나 스위치가 없어 “한 모드만 사용 가능”한 상태. 라이트/다크 의무 준수를 위해
.dark클래스 토글(localStorage 영속 + 시스템 기본)을 최소 배선(선행 티켓).
Architecture Benchmarking (토스 벤치마킹)
운영자 화면도 토스 어드민 톤을 벤치마킹한다.
| 화면 | 참고 토스 패턴 |
|---|---|
| 운영시간 등록 | 토스 설정 폼 — 요일별 행, 시각 인풋 쌍(오픈~마감), 브레이크 추가는 인라인 ”+ 추가”, 절제된 회색 위계 |
| 휴무일 | 토스 캘린더 선택 — 날짜 칩 리스트 + 추가/삭제, 단일 accent |
| 시설상품 | 토스 상품 등록 — 가격 위계 최상단, 정원·소요시간 보조 필드, 하단 단일 저장 CTA |
| 슬롯 제어 | 토스 리스트 토글 — 각 슬롯 행에 OPEN/CLOSED 상태 배지 + 단일 토글 액션, 확인 다이얼로그 |
Detail Design
플랫폼 경계 (앱과의 공유)
web/(운영 포털)와 mobile/(소비자 앱)은 별도 앱이다. 유일한 공유 계약은 BE API 계약 필드다 — 웹은 lib/portal/types.ts에 계약을 미러링하고(zod 스키마 검증), 앱은 자체 api/types.ts에 미러링한다. 코드·컴포넌트·훅 공유 없음. 운영시간·휴무·program·슬롯 상태의 표시(읽기)는 앱 시설 상세(A-F1)에도 나타나지만, 편집은 웹 전용이다 (기능별 담당 플랫폼 표는 app 설계 문서 참조).
화면 목록 (웹)
| ID | 화면 | 경로 | 신규/확장 |
|---|---|---|---|
| W-OH | 운영시간 등록 | app/portal/facilities/[id] 내 “운영시간” 탭 | 확장 |
| W-HD | 휴무일 관리 | app/portal/facilities/[id] 내 “휴무일” 섹션 | 확장 |
| W-PG | 시설상품 목록·등록 | app/portal/facilities/[id] 내 “시설상품” 탭 | 확장 |
| W-SL | 슬롯 open/close | app/portal/slots/page.tsx 확장 | 확장 |
| W-TH | 다크 모드 토글 배선 | app/layout.tsx + components/ui/ThemeToggle.tsx | 신규(선행) |
텍스트 와이어프레임 (토스 패턴)
W-OH 운영시간 — 요일별 행
운영시간
┌ 월 ─────────────────────────────┐
│ [06:00] ~ [22:00] 슬롯 [60]분 │
│ 정원 [10] 브레이크 [12:00~13:00] [+]│
├ 화 ─────────────────────────────┤
│ ... │
└──────────────────────────────────┘
[ 저장 ] ← 단일 CTA(accent)
W-HD 휴무일
휴무일
[ 7/15(화) ✕ ] [ 7/22(화) ✕ ] ← 칩 + 삭제
[ + 날짜 추가 ] → 날짜 선택 다이얼로그
W-PG 시설상품
시설상품 [ + 상품 등록 ]
┌────────────────────────────────┐
│ PT 1:1 50,000원 │ ← 가격 위계 최상단
│ 정원 1 · 60분 [수정] │
├────────────────────────────────┤
│ 필라테스 그룹 30,000원 │
│ 정원 6 · 50분 [수정] │
└────────────────────────────────┘
등록 다이얼로그: 이름·설명·가격·정원·소요시간 + [저장]
W-SL 슬롯 open/close (기존 달력/리스트에 상태 추가)
7/12(토)
┌────────────────────────────────┐
│ 14:00~15:00 정원 2/8 [OPEN] [마감]│
│ 15:00~16:00 정원 0/8 [CLOSED] [오픈]│ ← CLOSED는 회색 배지
└────────────────────────────────┘
마감 클릭 → 확인 다이얼로그("신규 예약만 차단, 기존 예약은 유지됩니다")
화면별 4상태 표 (loading / empty / error / success)
| 화면 | loading | empty | error | success |
|---|---|---|---|---|
| W-OH | 폼 스켈레톤/스피너 | 미등록 요일→기본값 빈 행 | 403 비소유자→“권한 없음”, 저장 실패→toast | 저장 후 성공 toast |
| W-HD | 리스트 스피너 | ”휴무일 없음”(정상) | 추가/삭제 실패 toast | 칩 갱신 |
| W-PG | 리스트 스피너 | ”등록된 상품이 없어요” | 조회/등록 실패 toast, 400 검증 인라인 | 목록 갱신 |
| W-SL | 달력 스피너 | 해당 날짜 슬롯 0건 | open/close 실패 toast, 409 상태충돌 | 상태 배지 즉시 갱신 |
| W-TH | 해당 없음 | 해당 없음 | 해당 없음 | 클릭 시 즉시 전환·localStorage 영속 |
컴포넌트 트리
app/portal/facilities/[id]/page.tsx (확장)
└─ FacilityTabs [정보|운영시간|휴무일|시설상품] (components/ui Tabs)
├─ OperatingHoursForm (W-OH) — useOperatingHours + updateOperatingHours
│ └─ WeekdayRow × 7 (BreakTimeInput[])
├─ HolidaySection (W-HD) — useHolidays + add/removeHoliday
│ └─ HolidayChip[] + DatePickerDialog
└─ ProgramSection (W-PG) — usePrograms + createProgram
└─ ProgramCard[] + ProgramFormDialog
app/portal/slots/page.tsx (확장)
└─ SlotRow (status 배지 + closeSlot/openSlot 액션 + ConfirmDialog)
app/layout.tsx (확장) + components/ui/ThemeToggle.tsx (W-TH)
프레젠테이션 컴포넌트는 데이터 가공 없이 props 렌더. 폼 검증은 zod 스키마(lib/portal/schemas.ts 확장)로, 로직은 _hooks/로 추출(컴포넌트 내 비즈니스 로직 금지).
상태관리 설계
레포 관례 유지 — TanStack Query·Zustand 미도입. 서버 상태는 lib/portal/use*.ts의 useState/useEffect/fetch 훅, 폼 상태는 지역 useState(+ zod 검증). 서버 데이터를 스토어에 복사 보관하지 않는다(관례 주석과 정합). 다크 모드만 localStorage + document.documentElement.classList 토글(전역 UI 상태, 최소 배선).
API 연동 표 (BE 계약 대조)
컴포넌트 → lib/portal/*.ts(fetch to /api/portal/*) → Next Route Handler(BFF) → lib/server/be-client.ts → BE. 응답은 zod 파싱.
| 화면 | BE 메서드·경로 | BFF Route + lib 함수 | 에러 처리 |
|---|---|---|---|
| W-OH | PUT /facilities/{facilityId}/operating-hours | /api/portal/facilities/[id]/operating-hours + updateOperatingHours | 403 비소유자, 400 검증→인라인 |
| W-HD | POST /facilities/{facilityId}/holidays · DELETE .../holidays | /api/portal/facilities/[id]/holidays + addHoliday/removeHoliday | 403, 중복 날짜 멱등 |
| W-PG | POST /facilities/{facilityId}/programs | /api/portal/facilities/[id]/programs + createProgram | 403, 400(가격<0·정원<1·소요분≤0) |
| W-PG | GET /facilities/{facilityId}/programs | 동경로 GET + listPrograms | 공개, 0건 정상 |
| W-SL | PATCH /facilities/{facilityId}/slots/{slotId}/close | /api/portal/facilities/[id]/slots/[slotId]/close + closeSlot | 403, 409 상태충돌 |
| W-SL | PATCH /facilities/{facilityId}/slots/{slotId}/open | .../open + openSlot | 403, 409 |
운영시간·휴무는 BE에서 시설 문서에 임베드(Mongo) → 시설 상세 응답(GET /facilities/{id} = getMyFacility)에 포함될 수 있음. FE는 별도 조회 대신 시설 상세 응답에서 읽고, 편집만 전용 엔드포인트 사용(§확인 필요).
라우팅 흐름
flowchart LR List["portal/facilities (목록)"] --> Detail["portal/facilities/[id]"] Detail --> OH["운영시간 탭 (W-OH)"] Detail --> HD["휴무일 섹션 (W-HD)"] Detail --> PG["시설상품 탭 (W-PG)"] Slots["portal/slots (W-SL)"] --> SlotAction["close/open PATCH"] Layout["app/layout (W-TH)"] --> Toggle["ThemeToggle 전역"]
테마 토큰 (시맨틱 토큰 → 라이트/다크 매핑)
SSOT는 web/app/globals.css(HSL CSS 변수). 신규 토큰 0개 — 기존 shadcn 토큰 재사용. 색 하드코딩 금지, Tailwind 시맨틱 클래스(bg-background·text-muted-foreground 등)만 사용.
| 시맨틱 토큰 | 라이트 (HSL) | 다크 (HSL) | 이 설계 용도 |
|---|---|---|---|
| background | 0 0% 100% | 222.2 84% 4.9% | 페이지 배경 |
| card | 0 0% 100% | 222.2 84% 4.9% | 시설상품·슬롯 카드 |
| foreground | 222.2 84% 4.9% | 210 40% 98% | 본문·라벨 |
| muted-foreground | 215.4 16.3% 46.9% | 215 20.2% 65.1% | 보조 메타(정원·소요시간) |
| primary | 222.2 47.4% 11.2% | 210 40% 98% | 저장 CTA |
| accent | 210 40% 96.1% | 217.2 32.6% 17.5% | 선택 탭 강조 |
| destructive | 0 84.2% 60.2% | 0 62.8% 30.6% | 마감·삭제 |
| success | 142 71% 45% | 142 64% 42% | OPEN 배지 저장 성공 |
| warning | 38 92% 50% | 38 84% 46% | CLOSED·주의 고지 |
| border/input | 214.3 31.8% 91.4% | 217.2 32.6% 17.5% | 폼 필드·구분선 |
- 다크 모드 의무: 토큰은 완비됐으나 토글 미배선 → W-TH로
.dark클래스 토글을 배선해야 두 모드 모두 사용 가능. W-TH 완료 전에는 신규 화면이 사실상 라이트만 확인 가능(미완성) → W-TH를 선행 wave에 둔다.
Testing Plan (implementer TDD 입력)
Vitest + @testing-library/react + jsdom. BFF는 msw로 목. 각 티켓 최소 3케이스.
| 대상 | 핵심 케이스 |
|---|---|
| W-OH 운영시간 폼 | 요일별 입력 저장 성공 / 오픈>마감 검증 실패 인라인 / 브레이크 추가·삭제 / 403 권한 안내 |
| W-HD 휴무일 | 날짜 추가 후 칩 렌더 / 삭제 / 중복 추가 멱등(에러 아님) / 0건 empty |
| W-PG 시설상품 | 목록 렌더 / 등록 성공 후 목록 갱신 / 가격<0·정원<1 검증 / 0건 empty |
| W-SL 슬롯 | OPEN→마감 상태 배지 전환 / CLOSED→오픈 / 확인 다이얼로그 노출 / 409 충돌 toast |
| W-TH 토글 | 클릭 시 .dark 클래스 토글 / localStorage 영속 / 시스템 기본 반영 |
| 다크 모드 | 각 화면 라이트/다크 두 모드에서 시맨틱 클래스 적용(하드코딩 색 0건) |
Release Scenario — 기능 플래그 · 점진 공개
- 신규 BFF Route·탭·섹션은 additive — 기존 시설/슬롯 화면 무변경. 롤백 = 탭/액션 렌더 조건 제거.
- 웹은 BE/Redis 백엔드 동적 플래그(admin feature-flags)가 이미 존재 → 신규 운영자 화면 노출을
facility.program.enabled등 BE 플래그로 게이팅 가능(운영자 UI 조건부 렌더). 앱 플래그와 축 단위로 동기 ON. - W-TH(다크 토글)는 독립 배선 — 먼저 머지해도 무해.
Open Questions
| 항목 | 처리 |
|---|---|
| 운영시간·휴무 조회 경로 | BE 계약에 운영시간·휴무 GET 전용 경로가 명시 안 됨(PUT/POST/DELETE만). 시설 상세 응답(GET /facilities/{id})에 임베드 포함되는지 확인 필요 — 포함이면 별도 조회 불요, 미포함이면 GET /facilities/{id}/operating-hours 역제안 |
| 슬롯 status 노출 | 기존 GET /facilities/{id}/slots 응답에 status(OPEN/CLOSED)가 추가되는지 BE 계약 확인 필요 — W-SL 배지 표시의 전제 |
| program 수정·삭제 | BE 계약에 program POST/GET만 있음. 수정(PUT)·삭제(DELETE) 필요 시 역제안 — 이번 스코프는 등록·목록만(수정은 후속) |
| 다크 토글 소유 | 앱은 이미 토글 존재. 웹은 미배선 — 이번에 배선(W-TH). 관리자 전체 포털 공통이므로 별도 소유 티켓 |
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-07 | 최초 작성 — 웹 운영 포털 FE 설계. 운영시간·휴무·시설상품·슬롯 open/close 4기능 + 다크 토글 배선(W-TH). 시설 상세 탭 확장·슬롯 화면 확장·BFF Route Handler 6종. 레포 관례(fetch 훅·zod·shadcn) 유지, 신규 상태 라이브러리·토큰 0개 |