From 07a0c30f0f0081afcfb628cfdac47b10fc79488e Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 9 Jan 2026 23:05:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20NumberPicker,=20CustomTimePicker=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로 생성된 파일: - components/admin/NumberPicker.jsx (185줄) - components/admin/CustomTimePicker.jsx (176줄) 수정된 파일: - pages/pc/admin/AdminScheduleForm.jsx (362줄 제거) AdminScheduleForm에서 중복 정의된 NumberPicker, CustomTimePicker를 별도 파일로 분리하고 import로 대체. 총 코드 변화: 361줄 → 0줄 (361줄 제거) --- .../src/components/admin/CustomTimePicker.jsx | 178 +++++++++ .../src/components/admin/NumberPicker.jsx | 192 +++++++++ .../src/pages/pc/admin/AdminScheduleForm.jsx | 364 +----------------- 3 files changed, 371 insertions(+), 363 deletions(-) create mode 100644 frontend/src/components/admin/CustomTimePicker.jsx create mode 100644 frontend/src/components/admin/NumberPicker.jsx 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();