+
+
+ }
+ 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}
+
+
+
+ {actionLabel}
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {isAnswered ? "답변완료" : "답변대기"}
+
+
+
+
+
+ {feedback.question}
+
+
+ {feedback.createdAt.replaceAll("-", ".")}
+
+
+
+ );
+}
+
+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 ? (
+
+
+ onViewRound(feedback.id)}
+ size="medium"
+ variant="outlined"
+ >
+ 해당 회차 답변 모아보기
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
+
+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" 상자가 비율대로 커지지 않는다 */}
-
-
+
{
+ 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}
+ {" "}
+ 사물함
+
+
+
}
+ variant="outlined"
+ >
+ 사물함 위치 보기
+
+
+
+ );
+}
+
+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 (
onSelect(locker.lockerNumber)}
type="button"
>
-
- {locker.lockerNumber}
-
+ {number}
);
}
-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" 상자가 비율대로 커지지 않는다 */}
+
+
+ );
+}
+
+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,