diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index def0347..70c0dd6 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -492,7 +492,7 @@ WDS에 이름이 비슷한 `ProgressIndicator`가 실제로 존재해서(`제외 | 블록 | Figma | 코드 | |---|---|---| -| `lockerGroup` | Zone Area + Locker Grid | `LockersLockerGrid`(`bordered`면 `Line/Solid/Alternative` 테두리 상자) | +| `lockerGroup` | Zone Area + Locker Grid | `LockersGrid`(`bordered`면 `Line/Solid/Alternative` 테두리 상자) | | `label` | Direction Label·Aisle | `LockersMapLabel` — 세로면 한 글자씩 줄바꿈하고 단어 사이에 빈 줄 | | `area` | Room Label·Zone Label·Stairs Area | `LockersMapArea` — 구역 선택 평면도와 같은 점선 상자 | | `shelfLabel` | Shelf Label | `LockersShelfLabel` | @@ -609,3 +609,18 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px ### 빈 상태는 WDS `FallbackView`가 아니라 홈 로컬 `HomeEmptyState` 일러스트 44px + 문구 한 줄 + 작은 버튼이라 행사 목록 Empty State 때와 같은 이유(`FallbackView`의 일러스트 폭·패딩 고정)로 맞지 않는다. 그림은 빌릴게 반납 빈 상태(`icons/bililge-empty/return-items.svg`)·행사 목록 빈 상태(`icons/events/empty-events.svg`)와 같은 도형이라 크기만 줄여 그대로 쓴다. + +## 내 내역 화면(열린피드백 작성내역 `3147:147748`·`3147:147818`·`3147:147756`·`3147:147779` / 학생회비 납부내역 `3147:147860`·`3147:147879`·`3147:147841` / 사물함 배정 상태 `3147:148357`·`3147:148331`) 구현 중 확정된 매핑 + +| Figma | 코드 | 확인 내용 | +|---|---|---| +| `Content Badge` (답변완료·답변대기, 납부확인중·확인필요·납부완료, 배정완료) | `ContentBadge size="small" variant="solid" color="accent"` | 홈 내 정보 목록과 같은 상태 이름·색이다. 초록 green, 주황 orange, 빨강 red | +| 작성내역 상세 `해당 회차 답변 모아보기` | `Button color="primary" size="medium" variant="outlined"` | | +| 학생회비 `문의하기`, 사물함 `사물함 위치 보기` | `Button color="assistive" size="medium" variant="outlined" fullWidth` | 위치 보기의 꺾쇠는 `trailingContent={}` | +| 빈 상태 버튼 `열린피드백 작성하기` / `열린피드백 보러 가기` | `Button color="assistive" size="small" variant="solid"` | 행사 목록 빈 상태(outlined)와 variant가 다르다 | +| 뒤로가기 헤더 | `ScreenHeader variant="normal"` + `TopNavigationButton` | 바로 들어와 뒤로 갈 기록이 없으면 홈(상세는 목록, 위치 보기는 배정 상태)으로 보낸다 | + +- **카드·빈 상태는 Stream 로컬**: 작성내역 카드(`FeedbacksHistoryCard`), 납부 카드(`FeePaymentCard`), 배정 카드는 흰 바탕 `rounded-xl p-4` 로컬 카드다. 빈 상태는 행사 목록 Empty State와 같은 이유(`FallbackView`의 일러스트 크기·패딩 고정)로 로컬이다. 열린피드백은 `FeedbacksEmptyState`(그림 `icons/feedbacks/empty-feedback.svg`)를 쓰고, 사물함은 행사 목록과 같은 도형이라 `icons/events/empty-events.svg`를 그대로 쓴다. +- **답변 강조 표시는 두 상세 화면이 공유한다**: 답변 글의 `**굵게**`를 볼드 `Typography`로 바꾸는 `FeedbacksAnswerText`를 모아보기 상세와 작성내역 상세가 같이 쓴다. +- **사물함 위치 보기(`/my/locker/location`)는 Figma에 없다**: 칸 선택 화면 배치를 보기 전용으로 쓴다. 칸 영역(미니맵·범례·확대 스크롤)은 `LockersMapView`로 두 화면이 공유한다. 보기 전용에서는 칸을 버튼으로 그리지 않고, 내 칸만 선택된 칸 모양(Primary/Normal 배경 + 흰 번호)으로 표시한다. 범례도 "내 사물함" 하나로 바꾸고, 처음 열 때 내 칸이 화면 밖이면 보일 만큼만 스크롤한다. 신청 화면의 내 칸(초록 테두리, 선택 불가)은 그대로다. +- **Figma와 다르게 둔 값**: 요일은 날짜로 계산해서 Figma의 요일 표기와 다를 수 있다. 사물함 번호는 칸 배치 목데이터가 있는 A-1 구역에 맞춰 A-7로 두었다(Figma는 B-25). 사물함 빈 상태 문구의 연도는 올해다. diff --git a/src/app/router.tsx b/src/app/router.tsx index b1b3b4d..9c4cded 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -12,14 +12,19 @@ import EventsApplicationCompleteScreen from "@/features/events/EventsApplication import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; import EventsDetailScreen from "@/features/events/EventsDetailScreen"; import EventsListScreen from "@/features/events/EventsListScreen"; +import FeePaymentsScreen from "@/features/fee/FeePaymentsScreen"; import FeedbacksDetailScreen from "@/features/feedbacks/FeedbacksDetailScreen"; import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; +import FeedbacksMineDetailScreen from "@/features/feedbacks/FeedbacksMineDetailScreen"; +import FeedbacksMineScreen from "@/features/feedbacks/FeedbacksMineScreen"; import FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen"; import HomeScreen from "@/features/home/HomeScreen"; import LockersApplyCompleteScreen from "@/features/lockers/LockersApplyCompleteScreen"; import LockersApplyFailureScreen from "@/features/lockers/LockersApplyFailureScreen"; import LockersApplyScreen from "@/features/lockers/LockersApplyScreen"; -import LockersLockerSelectScreen from "@/features/lockers/LockersLockerSelectScreen"; +import LockersAssignmentScreen from "@/features/lockers/LockersAssignmentScreen"; +import LockersCellSelectScreen from "@/features/lockers/LockersCellSelectScreen"; +import LockersMineScreen from "@/features/lockers/LockersMineScreen"; import LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectScreen"; import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen"; import NoticesListScreen from "@/features/notices/NoticesListScreen"; @@ -77,6 +82,42 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/feedbacks/:feedbackId", }, + { + element: , + // 내 내역 화면 — 하단 탭 없이 회색 배경(기본값) + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/my/feedbacks", + }, + { + element: , + // 작성내역 상세 — 열린피드백 상세와 같은 흰 배경, 하단 탭 없음 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/my/feedbacks/:feedbackId", + }, + { + element: , + // 내 내역 화면 — 하단 탭 없이 회색 배경(기본값) + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/my/fee", + }, + { + element: , + // 내 내역 화면 — 하단 탭 없이 회색 배경(기본값) + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/my/locker", + }, + { + element: , + // 칸 선택 화면과 같은 흰 배경 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/my/locker/location", + }, { element: , // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 @@ -153,7 +194,7 @@ const routes = [ path: "/lockers/apply/sections", }, { - element: , + element: , // 구역 선택 화면과 같다 — 하단 고정 선택 영역이 있고, 칸 배경이 흰 면 위에 놓인다 handle: { background: "normal", diff --git a/src/assets/icons/feedbacks/empty-feedback.svg b/src/assets/icons/feedbacks/empty-feedback.svg new file mode 100644 index 0000000..8ca4539 --- /dev/null +++ b/src/assets/icons/feedbacks/empty-feedback.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/entities/fee/feeApi.ts b/src/entities/fee/feeApi.ts new file mode 100644 index 0000000..61e4fd5 --- /dev/null +++ b/src/entities/fee/feeApi.ts @@ -0,0 +1,8 @@ +import { FEE_PAYMENTS_MOCK } from "@/entities/fee/feeMock"; +import type { FeePayment } from "@/entities/fee/types"; +import { mockupApi } from "@/lib/mockupApi"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. +export function fetchFeePayments(): Promise { + return mockupApi("fee/payments", () => FEE_PAYMENTS_MOCK); +} diff --git a/src/entities/fee/feeMock.ts b/src/entities/fee/feeMock.ts new file mode 100644 index 0000000..a7ef8ca --- /dev/null +++ b/src/entities/fee/feeMock.ts @@ -0,0 +1,57 @@ +import type { FeePayment } from "@/entities/fee/types"; + +const TITLE = "소프트웨어융합대학 학생회비"; + +// Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) +const CHECKING: FeePayment = { + amount: 150000, + confirmedAt: null, + id: "2026-2", + paidAt: "2026-09-04T11:49", + status: "checking", + title: TITLE, +}; + +const NEEDS_CHECK: FeePayment = { + amount: null, + confirmedAt: null, + id: "2026-2", + paidAt: "2026-09-04T11:49", + reviewNote: + "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.", + status: "needs-check", + title: TITLE, +}; + +const PAID: FeePayment = { + amount: 150000, + confirmedAt: "2026-09-05T13:30", + id: "2026-2", + paidAt: "2026-09-04T11:49", + status: "paid", + title: TITLE, +}; + +// 여러 학기 내역이 쌓인 경우 — 최신순 +const MULTIPLE: FeePayment[] = [ + NEEDS_CHECK, + { + ...PAID, + confirmedAt: "2026-03-05T13:30", + id: "2026-1", + paidAt: "2026-03-04T11:49", + }, +]; + +// 상태를 바꿔 납부내역 화면을 확인한다 +type FeeMockState = "checking" | "needs-check" | "paid" | "multiple"; +const FEE_MOCK_STATE: FeeMockState = "multiple"; + +const FEE_PAYMENTS: Record = { + checking: [CHECKING], + multiple: MULTIPLE, + "needs-check": [NEEDS_CHECK], + paid: [PAID], +}; + +export const FEE_PAYMENTS_MOCK = FEE_PAYMENTS[FEE_MOCK_STATE]; diff --git a/src/entities/fee/types.ts b/src/entities/fee/types.ts new file mode 100644 index 0000000..ea280e3 --- /dev/null +++ b/src/entities/fee/types.ts @@ -0,0 +1,14 @@ +// 납부확인중 · 확인필요 · 납부완료 — 미납이면 내역이 없다(홈에서 납부하기로 안내) +export type FeePaymentStatus = "checking" | "needs-check" | "paid"; + +export interface FeePayment { + id: string; + status: FeePaymentStatus; + title: string; + // 확인 전이거나 입금자를 확인하지 못하면 금액·확인일시가 비어 있다 + amount: number | null; + paidAt: string; + confirmedAt: string | null; + // 확인필요일 때 학생회가 남긴 확인 내용 + reviewNote?: string; +} diff --git a/src/entities/feedbacks/feedbacksApi.ts b/src/entities/feedbacks/feedbacksApi.ts new file mode 100644 index 0000000..0c030a7 --- /dev/null +++ b/src/entities/feedbacks/feedbacksApi.ts @@ -0,0 +1,18 @@ +import { MY_FEEDBACKS_MOCK } from "@/entities/feedbacks/feedbacksMock"; +import type { MyFeedback } from "@/entities/feedbacks/types"; +import { mockupApi } from "@/lib/mockupApi"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. +export function fetchMyFeedbacks(): Promise { + return mockupApi("feedbacks/mine", () => MY_FEEDBACKS_MOCK); +} + +// 없는 피드백은 null — 상세 화면이 홈으로 보낸다. +export function fetchMyFeedback( + feedbackId: string, +): Promise { + return mockupApi( + `feedbacks/mine/${feedbackId}`, + () => MY_FEEDBACKS_MOCK.find((item) => item.id === feedbackId) ?? null, + ); +} diff --git a/src/entities/feedbacks/feedbacksMock.ts b/src/entities/feedbacks/feedbacksMock.ts new file mode 100644 index 0000000..6a130dd --- /dev/null +++ b/src/entities/feedbacks/feedbacksMock.ts @@ -0,0 +1,74 @@ +import type { Feedback, MyFeedback } from "@/entities/feedbacks/types"; + +// 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma +// 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. +// `FeedbacksAnswerText`가 이 문법을 해석해서 렌더링하고, 카드 미리보기(FeedbacksQaCard)는 +// **만 지운 평문으로 보여준다. +const MT_ANSWER = [ + "학우 여러분의 경제적 부담을 덜어드리기 위해 더욱 고민하겠습니다. 최근 **지속적인 물가 상승**으로 인해 숙박 및 대관료가 전반적으로 크게 인상된 상황이었습니다.", + "실제로는 기존보다 참가비가 인상될 수밖에 없는 여건이었으나, 학생회에서는 학우분들의 **부담을 최소화**하기 위해 업체 측과 수차례 협의를 진행하며 최대한 비용을 조정했고, 학교 측 지원금 확보 노력 등을 통해 기존 수준으로 **참가비를 동결**하고자 최선을 다했습니다.", + "그럼에도 불구하고 학우분들께서 체감하시는 부담을 충분히 줄여드리지 못한 점은 학생회 역시 무겁게 받아들이고 있습니다.", + "향후 행사에서는 보다 **합리적이고 만족도 높은 행사 운영**이 이루어질 수 있도록 최선을 다하겠습니다.", +].join("\n\n"); + +// Figma: 게시판 - 열린피드백 (nodeId 1410:50011) 목업 데이터 +export const FEEDBACKS: Feedback[] = [ + { + answer: MT_ANSWER, + answerDate: "2026-05-06", + id: "1", + question: "MT 참가 비용이 다소 비싼 편이라 부담스러웠어요.", + round: "2차", + }, + { + answer: + "말씀 주신 대로 다음 학기부터는 개강총회를 목요일에 진행해 다음 날 부담을 줄일 수 있도록 반영하겠습니다.", + answerDate: "2026-05-06", + id: "2", + question: "목요일에 개강총회를 진행해서 다음 날 부담이 적어 좋았습니다.", + round: "2차", + }, + { + answer: "건의해주신 내용은 다음 학생회 운영위에서 함께 논의하겠습니다.", + answerDate: "2026-03-14", + id: "3", + question: "동아리방 냉난방기 가동 시간을 조금 더 늘려주시면 좋겠습니다.", + round: "1차", + }, + { + answer: "안내드린 대로 다음 공지에 상세 시간표를 함께 첨부하겠습니다.", + answerDate: "2026-07-02", + id: "4", + question: + "축제 공연 시간표를 미리 공지해주시면 일정을 맞추기 편할 것 같아요.", + round: "3차", + }, + { + answer: "학생증 재발급 신청 절차를 학과 사무실과 다시 공유드리겠습니다.", + answerDate: "2026-08-20", + id: "5", + question: "학생증 재발급 절차가 헷갈려서 안내가 더 필요해요.", + round: "4차", + }, +]; + +// Figma: 열린피드백 작성내역 (nodeId 3147:147748), 상세 (3147:147756 답변완료, 3147:147779 답변대기) +// 답변이 달린 건 열린피드백 목록의 피드백과 같은 id다. +const MY_FEEDBACKS: MyFeedback[] = [ + { ...FEEDBACKS[0], createdAt: "2026-04-25" }, + { ...FEEDBACKS[1], createdAt: "2026-05-02" }, + { + createdAt: "2026-04-25", + id: "mine-1", + question: + "다양한 학과 전공 관련 대회나 활동을 통해 포트폴리오를 채워나갈 수 있는 활동이 좋았습니다. 3~5명 정도의 원하는 사람들과 팀을 구성해서 나갈 수 있는 대회나 공모전 정보가 더 많았으면 좋겠어요.", + round: "2차", + }, +]; + +// 상태를 바꿔 작성내역 화면을 확인한다. empty: 빈 상태 (Figma 3147:147818) +type MyFeedbacksMockState = "list" | "empty"; +const MY_FEEDBACKS_MOCK_STATE: MyFeedbacksMockState = "list"; + +export const MY_FEEDBACKS_MOCK = + MY_FEEDBACKS_MOCK_STATE === "list" ? MY_FEEDBACKS : []; diff --git a/src/entities/feedbacks/types.ts b/src/entities/feedbacks/types.ts new file mode 100644 index 0000000..e1fa5fe --- /dev/null +++ b/src/entities/feedbacks/types.ts @@ -0,0 +1,15 @@ +export type FeedbackRound = "1차" | "2차" | "3차" | "4차"; + +export interface Feedback { + id: string; + question: string; + round: FeedbackRound; + answer?: string; + // 상세페이지(모아보기)의 "학생회 답변" 날짜. 답변이 없으면 의미가 없어 answer와 함께 옵셔널이다. + answerDate?: string; +} + +// 내가 쓴 피드백 — 작성일이 붙는다. 답변이 달리면 같은 id로 열린피드백 목록에도 보인다. +export interface MyFeedback extends Feedback { + createdAt: string; +} diff --git a/src/entities/lockers/lockersApi.ts b/src/entities/lockers/lockersApi.ts new file mode 100644 index 0000000..8e7541b --- /dev/null +++ b/src/entities/lockers/lockersApi.ts @@ -0,0 +1,8 @@ +import { LOCKER_ASSIGNMENT_MOCK } from "@/entities/lockers/lockersMock"; +import type { LockerAssignment } from "@/entities/lockers/types"; +import { mockupApi } from "@/lib/mockupApi"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. +export function fetchMyLockerAssignment(): Promise { + return mockupApi("lockers/assignment", () => LOCKER_ASSIGNMENT_MOCK); +} diff --git a/src/entities/lockers/lockersMock.ts b/src/entities/lockers/lockersMock.ts new file mode 100644 index 0000000..bf7adc3 --- /dev/null +++ b/src/entities/lockers/lockersMock.ts @@ -0,0 +1,19 @@ +import type { LockerAssignment } from "@/entities/lockers/types"; + +// Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) +// Figma는 B-25지만 칸 배치 목데이터가 A-1·A-2만 있어서 위치 보기가 열리도록 A-1구역 칸으로 둔다. +const ASSIGNED: LockerAssignment = { + appliedAt: "2026-09-05T13:59", + lockerLabel: "A-7", + sectionId: "A-1", + semester: "2026-2학기", + usageEndDate: "2026-12-15", + usageStartDate: "2026-09-07", +}; + +// 상태를 바꿔 배정 상태 화면을 확인한다 +type LockerAssignmentMockState = "assigned" | "empty"; +const LOCKER_ASSIGNMENT_MOCK_STATE: LockerAssignmentMockState = "assigned"; + +export const LOCKER_ASSIGNMENT_MOCK = + LOCKER_ASSIGNMENT_MOCK_STATE === "assigned" ? ASSIGNED : null; diff --git a/src/entities/lockers/types.ts b/src/entities/lockers/types.ts new file mode 100644 index 0000000..1d73d3f --- /dev/null +++ b/src/entities/lockers/types.ts @@ -0,0 +1,10 @@ +// 내 사물함 배정 — 신청하면 바로 확정이라 배정대기 상태가 없다. 신청하지 않았으면 null. +export interface LockerAssignment { + semester: string; + appliedAt: string; + usageStartDate: string; + usageEndDate: string; + lockerLabel: string; + // 사물함 위치 보기 — 이 구역의 칸 화면을 연다 + sectionId: string; +} diff --git a/src/entities/search/searchApi.ts b/src/entities/search/searchApi.ts index 8007351..6f8f12f 100644 --- a/src/entities/search/searchApi.ts +++ b/src/entities/search/searchApi.ts @@ -1,9 +1,9 @@ import { ARCHIVES } from "@/entities/archives/archivesMock"; import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock"; import { EVENTS } from "@/entities/events/eventsMock"; +import { FEEDBACKS } from "@/entities/feedbacks/feedbacksMock"; import { NOTICES } from "@/entities/notices/noticesMock"; import type { SearchResults } from "@/entities/search/types"; -import { FEEDBACKS } from "@/features/feedbacks/constants/feedbacks"; import { mockupApi } from "@/lib/mockupApi"; function includesKeyword(text: string, keyword: string): boolean { diff --git a/src/entities/search/types.ts b/src/entities/search/types.ts index e7712bc..9d37c5b 100644 --- a/src/entities/search/types.ts +++ b/src/entities/search/types.ts @@ -1,8 +1,8 @@ import type { ArchiveItem } from "@/entities/archives/types"; import type { BililgeItem } from "@/entities/bililge/types"; import type { EventItem } from "@/entities/events/types"; +import type { Feedback } from "@/entities/feedbacks/types"; import type { Notice } from "@/entities/notices/types"; -import type { Feedback } from "@/features/feedbacks/constants/feedbacks"; // 검색 결과는 카테고리별 배열이다 — 키는 용어 사전(terminology.md)의 코드 용어를 그대로 쓴다. export interface SearchResults { diff --git a/src/features/fee/FeePaymentsScreen.tsx b/src/features/fee/FeePaymentsScreen.tsx new file mode 100644 index 0000000..c5e56b1 --- /dev/null +++ b/src/features/fee/FeePaymentsScreen.tsx @@ -0,0 +1,57 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import FeePaymentList from "@/features/fee/components/FeePaymentList"; +import FeePaymentsSkeleton from "@/features/fee/components/FeePaymentsSkeleton"; +import { STUDENT_COUNCIL_KAKAO_CHANNEL_URL } from "@/features/fee/constants/fee"; + +// Figma: 학생회비 납부내역 (nodeId 3147:147860 납부확인중, 3147:147879 확인필요, 3147:147841 납부완료) +function FeePaymentsScreen() { + const navigate = useNavigate(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 + const goBack = () => { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate("/", { replace: true }); + }; + + useScreenHeader( + + + + } + title="학생회비 납부내역" + variant="normal" + />, + ); + + const openKakaoChannel = () => { + if (STUDENT_COUNCIL_KAKAO_CHANNEL_URL) { + window.open(STUDENT_COUNCIL_KAKAO_CHANNEL_URL, "_blank", "noopener"); + } + }; + + return ( +
+ }> + + +
+ ); +} + +export default FeePaymentsScreen; diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx new file mode 100644 index 0000000..7141b82 --- /dev/null +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -0,0 +1,106 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; + +import type { FeePayment } from "@/entities/fee/types"; +import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; + +interface FeePaymentCardProps { + payment: FeePayment; + onContact: () => void; +} + +// "2026-09-04T11:49" → "2026.09.04 11:49" +function formatDateTime(value: string | null) { + return value ? value.replace("T", " ").replaceAll("-", ".") : "-"; +} + +function formatAmount(value: number | null) { + return value === null ? "-" : `${value.toLocaleString("ko-KR")}원`; +} + +// Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) +// 납부 카드 아래에, 확인필요일 때만 학생회 확인 내용 카드가 붙는다. 둘 다 Stream 로컬 카드다. +function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { + const badge = FEE_STATUS_BADGES[payment.status]; + const rows = [ + { label: "납부금액", value: formatAmount(payment.amount) }, + { label: "납부일시", value: formatDateTime(payment.paidAt) }, + { label: "확인일시", value: formatDateTime(payment.confirmedAt) }, + ]; + + return ( +
+
+ + {badge.label} + + + {payment.title} + + {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+ + {payment.status === "needs-check" && payment.reviewNote && ( +
+
+ + 학생회 확인 내용 + + + {payment.reviewNote} + +
+ +
+ )} +
+ ); +} + +export default FeePaymentCard; diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx new file mode 100644 index 0000000..25998a4 --- /dev/null +++ b/src/features/fee/components/FeePaymentList.tsx @@ -0,0 +1,27 @@ +import { use } from "react"; + +import { fetchFeePayments } from "@/entities/fee/feeApi"; +import FeePaymentCard from "@/features/fee/components/FeePaymentCard"; + +interface FeePaymentListProps { + onContact: () => void; +} + +// 학기별 납부 내역을 최신순으로 쌓는다. Figma는 한 건만 그렸고, 여러 건이면 건 사이를 카드 간격보다 넓게(12px) 둔다. +function FeePaymentList({ onContact }: FeePaymentListProps) { + const payments = use(fetchFeePayments()); + + return ( +
+ {payments.map((payment) => ( + + ))} +
+ ); +} + +export default FeePaymentList; diff --git a/src/features/fee/components/FeePaymentsSkeleton.tsx b/src/features/fee/components/FeePaymentsSkeleton.tsx new file mode 100644 index 0000000..1561b87 --- /dev/null +++ b/src/features/fee/components/FeePaymentsSkeleton.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 납부내역을 받는 동안 보이는 스켈레톤 — 납부 카드(배지 + 제목 + 3줄) 배치만 따른다. +function FeePaymentsSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ + + + + +
+
+ ); +} + +export default FeePaymentsSkeleton; diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts new file mode 100644 index 0000000..e3bb756 --- /dev/null +++ b/src/features/fee/constants/fee.ts @@ -0,0 +1,16 @@ +import type { FeePaymentStatus } from "@/entities/fee/types"; + +// 학생회 카카오톡 채널 — 주소를 받으면 채운다. 비어 있으면 문의하기를 눌러도 아무 일도 하지 않는다. +export const STUDENT_COUNCIL_KAKAO_CHANNEL_URL = ""; + +type BadgeColor = "orange" | "red" | "green"; + +// 홈 내 정보 목록(HomeInfoList)의 학생회비 배지와 같은 이름·색이다 +export const FEE_STATUS_BADGES: Record< + FeePaymentStatus, + { label: string; color: BadgeColor } +> = { + checking: { color: "orange", label: "납부확인중" }, + "needs-check": { color: "red", label: "확인필요" }, + paid: { color: "green", label: "납부완료" }, +}; diff --git a/src/features/feedbacks/FeedbacksDetailScreen.tsx b/src/features/feedbacks/FeedbacksDetailScreen.tsx index 5a1712d..e64b429 100644 --- a/src/features/feedbacks/FeedbacksDetailScreen.tsx +++ b/src/features/feedbacks/FeedbacksDetailScreen.tsx @@ -5,37 +5,15 @@ import { Typography, } from "@wanteddev/wds"; import { IconClose } from "@wanteddev/wds-icon"; -import { - Fragment, - type UIEvent, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type UIEvent, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import answerIcon from "@/assets/icons/feedbacks/answer.svg"; import questionIcon from "@/assets/icons/feedbacks/question.svg"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import { FEEDBACKS } from "@/features/feedbacks/constants/feedbacks"; - -// 답변 텍스트의 **강조** 구간을 볼드 Typography로 바꿔 그린다(Figma nodeId 1410:50009의 -// 인라인 볼드 스팬). 문단 구분(\n\n)은 별도 파싱 없이 부모의 white-space: pre-wrap에 맡긴다. -function renderAnswerText(text: string, idPrefix: string) { - return text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { - const key = `${idPrefix}-${index}`; - if (segment.startsWith("**") && segment.endsWith("**")) { - return ( - - {segment.slice(2, -2)} - - ); - } - return {segment}; - }); -} +import { FEEDBACKS } from "@/entities/feedbacks/feedbacksMock"; +import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; // Figma: 열린피드백 상세페이지 (nodeId 1410:49988) // 제목·진행 바는 같은 회차(round)의 답변된 피드백들을 한데 묶어 보여준다는 뜻에서 @@ -216,15 +194,7 @@ function FeedbacksDetailScreen() { - - {renderAnswerText(item.answer, item.id)} - + )} diff --git a/src/features/feedbacks/FeedbacksListScreen.tsx b/src/features/feedbacks/FeedbacksListScreen.tsx index 696f234..3569ee5 100644 --- a/src/features/feedbacks/FeedbacksListScreen.tsx +++ b/src/features/feedbacks/FeedbacksListScreen.tsx @@ -18,12 +18,10 @@ import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { FEEDBACKS } from "@/entities/feedbacks/feedbacksMock"; import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard"; import FeedbacksQaCard from "@/features/feedbacks/components/FeedbacksQaCard"; -import { - FEEDBACK_ROUND_FILTERS, - FEEDBACKS, -} from "@/features/feedbacks/constants/feedbacks"; +import { FEEDBACK_ROUND_FILTERS } from "@/features/feedbacks/constants/feedbacks"; import { getSearchPath } from "@/features/search/constants/search"; // 카드 한 장이 차지하는 가로 길이(카드끼리 gap이 없어서 카드 폭 그 자체다). 카드가 캐러셀 diff --git a/src/features/feedbacks/FeedbacksMineDetailScreen.tsx b/src/features/feedbacks/FeedbacksMineDetailScreen.tsx new file mode 100644 index 0000000..90169a5 --- /dev/null +++ b/src/features/feedbacks/FeedbacksMineDetailScreen.tsx @@ -0,0 +1,59 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import FeedbacksMineDetailContent from "@/features/feedbacks/components/FeedbacksMineDetailContent"; +import FeedbacksMineDetailSkeleton from "@/features/feedbacks/components/FeedbacksMineDetailSkeleton"; + +// Figma: 열린피드백 작성내역 상세 (nodeId 3147:147756 답변완료, 3147:147779 답변대기) +function FeedbacksMineDetailScreen() { + const navigate = useNavigate(); + const { feedbackId = "" } = useParams(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 작성내역 목록으로 보낸다 + const goBack = () => { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate("/my/feedbacks", { replace: true }); + }; + + useScreenHeader( + + + + } + title="작성내역 상세" + variant="normal" + />, + ); + + return ( +
+ }> + + navigate("/feedbacks", { viewTransition: true }) + } + onViewRound={(id) => + navigate(`/feedbacks/${id}`, { viewTransition: true }) + } + /> + +
+ ); +} + +export default FeedbacksMineDetailScreen; diff --git a/src/features/feedbacks/FeedbacksMineScreen.tsx b/src/features/feedbacks/FeedbacksMineScreen.tsx new file mode 100644 index 0000000..d3ef613 --- /dev/null +++ b/src/features/feedbacks/FeedbacksMineScreen.tsx @@ -0,0 +1,55 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import FeedbacksMineList from "@/features/feedbacks/components/FeedbacksMineList"; +import FeedbacksMineSkeleton from "@/features/feedbacks/components/FeedbacksMineSkeleton"; + +// Figma: 열린피드백 작성내역 (nodeId 3147:147748) +function FeedbacksMineScreen() { + const navigate = useNavigate(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다(행사 신청 화면과 같은 이유) + const goBack = () => { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate("/", { replace: true }); + }; + + useScreenHeader( + + + + } + title="열린피드백 작성내역" + variant="normal" + />, + ); + + return ( +
+ }> + + navigate(`/my/feedbacks/${feedbackId}`, { viewTransition: true }) + } + onWrite={() => navigate("/feedbacks/new", { viewTransition: true })} + /> + +
+ ); +} + +export default FeedbacksMineScreen; diff --git a/src/features/feedbacks/components/FeedbacksAnswerText.tsx b/src/features/feedbacks/components/FeedbacksAnswerText.tsx new file mode 100644 index 0000000..83014e5 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksAnswerText.tsx @@ -0,0 +1,41 @@ +import { Typography } from "@wanteddev/wds"; +import { Fragment } from "react"; + +interface FeedbacksAnswerTextProps { + text: string; +} + +// 학생회 답변 본문 — **강조** 구간을 볼드 Typography로 바꿔 그린다(Figma nodeId 1410:50009의 +// 인라인 볼드 스팬). 문단 구분(\n\n)은 별도 파싱 없이 white-space: pre-wrap에 맡긴다. +// 열린피드백 상세와 작성내역 상세가 같이 쓴다. +function FeedbacksAnswerText({ text }: FeedbacksAnswerTextProps) { + return ( + + {text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => { + // 같은 답변 안에서 순서가 바뀌지 않는 조각이라 위치를 key로 쓴다 + const key = `${index}-${segment}`; + if (segment.startsWith("**") && segment.endsWith("**")) { + return ( + + {segment.slice(2, -2)} + + ); + } + return {segment}; + })} + + ); +} + +export default FeedbacksAnswerText; diff --git a/src/features/feedbacks/components/FeedbacksEmptyState.tsx b/src/features/feedbacks/components/FeedbacksEmptyState.tsx new file mode 100644 index 0000000..2bb92b3 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksEmptyState.tsx @@ -0,0 +1,58 @@ +import { Button, Typography } from "@wanteddev/wds"; + +import emptyFeedback from "@/assets/icons/feedbacks/empty-feedback.svg"; + +interface FeedbacksEmptyStateProps { + title: string; + description: string; + actionLabel: string; + onAction: () => void; +} + +// Figma: 열린피드백 작성내역 empty > Empty State (nodeId 3147:147823) — 행사 목록 Empty State와 같은 +// 틀(81px 그림 + 타이틀·설명 + 작은 버튼)이고 그림·버튼 색만 다르다. WDS FallbackView를 쓰지 않는 이유도 같다. +function FeedbacksEmptyState({ + title, + description, + actionLabel, + onAction, +}: FeedbacksEmptyStateProps) { + return ( +
+ {/* Figma 그림 자리 81px(size-20.25) */} +
+ +
+
+
+ + {title} + + + {description} + +
+ +
+
+ ); +} + +export default FeedbacksEmptyState; diff --git a/src/features/feedbacks/components/FeedbacksHistoryCard.tsx b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx new file mode 100644 index 0000000..7d2c01e --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksHistoryCard.tsx @@ -0,0 +1,63 @@ +import { ContentBadge, Typography } from "@wanteddev/wds"; + +import arrowRight from "@/assets/icons/home/arrow-right.svg"; +import type { MyFeedback } from "@/entities/feedbacks/types"; + +interface FeedbacksHistoryCardProps { + feedback: MyFeedback; + onSelect: () => void; +} + +// Figma: Feedback History Card (nodeId 2663:183140) — Stream 로컬. 답변이 있으면 답변완료(초록), +// 없으면 답변대기(주황) 배지를 붙인다. +function FeedbacksHistoryCard({ + feedback, + onSelect, +}: FeedbacksHistoryCardProps) { + const isAnswered = feedback.answer !== undefined; + + return ( + + ); +} + +export default FeedbacksHistoryCard; diff --git a/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx new file mode 100644 index 0000000..926acfe --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineDetailContent.tsx @@ -0,0 +1,118 @@ +import { Button, Typography } from "@wanteddev/wds"; +import { use } from "react"; +import { Navigate } from "react-router-dom"; + +import answerIcon from "@/assets/icons/feedbacks/answer.svg"; +import questionIcon from "@/assets/icons/feedbacks/question.svg"; +import { fetchMyFeedback } from "@/entities/feedbacks/feedbacksApi"; +import FeedbacksAnswerText from "@/features/feedbacks/components/FeedbacksAnswerText"; +import FeedbacksEmptyState from "@/features/feedbacks/components/FeedbacksEmptyState"; + +interface FeedbacksMineDetailContentProps { + feedbackId: string; + onBrowseFeedbacks: () => void; + onViewRound: (feedbackId: string) => void; +} + +// Figma: 열린피드백 작성내역 상세 답변완료 (nodeId 3147:147756), 답변대기 (3147:147779) +// 질문·답변 머리(32px 배경 원 + 아이콘)는 열린피드백 상세(FeedbacksDetailScreen)와 같다. +function FeedbacksMineDetailContent({ + feedbackId, + onBrowseFeedbacks, + onViewRound, +}: FeedbacksMineDetailContentProps) { + const feedback = use(fetchMyFeedback(feedbackId)); + + if (!feedback) { + return ; + } + + return ( +
+
+
+
+ +
+
+ + 피드백 내용 + + + {feedback.createdAt} + +
+
+ + {feedback.question} + +
+ +
+ +
+
+
+ +
+
+ + 학생회 답변 + + + {feedback.answerDate ?? "-"} + +
+
+ + {feedback.answer ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+
+ ); +} + +export default FeedbacksMineDetailContent; diff --git a/src/features/feedbacks/components/FeedbacksMineDetailSkeleton.tsx b/src/features/feedbacks/components/FeedbacksMineDetailSkeleton.tsx new file mode 100644 index 0000000..f372d52 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineDetailSkeleton.tsx @@ -0,0 +1,71 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 작성내역 상세를 받는 동안 보이는 스켈레톤 — 질문 머리·본문, 구분 띠, 답변 머리·본문 배치만 따른다. +function FeedbacksMineDetailSkeleton() { + const animation = useSkeletonAnimation(); + + const header = ( +
+ +
+ + +
+
+ ); + + return ( +
+
+ {header} + +
+
+
+ {header} + + + +
+
+ ); +} + +export default FeedbacksMineDetailSkeleton; diff --git a/src/features/feedbacks/components/FeedbacksMineList.tsx b/src/features/feedbacks/components/FeedbacksMineList.tsx new file mode 100644 index 0000000..48a0fa2 --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineList.tsx @@ -0,0 +1,43 @@ +import { use } from "react"; + +import { fetchMyFeedbacks } from "@/entities/feedbacks/feedbacksApi"; +import FeedbacksEmptyState from "@/features/feedbacks/components/FeedbacksEmptyState"; +import FeedbacksHistoryCard from "@/features/feedbacks/components/FeedbacksHistoryCard"; + +interface FeedbacksMineListProps { + onSelect: (feedbackId: string) => void; + onWrite: () => void; +} + +// Figma: 열린피드백 작성내역 (nodeId 3147:147748), empty (3147:147818) +function FeedbacksMineList({ onSelect, onWrite }: FeedbacksMineListProps) { + const feedbacks = use(fetchMyFeedbacks()); + + if (feedbacks.length === 0) { + // Figma는 헤더 아래 200px 지점에 빈 상태를 둔다 + return ( +
+ +
+ ); + } + + return ( +
+ {feedbacks.map((feedback) => ( + onSelect(feedback.id)} + /> + ))} +
+ ); +} + +export default FeedbacksMineList; diff --git a/src/features/feedbacks/components/FeedbacksMineSkeleton.tsx b/src/features/feedbacks/components/FeedbacksMineSkeleton.tsx new file mode 100644 index 0000000..fdd8aad --- /dev/null +++ b/src/features/feedbacks/components/FeedbacksMineSkeleton.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 작성내역을 받는 동안 목록 자리에 보이는 스켈레톤 — 카드(배지 + 내용 + 날짜) 배치만 따른다. +function FeedbacksMineSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.slice(0, 3).map((key) => ( +
+ + + +
+ ))} +
+ ); +} + +export default FeedbacksMineSkeleton; diff --git a/src/features/feedbacks/constants/feedbacks.ts b/src/features/feedbacks/constants/feedbacks.ts index 394dce8..e44e0a6 100644 --- a/src/features/feedbacks/constants/feedbacks.ts +++ b/src/features/feedbacks/constants/feedbacks.ts @@ -1,16 +1,5 @@ import type { FilterChipOption } from "@/components/ui/FilterChipGroup"; -export type FeedbackRound = "1차" | "2차" | "3차" | "4차"; - -export interface Feedback { - id: string; - question: string; - round: FeedbackRound; - answer?: string; - // 상세페이지(모아보기)의 "학생회 답변" 날짜. 답변이 없으면 의미가 없어 answer와 함께 옵셔널이다. - answerDate?: string; -} - export const FEEDBACK_ROUND_FILTERS: FilterChipOption[] = [ { label: "전체", value: "all" }, { label: "1차", value: "1차" }, @@ -18,55 +7,3 @@ export const FEEDBACK_ROUND_FILTERS: FilterChipOption[] = [ { label: "3차", value: "3차" }, { label: "4차", value: "4차" }, ]; - -// 문단은 빈 줄(\n\n)로 나누고, 굵게 강조할 구간은 **텍스트**로 표시한다 — 둘 다 Figma -// 원본(nodeId 1410:50009)의 문단 구분·볼드 스팬을 그대로 옮긴 것이다. -// FeedbacksDetailScreen이 이 문법을 해석해서 렌더링하고, 카드 미리보기(FeedbacksQaCard)는 -// **만 지운 평문으로 보여준다. -const MT_ANSWER = [ - "학우 여러분의 경제적 부담을 덜어드리기 위해 더욱 고민하겠습니다. 최근 **지속적인 물가 상승**으로 인해 숙박 및 대관료가 전반적으로 크게 인상된 상황이었습니다.", - "실제로는 기존보다 참가비가 인상될 수밖에 없는 여건이었으나, 학생회에서는 학우분들의 **부담을 최소화**하기 위해 업체 측과 수차례 협의를 진행하며 최대한 비용을 조정했고, 학교 측 지원금 확보 노력 등을 통해 기존 수준으로 **참가비를 동결**하고자 최선을 다했습니다.", - "그럼에도 불구하고 학우분들께서 체감하시는 부담을 충분히 줄여드리지 못한 점은 학생회 역시 무겁게 받아들이고 있습니다.", - "향후 행사에서는 보다 **합리적이고 만족도 높은 행사 운영**이 이루어질 수 있도록 최선을 다하겠습니다.", -].join("\n\n"); - -// Figma: 게시판 - 열린피드백 (nodeId 1410:50011) 목업 데이터 -export const FEEDBACKS: Feedback[] = [ - { - answer: MT_ANSWER, - answerDate: "2026-05-06", - id: "1", - question: "MT 참가 비용이 다소 비싼 편이라 부담스러웠어요.", - round: "2차", - }, - { - answer: - "말씀 주신 대로 다음 학기부터는 개강총회를 목요일에 진행해 다음 날 부담을 줄일 수 있도록 반영하겠습니다.", - answerDate: "2026-05-06", - id: "2", - question: "목요일에 개강총회를 진행해서 다음 날 부담이 적어 좋았습니다.", - round: "2차", - }, - { - answer: "건의해주신 내용은 다음 학생회 운영위에서 함께 논의하겠습니다.", - answerDate: "2026-03-14", - id: "3", - question: "동아리방 냉난방기 가동 시간을 조금 더 늘려주시면 좋겠습니다.", - round: "1차", - }, - { - answer: "안내드린 대로 다음 공지에 상세 시간표를 함께 첨부하겠습니다.", - answerDate: "2026-07-02", - id: "4", - question: - "축제 공연 시간표를 미리 공지해주시면 일정을 맞추기 편할 것 같아요.", - round: "3차", - }, - { - answer: "학생증 재발급 신청 절차를 학과 사무실과 다시 공유드리겠습니다.", - answerDate: "2026-08-20", - id: "5", - question: "학생증 재발급 절차가 헷갈려서 안내가 더 필요해요.", - round: "4차", - }, -]; diff --git a/src/features/lockers/LockersAssignmentScreen.tsx b/src/features/lockers/LockersAssignmentScreen.tsx new file mode 100644 index 0000000..a653c0e --- /dev/null +++ b/src/features/lockers/LockersAssignmentScreen.tsx @@ -0,0 +1,54 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import LockersAssignmentContent from "@/features/lockers/components/LockersAssignmentContent"; +import LockersAssignmentSkeleton from "@/features/lockers/components/LockersAssignmentSkeleton"; + +// Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) +function LockersAssignmentScreen() { + const navigate = useNavigate(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 홈으로 보낸다 + const goBack = () => { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate("/", { replace: true }); + }; + + useScreenHeader( + + + + } + title="사물함 배정 상태" + variant="normal" + />, + ); + + return ( +
+ }> + + navigate("/my/locker/location", { viewTransition: true }) + } + /> + +
+ ); +} + +export default LockersAssignmentScreen; diff --git a/src/features/lockers/LockersLockerSelectScreen.tsx b/src/features/lockers/LockersCellSelectScreen.tsx similarity index 56% rename from src/features/lockers/LockersLockerSelectScreen.tsx rename to src/features/lockers/LockersCellSelectScreen.tsx index f83a443..65fa123 100644 --- a/src/features/lockers/LockersLockerSelectScreen.tsx +++ b/src/features/lockers/LockersCellSelectScreen.tsx @@ -1,6 +1,6 @@ import { TopNavigationButton } from "@wanteddev/wds"; import { IconChevronLeft, IconImage, IconReset } from "@wanteddev/wds-icon"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import ComingSoonScreen from "@/app/ComingSoonScreen"; @@ -9,10 +9,7 @@ import ScreenHeader from "@/components/ui/ScreenHeader"; import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer"; -import LockersLockerLegend from "@/features/lockers/components/LockersLockerLegend"; -import LockersLockerMinimap, { - type LockersLockerMinimapViewport, -} from "@/features/lockers/components/LockersLockerMinimap"; +import LockersMapView from "@/features/lockers/components/LockersMapView"; import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal"; import LockersSelectedLockerBar from "@/features/lockers/components/LockersSelectedLockerBar"; import { LOCKERS_APPLY_SUBMITTING_TEXT } from "@/features/lockers/constants/lockersApplySubmit"; @@ -21,23 +18,9 @@ import { type LockersSectionDetail, } from "@/features/lockers/constants/lockersSectionDetails"; import { useLockersApplySubmit } from "@/features/lockers/hooks/useLockersApplySubmit"; -import { useLockersPinchZoom } from "@/features/lockers/hooks/useLockersPinchZoom"; - -// 배치 영역 안쪽 여백(px-5, pb-5) — 미니맵에 보이는 영역을 배치 기준으로 계산할 때 뺀다 -const CONTENT_PADDING = 20; - -interface MinimapState { - viewport: LockersLockerMinimapViewport; - layoutWidth: number; - isScrollable: boolean; -} - -function clampRatio(value: number) { - return Math.min(Math.max(value, 0), 1); -} // Figma: A-1구역 (nodeId 2159:110753), A-2구역 (2159:109533), A-1구역 실제사진 (2159:110174) -function LockersLockerSelectScreen() { +function LockersCellSelectScreen() { const { sectionId = "" } = useParams(); const detail = LOCKERS_SECTION_DETAILS[sectionId]; @@ -58,10 +41,6 @@ function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) { const [isPhotoOpen, setIsPhotoOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const { isSubmitting, submit } = useLockersApplySubmit(detail.section); - const [minimap, setMinimap] = useState(null); - const [scrollerWidth, setScrollerWidth] = useState(); - const scrollRef = useRef(null); - const contentRef = useRef(null); const lockers = useMemo( () => @@ -73,52 +52,6 @@ function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) { ? undefined : lockers.get(selectedLockerNumber); - const updateViewport = useCallback(() => { - const scroller = scrollRef.current; - const content = contentRef.current; - if (!scroller || !content) { - return; - } - // 제스처 중에는 배율이 상태보다 앞서 DOM에만 반영돼 있어서 DOM 값을 읽는다 - const zoom = Number(content.style.zoom) || 1; - const rect = content.getBoundingClientRect(); - const layoutWidth = rect.width / zoom - CONTENT_PADDING * 2; - const layoutHeight = rect.height / zoom - CONTENT_PADDING; - const visibleLeft = scroller.scrollLeft / zoom - CONTENT_PADDING; - const visibleTop = scroller.scrollTop / zoom; - const left = clampRatio(visibleLeft / layoutWidth); - const top = clampRatio(visibleTop / layoutHeight); - const right = clampRatio( - (visibleLeft + scroller.clientWidth / zoom) / layoutWidth, - ); - const bottom = clampRatio( - (visibleTop + scroller.clientHeight / zoom) / layoutHeight, - ); - - setScrollerWidth(scroller.clientWidth); - setMinimap({ - isScrollable: - scroller.scrollWidth > scroller.clientWidth + 1 || - scroller.scrollHeight > scroller.clientHeight + 1, - layoutWidth, - viewport: { height: bottom - top, left, top, width: right - left }, - }); - }, []); - - useLayoutEffect(() => { - const el = scrollRef.current; - if (!el) { - return; - } - updateViewport(); - const observer = new ResizeObserver(updateViewport); - observer.observe(el); - - return () => observer.disconnect(); - }, [updateViewport]); - - const zoom = useLockersPinchZoom(scrollRef, contentRef, updateViewport); - useScreenHeader( -
- {/* Figma Minimap Section */} -
- {minimap ? ( - - {map} - - ) : ( -
- )} - -
- {/* touch-pan: 두 손가락 동작을 페이지 확대 대신 이 영역의 확대로 받는다. - minWidth를 %가 아니라 px로 주는 이유: zoom을 걸면 %는 배율과 상관없이 화면 폭으로 - 계산돼서, 화면보다 좁은 구역을 확대할 때 "fill" 상자가 비율대로 커지지 않는다 */} -
-
- {map} -
-
-
+ { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate("/my/locker", { replace: true }); + }; + + useScreenHeader( + + + + } + title="내 사물함" + trailing={ + setIsPhotoOpen(true)} + variant="icon" + > + + + } + variant="normal" + />, + ); + + return ( +
+ }> + setIsPhotoOpen(false)} + /> + +
+ ); +} + +export default LockersMineScreen; diff --git a/src/features/lockers/components/LockersAssignmentContent.tsx b/src/features/lockers/components/LockersAssignmentContent.tsx new file mode 100644 index 0000000..253a253 --- /dev/null +++ b/src/features/lockers/components/LockersAssignmentContent.tsx @@ -0,0 +1,148 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; +import { IconChevronRight } from "@wanteddev/wds-icon"; +import { use } from "react"; + +import emptyEventsIllustration from "@/assets/icons/events/empty-events.svg"; +import { fetchMyLockerAssignment } from "@/entities/lockers/lockersApi"; + +interface LockersAssignmentContentProps { + onViewLocation: () => void; +} + +const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; + +// "2026-09-05" → "2026.09.05(토)". 시간대에 따라 날짜가 밀리지 않게 문자열을 직접 나눠 읽는다. +function formatDate(date: string) { + const [year, month, day] = date.split("-").map(Number); + const weekday = WEEKDAYS[new Date(year, month - 1, day).getDay()]; + return `${date.replaceAll("-", ".")}(${weekday})`; +} + +// "2026-09-05T13:59" → "2026.09.05(토) 13:59" +function formatDateTime(value: string) { + const [date, time] = value.split("T"); + return `${formatDate(date)} ${time}`; +} + +// Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) +// 배정 요약 카드와 배정된 사물함 카드 둘 다 Stream 로컬 카드다(학생회비 납부내역 카드와 같은 틀). +function LockersAssignmentContent({ + onViewLocation, +}: LockersAssignmentContentProps) { + const assignment = use(fetchMyLockerAssignment()); + + if (!assignment) { + const year = new Date().getFullYear(); + return ( +
+ {/* Figma: Empty State (nodeId 3147:148336) — 행사 목록과 같은 그림, 타이틀만 있다 */} +
+
+ +
+ + {year} 사물함 신청내역이 없어요 + +
+
+ ); + } + + const rows = [ + { label: "신청일시", value: formatDateTime(assignment.appliedAt) }, + { + label: "사용기한", + value: `${formatDate(assignment.usageStartDate)} - ${formatDate(assignment.usageEndDate)}`, + }, + ]; + + return ( +
+
+ + 배정완료 + + + {assignment.semester} 사물함 + +
+ {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+
+ +
+
+ + 배정된 사물함 + + + + {assignment.lockerLabel} + {" "} + 사물함 + +
+ +
+
+ ); +} + +export default LockersAssignmentContent; diff --git a/src/features/lockers/components/LockersAssignmentSkeleton.tsx b/src/features/lockers/components/LockersAssignmentSkeleton.tsx new file mode 100644 index 0000000..4a40d2d --- /dev/null +++ b/src/features/lockers/components/LockersAssignmentSkeleton.tsx @@ -0,0 +1,63 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 배정 상태를 받는 동안 보이는 스켈레톤 — 배정 요약 카드(배지 + 제목 + 2줄)와 사물함 카드 배치만 따른다. +function LockersAssignmentSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ + + + +
+
+ + + +
+
+ ); +} + +export default LockersAssignmentSkeleton; diff --git a/src/features/lockers/components/LockersLockerCell.tsx b/src/features/lockers/components/LockersCell.tsx similarity index 53% rename from src/features/lockers/components/LockersLockerCell.tsx rename to src/features/lockers/components/LockersCell.tsx index a338328..0727cbc 100644 --- a/src/features/lockers/components/LockersLockerCell.tsx +++ b/src/features/lockers/components/LockersCell.tsx @@ -3,10 +3,11 @@ import { Typography } from "@wanteddev/wds"; import lockerUnavailableIcon from "@/assets/icons/lockers/locker-unavailable.svg"; import type { LockersSectionLocker } from "@/features/lockers/constants/lockersSectionDetails"; -interface LockersLockerCellProps { +interface LockersCellProps { locker: LockersSectionLocker; isSelected: boolean; - onSelect: (lockerNumber: number) => void; + /** 없으면 보기 전용 — 칸을 누를 수 없다(내 사물함 화면) */ + onSelect?: (lockerNumber: number) => void; } // Figma: Locker Cell (nodeId 2159:110833 외) — Stream 로컬. 28px 정사각형, Orange/95 배경에 @@ -20,12 +21,12 @@ interface LockersLockerCellProps { // Background/Normal/Normal(흰색) SemiBold로 바꾼다. // // 내 사물함은 Figma에 상태가 없어서 임시로 초록 테두리를 둔다(이미 신청한 칸이라 고를 수 없다). -function LockersLockerCell({ - locker, - isSelected, - onSelect, -}: LockersLockerCellProps) { - if (locker.isMine) { +// +// 보기 전용(내 사물함 화면)에서는 칸을 버튼으로 그리지 않고, 내 칸을 선택된 칸 모양으로 표시한다. +function LockersCell({ locker, isSelected, onSelect }: LockersCellProps) { + const isViewOnly = onSelect === undefined; + + if (locker.isMine && !isViewOnly) { return (
+ {locker.lockerNumber} + + ); + + if (isViewOnly) { + return ( +
+ {number} +
+ ); + } + return ( ); } -export default LockersLockerCell; +export default LockersCell; diff --git a/src/features/lockers/components/LockersLockerGrid.tsx b/src/features/lockers/components/LockersGrid.tsx similarity index 81% rename from src/features/lockers/components/LockersLockerGrid.tsx rename to src/features/lockers/components/LockersGrid.tsx index 945379f..8d6476a 100644 --- a/src/features/lockers/components/LockersLockerGrid.tsx +++ b/src/features/lockers/components/LockersGrid.tsx @@ -1,21 +1,22 @@ -import LockersLockerCell from "@/features/lockers/components/LockersLockerCell"; +import LockersCell from "@/features/lockers/components/LockersCell"; import type { LockersSectionLocker } from "@/features/lockers/constants/lockersSectionDetails"; -interface LockersLockerGridProps { +interface LockersGridProps { rows: (number | null)[][]; lockers: ReadonlyMap; selectedLockerNumber: number | null; - onSelect: (lockerNumber: number) => void; + /** 없으면 보기 전용 — 칸을 누를 수 없다(내 사물함 화면) */ + onSelect?: (lockerNumber: number) => void; } // Figma: Locker Grid (nodeId 2159:110832 외) // layout에는 있는데 lockers에 없는 번호(서버 데이터가 어긋난 경우)는 빈 자리로 둔다. -function LockersLockerGrid({ +function LockersGrid({ rows, lockers, selectedLockerNumber, onSelect, -}: LockersLockerGridProps) { +}: LockersGridProps) { const columnCount = Math.max(...rows.map((row) => row.length)); return ( @@ -39,7 +40,7 @@ function LockersLockerGrid({ } return ( - ; selectedLockerNumber: number | null; - onSelect: (lockerNumber: number) => void; + /** 없으면 보기 전용 — 칸을 누를 수 없다(내 사물함 화면) */ + onSelect?: (lockerNumber: number) => void; } type LockersLayoutBlockContext = Omit; @@ -102,7 +103,7 @@ function LockersLayoutBlockView({ ); case "lockerGroup": { const grid = ( - + } + label="내 사물함" + /> +
+ ); + } + return (
(null); + const [scrollerWidth, setScrollerWidth] = useState(); + const scrollRef = useRef(null); + const contentRef = useRef(null); + + const updateViewport = useCallback(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + if (!scroller || !content) { + return; + } + // 제스처 중에는 배율이 상태보다 앞서 DOM에만 반영돼 있어서 DOM 값을 읽는다 + const zoom = Number(content.style.zoom) || 1; + const rect = content.getBoundingClientRect(); + const layoutWidth = rect.width / zoom - CONTENT_PADDING * 2; + const layoutHeight = rect.height / zoom - CONTENT_PADDING; + const visibleLeft = scroller.scrollLeft / zoom - CONTENT_PADDING; + const visibleTop = scroller.scrollTop / zoom; + const left = clampRatio(visibleLeft / layoutWidth); + const top = clampRatio(visibleTop / layoutHeight); + const right = clampRatio( + (visibleLeft + scroller.clientWidth / zoom) / layoutWidth, + ); + const bottom = clampRatio( + (visibleTop + scroller.clientHeight / zoom) / layoutHeight, + ); + + setScrollerWidth(scroller.clientWidth); + setMinimap({ + isScrollable: + scroller.scrollWidth > scroller.clientWidth + 1 || + scroller.scrollHeight > scroller.clientHeight + 1, + layoutWidth, + viewport: { height: bottom - top, left, top, width: right - left }, + }); + }, []); + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) { + return; + } + updateViewport(); + const observer = new ResizeObserver(updateViewport); + observer.observe(el); + + return () => observer.disconnect(); + }, [updateViewport]); + + const zoom = useLockersPinchZoom(scrollRef, contentRef, updateViewport); + + // 확대 훅이 처음에 가운데로 맞춘 뒤에 돌아야 해서 훅 호출 아래에 둔다. + // 내 칸이 화면 밖에 있으면 안쪽 여백까지 보이도록 필요한 만큼만 스크롤한다(가운데로 맞추진 않는다). + // 미니맵에도 같은 칸이 있어서 본문 안에서만 찾는다. + useLayoutEffect(() => { + const scroller = scrollRef.current; + const cell = contentRef.current?.querySelector("[data-my-locker]"); + if (!isViewOnly || !scroller || !cell) { + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const cellRect = cell.getBoundingClientRect(); + const overflowRight = cellRect.right + CONTENT_PADDING - scrollerRect.right; + const overflowLeft = scrollerRect.left - (cellRect.left - CONTENT_PADDING); + const overflowBottom = + cellRect.bottom + CONTENT_PADDING - scrollerRect.bottom; + const overflowTop = scrollerRect.top - cellRect.top; + if (overflowRight > 0) { + scroller.scrollLeft += overflowRight; + } else if (overflowLeft > 0) { + scroller.scrollLeft -= overflowLeft; + } + if (overflowBottom > 0) { + scroller.scrollTop += overflowBottom; + } else if (overflowTop > 0) { + scroller.scrollTop -= overflowTop; + } + updateViewport(); + }, [isViewOnly, updateViewport]); + + return ( +
+ {/* Figma Minimap Section */} +
+ {minimap ? ( + + {map} + + ) : ( +
+ )} + +
+ {/* touch-pan: 두 손가락 동작을 페이지 확대 대신 이 영역의 확대로 받는다. + minWidth를 %가 아니라 px로 주는 이유: zoom을 걸면 %는 배율과 상관없이 화면 폭으로 + 계산돼서, 화면보다 좁은 구역을 확대할 때 "fill" 상자가 비율대로 커지지 않는다 */} +
+
+ {map} +
+
+
+ ); +} + +export default LockersMapView; diff --git a/src/features/lockers/components/LockersMineBar.tsx b/src/features/lockers/components/LockersMineBar.tsx new file mode 100644 index 0000000..637502f --- /dev/null +++ b/src/features/lockers/components/LockersMineBar.tsx @@ -0,0 +1,35 @@ +import { Typography } from "@wanteddev/wds"; + +interface LockersMineBarProps { + lockerLabel: string; +} + +// 내 사물함 화면 하단 — 칸 선택 화면의 선택한 사물함 카드(LockersSelectedLockerBar)와 같은 모양에서 +// 신청 버튼만 뺐다. 보기 전용이라 할 동작이 없다. +function LockersMineBar({ lockerLabel }: LockersMineBarProps) { + return ( +
+
+ + 내 사물함 + + + {lockerLabel} + +
+
+
+ ); +} + +export default LockersMineBar; diff --git a/src/features/lockers/components/LockersMineContent.tsx b/src/features/lockers/components/LockersMineContent.tsx new file mode 100644 index 0000000..bc286d1 --- /dev/null +++ b/src/features/lockers/components/LockersMineContent.tsx @@ -0,0 +1,61 @@ +import { use, useMemo } from "react"; +import { Navigate } from "react-router-dom"; + +import { fetchMyLockerAssignment } from "@/entities/lockers/lockersApi"; +import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer"; +import LockersMapView from "@/features/lockers/components/LockersMapView"; +import LockersMineBar from "@/features/lockers/components/LockersMineBar"; +import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal"; +import { LOCKERS_SECTION_DETAILS } from "@/features/lockers/constants/lockersSectionDetails"; + +interface LockersMineContentProps { + isPhotoOpen: boolean; + onClosePhoto: () => void; +} + +// 배정된 구역의 칸 배치를 보기 전용으로 그리고 내 칸만 표시한다. +function LockersMineContent({ + isPhotoOpen, + onClosePhoto, +}: LockersMineContentProps) { + const assignment = use(fetchMyLockerAssignment()); + const detail = assignment + ? LOCKERS_SECTION_DETAILS[assignment.sectionId] + : undefined; + + const lockers = useMemo( + () => + new Map( + (detail?.lockers ?? []).map((locker) => [locker.lockerNumber, locker]), + ), + [detail], + ); + + // 배정이 없거나 구역 배치가 없으면 볼 칸이 없어서 배정 상태 화면으로 돌려보낸다 + if (!assignment || !detail) { + return ; + } + + const map = ( + + ); + + return ( + <> + + + + + ); +} + +export default LockersMineContent; diff --git a/src/features/lockers/components/LockersMineSkeleton.tsx b/src/features/lockers/components/LockersMineSkeleton.tsx new file mode 100644 index 0000000..649ae90 --- /dev/null +++ b/src/features/lockers/components/LockersMineSkeleton.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 배정 정보를 받는 동안 보이는 스켈레톤 — 미니맵 자리와 칸 배치 영역만 따른다. +function LockersMineSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ +
+ +
+ ); +} + +export default LockersMineSkeleton; diff --git a/src/features/lockers/components/LockersLockerMinimap.tsx b/src/features/lockers/components/LockersMinimap.tsx similarity index 94% rename from src/features/lockers/components/LockersLockerMinimap.tsx rename to src/features/lockers/components/LockersMinimap.tsx index fc63bc7..beea293 100644 --- a/src/features/lockers/components/LockersLockerMinimap.tsx +++ b/src/features/lockers/components/LockersMinimap.tsx @@ -1,19 +1,19 @@ import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; /** 칸 배치 전체 대비 지금 보이는 영역(0~1 비율) */ -export interface LockersLockerMinimapViewport { +export interface LockersMinimapViewport { left: number; top: number; width: number; height: number; } -interface LockersLockerMinimapProps { +interface LockersMinimapProps { /** 본문과 같은 칸 배치 — 축소해서 그린다 */ children: ReactNode; /** 본문 칸 배치의 1배 기준 폭. 같은 폭으로 그려야 "fill" 상자 비율이 본문과 같다 */ layoutWidth: number; - viewport: LockersLockerMinimapViewport; + viewport: LockersMinimapViewport; /** 스크롤이 없으면(전체가 보이면) 테두리를 숨긴다 */ isScrollable: boolean; } @@ -29,12 +29,12 @@ const EDGE_EPSILON = 0.001; // Figma: Minimap (nodeId 2159:110757) — Stream 로컬 // Figma는 칸·글자를 따로 줄여 그렸지만, 본문 배치를 zoom으로 줄여 그린다(배치를 두 벌 관리하지 않고, // 4px 글자가 브라우저 최소 글자 크기에 걸리지 않는다). 높이는 고정, 폭은 배치 비율에 맞추되 남은 폭을 넘지 않는다. -function LockersLockerMinimap({ +function LockersMinimap({ children, layoutWidth, viewport, isScrollable, -}: LockersLockerMinimapProps) { +}: LockersMinimapProps) { const areaRef = useRef(null); const contentRef = useRef(null); const [scale, setScale] = useState(null); @@ -127,4 +127,4 @@ function LockersLockerMinimap({ ); } -export default LockersLockerMinimap; +export default LockersMinimap; diff --git a/src/features/lockers/constants/lockersSectionDetails.ts b/src/features/lockers/constants/lockersSectionDetails.ts index 20642e4..2324b92 100644 --- a/src/features/lockers/constants/lockersSectionDetails.ts +++ b/src/features/lockers/constants/lockersSectionDetails.ts @@ -79,14 +79,17 @@ export interface LockersSectionDetail { lockers: LockersSectionLocker[]; } -// Figma 목업이 전부 선택 가능이라 그대로 두고, 이름은 Figma 표기("A-25")대로 만든다 +// Figma 목업이 전부 선택 가능이라 그대로 두고, 이름은 Figma 표기("A-25")대로 만든다. +// 내 사물함은 사물함 배정 상태 목데이터(entities/lockers)의 A-7과 맞춘다. +const MY_LOCKER_LABEL = "A-7"; + function createMockLockers( building: string, lockerNumbers: number[], ): LockersSectionLocker[] { return lockerNumbers.map((lockerNumber) => ({ isAvailable: true, - isMine: false, + isMine: `${building}-${lockerNumber}` === MY_LOCKER_LABEL, lockerId: lockerNumber, lockerLabel: `${building}-${lockerNumber}`, lockerNumber,