diff --git a/frontend/src/components/admin/CustomTimePicker.jsx b/frontend/src/components/admin/CustomTimePicker.jsx
new file mode 100644
index 0000000..9d42a96
--- /dev/null
+++ b/frontend/src/components/admin/CustomTimePicker.jsx
@@ -0,0 +1,178 @@
+/**
+ * CustomTimePicker 컴포넌트
+ * 오전/오후, 시간, 분을 선택할 수 있는 시간 피커
+ * NumberPicker를 사용하여 스크롤 방식 선택 제공
+ */
+import { useState, useEffect, useRef } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Clock } from 'lucide-react';
+import NumberPicker from './NumberPicker';
+
+function CustomTimePicker({ value, onChange, placeholder = "시간 선택" }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const ref = useRef(null);
+
+ // 현재 값 파싱
+ const parseValue = () => {
+ if (!value) return { hour: "12", minute: "00", period: "오후" };
+ const [h, m] = value.split(":");
+ const hour = parseInt(h);
+ const isPM = hour >= 12;
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return {
+ hour: String(hour12).padStart(2, "0"),
+ minute: m,
+ period: isPM ? "오후" : "오전",
+ };
+ };
+
+ const parsed = parseValue();
+ const [selectedHour, setSelectedHour] = useState(parsed.hour);
+ const [selectedMinute, setSelectedMinute] = useState(parsed.minute);
+ const [selectedPeriod, setSelectedPeriod] = useState(parsed.period);
+
+ // 외부 클릭 시 닫기
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ // 피커 열릴 때 현재 값으로 초기화
+ useEffect(() => {
+ if (isOpen) {
+ const parsed = parseValue();
+ setSelectedHour(parsed.hour);
+ setSelectedMinute(parsed.minute);
+ setSelectedPeriod(parsed.period);
+ }
+ }, [isOpen, value]);
+
+ // 시간 확정
+ const handleSave = () => {
+ let hour = parseInt(selectedHour);
+ if (selectedPeriod === "오후" && hour !== 12) hour += 12;
+ if (selectedPeriod === "오전" && hour === 12) hour = 0;
+ const timeStr = `${String(hour).padStart(2, "0")}:${selectedMinute}`;
+ onChange(timeStr);
+ setIsOpen(false);
+ };
+
+ // 취소
+ const handleCancel = () => {
+ setIsOpen(false);
+ };
+
+ // 초기화
+ const handleClear = () => {
+ onChange("");
+ setIsOpen(false);
+ };
+
+ // 표시용 포맷
+ const displayValue = () => {
+ if (!value) return placeholder;
+ const [h, m] = value.split(":");
+ const hour = parseInt(h);
+ const isPM = hour >= 12;
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return `${isPM ? "오후" : "오전"} ${hour12}:${m}`;
+ };
+
+ // 피커 아이템 데이터
+ const periods = ["오전", "오후"];
+ const hours = [
+ "01", "02", "03", "04", "05", "06",
+ "07", "08", "09", "10", "11", "12",
+ ];
+ const minutes = Array.from({ length: 60 }, (_, i) =>
+ String(i).padStart(2, "0")
+ );
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {/* 피커 영역 */}
+
+ {/* 오전/오후 (맨 앞) */}
+
+
+ {/* 시간 */}
+
+
+
+ :
+
+
+ {/* 분 */}
+
+
+
+ {/* 푸터 버튼 */}
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default CustomTimePicker;
diff --git a/frontend/src/components/admin/NumberPicker.jsx b/frontend/src/components/admin/NumberPicker.jsx
new file mode 100644
index 0000000..807d4a5
--- /dev/null
+++ b/frontend/src/components/admin/NumberPicker.jsx
@@ -0,0 +1,192 @@
+/**
+ * NumberPicker 컴포넌트
+ * 스크롤 가능한 숫자/값 선택 피커
+ * AdminScheduleForm의 시간 선택에서 사용
+ */
+import { useState, useEffect, useRef } from 'react';
+
+function NumberPicker({ items, value, onChange }) {
+ const ITEM_HEIGHT = 40;
+ const containerRef = useRef(null);
+ const [offset, setOffset] = useState(0);
+ const offsetRef = useRef(0); // 드래그용 ref
+ const touchStartY = useRef(0);
+ const startOffset = useRef(0);
+ const isScrolling = useRef(false);
+
+ // offset 변경시 ref도 업데이트
+ useEffect(() => {
+ offsetRef.current = offset;
+ }, [offset]);
+
+ // 초기 위치 설정
+ useEffect(() => {
+ if (value !== null && value !== undefined) {
+ const index = items.indexOf(value);
+ if (index !== -1) {
+ const newOffset = -index * ITEM_HEIGHT;
+ setOffset(newOffset);
+ offsetRef.current = newOffset;
+ }
+ }
+ }, []);
+
+ // 값 변경시 위치 업데이트
+ useEffect(() => {
+ const index = items.indexOf(value);
+ if (index !== -1) {
+ const targetOffset = -index * ITEM_HEIGHT;
+ if (Math.abs(offset - targetOffset) > 1) {
+ setOffset(targetOffset);
+ offsetRef.current = targetOffset;
+ }
+ }
+ }, [value, items]);
+
+ const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋
+
+ // 아이템이 중앙에 있는지 확인
+ const isItemInCenter = (item) => {
+ const itemIndex = items.indexOf(item);
+ const itemPosition = -itemIndex * ITEM_HEIGHT;
+ const tolerance = ITEM_HEIGHT / 2;
+ return Math.abs(offset - itemPosition) < tolerance;
+ };
+
+ // 오프셋 업데이트 (경계 제한)
+ const updateOffset = (newOffset) => {
+ const maxOffset = 0;
+ const minOffset = -(items.length - 1) * ITEM_HEIGHT;
+ return Math.min(maxOffset, Math.max(minOffset, newOffset));
+ };
+
+ // 중앙 아이템 업데이트
+ const updateCenterItem = (currentOffset) => {
+ const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT);
+ if (centerIndex >= 0 && centerIndex < items.length) {
+ const centerItem = items[centerIndex];
+ if (value !== centerItem) {
+ onChange(centerItem);
+ }
+ }
+ };
+
+ // 가장 가까운 아이템에 스냅
+ const snapToClosestItem = (currentOffset) => {
+ const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT;
+ setOffset(targetOffset);
+ offsetRef.current = targetOffset;
+ updateCenterItem(targetOffset);
+ };
+
+ // 터치 시작
+ const handleTouchStart = (e) => {
+ e.stopPropagation();
+ touchStartY.current = e.touches[0].clientY;
+ startOffset.current = offsetRef.current;
+ };
+
+ // 터치 이동
+ const handleTouchMove = (e) => {
+ e.stopPropagation();
+ const touchY = e.touches[0].clientY;
+ const deltaY = touchY - touchStartY.current;
+ const newOffset = updateOffset(startOffset.current + deltaY);
+ setOffset(newOffset);
+ offsetRef.current = newOffset;
+ };
+
+ // 터치 종료
+ const handleTouchEnd = (e) => {
+ e.stopPropagation();
+ snapToClosestItem(offsetRef.current);
+ };
+
+ // 마우스 휠 - 바깥 스크롤 방지
+ const handleWheel = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (isScrolling.current) return;
+ isScrolling.current = true;
+
+ const newOffset = updateOffset(
+ offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT
+ );
+ setOffset(newOffset);
+ offsetRef.current = newOffset;
+ snapToClosestItem(newOffset);
+
+ setTimeout(() => {
+ isScrolling.current = false;
+ }, 50);
+ };
+
+ // 마우스 드래그
+ const handleMouseDown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ touchStartY.current = e.clientY;
+ startOffset.current = offsetRef.current;
+
+ const handleMouseMove = (moveEvent) => {
+ moveEvent.preventDefault();
+ const deltaY = moveEvent.clientY - touchStartY.current;
+ const newOffset = updateOffset(startOffset.current + deltaY);
+ setOffset(newOffset);
+ offsetRef.current = newOffset;
+ };
+
+ const handleMouseUp = () => {
+ snapToClosestItem(offsetRef.current);
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ };
+
+ // wheel 이벤트 passive false로 등록
+ useEffect(() => {
+ const container = containerRef.current;
+ if (container) {
+ container.addEventListener("wheel", handleWheel, { passive: false });
+ return () => container.removeEventListener("wheel", handleWheel);
+ }
+ }, []);
+
+ return (
+
+ {/* 중앙 선택 영역 */}
+
+
+ {/* 피커 내부 */}
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ );
+}
+
+export default NumberPicker;
diff --git a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx
index fdd1fdb..5e4498b 100644
--- a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx
+++ b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx
@@ -27,375 +27,13 @@ import {
import Toast from "../../../components/Toast";
import Lightbox from "../../../components/common/Lightbox";
import CustomDatePicker from "../../../components/admin/CustomDatePicker";
+import CustomTimePicker from "../../../components/admin/CustomTimePicker";
import useToast from "../../../hooks/useToast";
import * as authApi from "../../../api/admin/auth";
import * as categoriesApi from "../../../api/admin/categories";
import * as schedulesApi from "../../../api/admin/schedules";
import { getMembers } from "../../../api/public/members";
-// 숫자 피커 컬럼 컴포넌트 (Vue 컴포넌트를 React로 변환)
-function NumberPicker({ items, value, onChange }) {
- const ITEM_HEIGHT = 40;
- const containerRef = useRef(null);
- const [offset, setOffset] = useState(0);
- const offsetRef = useRef(0); // 드래그용 ref
- const touchStartY = useRef(0);
- const startOffset = useRef(0);
- const isScrolling = useRef(false);
-
- // offset 변경시 ref도 업데이트
- useEffect(() => {
- offsetRef.current = offset;
- }, [offset]);
-
- // 초기 위치 설정
- useEffect(() => {
- if (value !== null && value !== undefined) {
- const index = items.indexOf(value);
- if (index !== -1) {
- const newOffset = -index * ITEM_HEIGHT;
- setOffset(newOffset);
- offsetRef.current = newOffset;
- }
- }
- }, []);
-
- // 값 변경시 위치 업데이트
- useEffect(() => {
- const index = items.indexOf(value);
- if (index !== -1) {
- const targetOffset = -index * ITEM_HEIGHT;
- if (Math.abs(offset - targetOffset) > 1) {
- setOffset(targetOffset);
- offsetRef.current = targetOffset;
- }
- }
- }, [value, items]);
-
- const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋
-
- // 아이템이 중앙에 있는지 확인
- const isItemInCenter = (item) => {
- const itemIndex = items.indexOf(item);
- const itemPosition = -itemIndex * ITEM_HEIGHT;
- const tolerance = ITEM_HEIGHT / 2;
- return Math.abs(offset - itemPosition) < tolerance;
- };
-
- // 오프셋 업데이트 (경계 제한)
- const updateOffset = (newOffset) => {
- const maxOffset = 0;
- const minOffset = -(items.length - 1) * ITEM_HEIGHT;
- return Math.min(maxOffset, Math.max(minOffset, newOffset));
- };
-
- // 중앙 아이템 업데이트
- const updateCenterItem = (currentOffset) => {
- const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT);
- if (centerIndex >= 0 && centerIndex < items.length) {
- const centerItem = items[centerIndex];
- if (value !== centerItem) {
- onChange(centerItem);
- }
- }
- };
-
- // 가장 가까운 아이템에 스냅
- const snapToClosestItem = (currentOffset) => {
- const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT;
- setOffset(targetOffset);
- offsetRef.current = targetOffset;
- updateCenterItem(targetOffset);
- };
-
- // 터치 시작
- const handleTouchStart = (e) => {
- e.stopPropagation();
- touchStartY.current = e.touches[0].clientY;
- startOffset.current = offsetRef.current;
- };
-
- // 터치 이동
- const handleTouchMove = (e) => {
- e.stopPropagation();
- const touchY = e.touches[0].clientY;
- const deltaY = touchY - touchStartY.current;
- const newOffset = updateOffset(startOffset.current + deltaY);
- setOffset(newOffset);
- offsetRef.current = newOffset;
- };
-
- // 터치 종료
- const handleTouchEnd = (e) => {
- e.stopPropagation();
- snapToClosestItem(offsetRef.current);
- };
-
- // 마우스 휠 - 바깥 스크롤 방지
- const handleWheel = (e) => {
- e.preventDefault();
- e.stopPropagation();
- if (isScrolling.current) return;
- isScrolling.current = true;
-
- const newOffset = updateOffset(
- offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT
- );
- setOffset(newOffset);
- offsetRef.current = newOffset;
- snapToClosestItem(newOffset);
-
- setTimeout(() => {
- isScrolling.current = false;
- }, 50);
- };
-
- // 마우스 드래그
- const handleMouseDown = (e) => {
- e.preventDefault();
- e.stopPropagation();
- touchStartY.current = e.clientY;
- startOffset.current = offsetRef.current;
-
- const handleMouseMove = (moveEvent) => {
- moveEvent.preventDefault();
- const deltaY = moveEvent.clientY - touchStartY.current;
- const newOffset = updateOffset(startOffset.current + deltaY);
- setOffset(newOffset);
- offsetRef.current = newOffset;
- };
-
- const handleMouseUp = () => {
- snapToClosestItem(offsetRef.current);
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- };
-
- // wheel 이벤트 passive false로 등록
- useEffect(() => {
- const container = containerRef.current;
- if (container) {
- container.addEventListener("wheel", handleWheel, { passive: false });
- return () => container.removeEventListener("wheel", handleWheel);
- }
- }, []);
-
- return (
-
- {/* 중앙 선택 영역 */}
-
-
- {/* 피커 내부 */}
-
- {items.map((item) => (
-
- {item}
-
- ))}
-
-
- );
-}
-
-// 커스텀 시간 피커 (Vue 컴포넌트 기반)
-function CustomTimePicker({ value, onChange, placeholder = "시간 선택" }) {
- const [isOpen, setIsOpen] = useState(false);
- const ref = useRef(null);
-
- // 현재 값 파싱
- const parseValue = () => {
- if (!value) return { hour: "12", minute: "00", period: "오후" };
- const [h, m] = value.split(":");
- const hour = parseInt(h);
- const isPM = hour >= 12;
- const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
- return {
- hour: String(hour12).padStart(2, "0"),
- minute: m,
- period: isPM ? "오후" : "오전",
- };
- };
-
- const parsed = parseValue();
- const [selectedHour, setSelectedHour] = useState(parsed.hour);
- const [selectedMinute, setSelectedMinute] = useState(parsed.minute);
- const [selectedPeriod, setSelectedPeriod] = useState(parsed.period);
-
- // 외부 클릭 시 닫기
- useEffect(() => {
- const handleClickOutside = (e) => {
- if (ref.current && !ref.current.contains(e.target)) {
- setIsOpen(false);
- }
- };
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
-
- // 피커 열릴 때 현재 값으로 초기화
- useEffect(() => {
- if (isOpen) {
- const parsed = parseValue();
- setSelectedHour(parsed.hour);
- setSelectedMinute(parsed.minute);
- setSelectedPeriod(parsed.period);
- }
- }, [isOpen, value]);
-
- // 시간 확정
- const handleSave = () => {
- let hour = parseInt(selectedHour);
- if (selectedPeriod === "오후" && hour !== 12) hour += 12;
- if (selectedPeriod === "오전" && hour === 12) hour = 0;
- const timeStr = `${String(hour).padStart(2, "0")}:${selectedMinute}`;
- onChange(timeStr);
- setIsOpen(false);
- };
-
- // 취소
- const handleCancel = () => {
- setIsOpen(false);
- };
-
- // 초기화
- const handleClear = () => {
- onChange("");
- setIsOpen(false);
- };
-
- // 표시용 포맷
- const displayValue = () => {
- if (!value) return placeholder;
- const [h, m] = value.split(":");
- const hour = parseInt(h);
- const isPM = hour >= 12;
- const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
- return `${isPM ? "오후" : "오전"} ${hour12}:${m}`;
- };
-
- // 피커 아이템 데이터
- const periods = ["오전", "오후"];
- const hours = [
- "01",
- "02",
- "03",
- "04",
- "05",
- "06",
- "07",
- "08",
- "09",
- "10",
- "11",
- "12",
- ];
- const minutes = Array.from({ length: 60 }, (_, i) =>
- String(i).padStart(2, "0")
- );
-
- return (
-
-
-
-
- {isOpen && (
-
- {/* 피커 영역 */}
-
- {/* 오전/오후 (맨 앞) */}
-
-
- {/* 시간 */}
-
-
-
- :
-
-
- {/* 분 */}
-
-
-
- {/* 푸터 버튼 */}
-
-
-
-
-
-
-
-
- )}
-
-
- );
-}
-
function AdminScheduleForm() {
const navigate = useNavigate();
const { id } = useParams();