refactor: NumberPicker, CustomTimePicker 공통 컴포넌트로 분리

새로 생성된 파일:
- components/admin/NumberPicker.jsx (185줄)
- components/admin/CustomTimePicker.jsx (176줄)

수정된 파일:
- pages/pc/admin/AdminScheduleForm.jsx (362줄 제거)

AdminScheduleForm에서 중복 정의된 NumberPicker, CustomTimePicker를
별도 파일로 분리하고 import로 대체.

총 코드 변화: 361줄 → 0줄 (361줄 제거)
This commit is contained in:
caadiq 2026-01-09 23:05:38 +09:00
parent 0b00055773
commit 07a0c30f0f
3 changed files with 371 additions and 363 deletions

View file

@ -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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<span className={value ? "text-gray-900" : "text-gray-400"}>
{displayValue()}
</span>
<Clock size={18} className="text-gray-400" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-200 z-50 overflow-hidden"
>
{/* 피커 영역 */}
<div className="flex items-center justify-center px-4 py-4">
{/* 오전/오후 (맨 앞) */}
<NumberPicker
items={periods}
value={selectedPeriod}
onChange={setSelectedPeriod}
/>
{/* 시간 */}
<NumberPicker
items={hours}
value={selectedHour}
onChange={setSelectedHour}
/>
<span className="text-xl text-gray-300 font-medium mx-0.5">
:
</span>
{/* 분 */}
<NumberPicker
items={minutes}
value={selectedMinute}
onChange={setSelectedMinute}
/>
</div>
{/* 푸터 버튼 */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
<button
type="button"
onClick={handleClear}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
초기화
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCancel}
className="px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg transition-colors"
>
취소
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-1.5 text-sm bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors"
>
저장
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default CustomTimePicker;

View file

@ -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 (
<div
ref={containerRef}
className="relative w-16 h-[120px] overflow-hidden touch-none select-none cursor-grab active:cursor-grabbing"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
>
{/* 중앙 선택 영역 */}
<div className="absolute top-1/2 left-1 right-1 h-10 -translate-y-1/2 bg-primary/10 rounded-lg z-0" />
{/* 피커 내부 */}
<div
className="relative transition-transform duration-150 ease-out"
style={{ transform: `translateY(${offset + centerOffset}px)` }}
>
{items.map((item) => (
<div
key={item}
className={`h-10 leading-10 text-center select-none transition-all duration-150 ${
isItemInCenter(item)
? "text-primary text-lg font-bold"
: "text-gray-300 text-base"
}`}
>
{item}
</div>
))}
</div>
</div>
);
}
export default NumberPicker;

View file

@ -27,375 +27,13 @@ import {
import Toast from "../../../components/Toast"; import Toast from "../../../components/Toast";
import Lightbox from "../../../components/common/Lightbox"; import Lightbox from "../../../components/common/Lightbox";
import CustomDatePicker from "../../../components/admin/CustomDatePicker"; import CustomDatePicker from "../../../components/admin/CustomDatePicker";
import CustomTimePicker from "../../../components/admin/CustomTimePicker";
import useToast from "../../../hooks/useToast"; import useToast from "../../../hooks/useToast";
import * as authApi from "../../../api/admin/auth"; import * as authApi from "../../../api/admin/auth";
import * as categoriesApi from "../../../api/admin/categories"; import * as categoriesApi from "../../../api/admin/categories";
import * as schedulesApi from "../../../api/admin/schedules"; import * as schedulesApi from "../../../api/admin/schedules";
import { getMembers } from "../../../api/public/members"; 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 (
<div
ref={containerRef}
className="relative w-16 h-[120px] overflow-hidden touch-none select-none cursor-grab active:cursor-grabbing"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
>
{/* 중앙 선택 영역 */}
<div className="absolute top-1/2 left-1 right-1 h-10 -translate-y-1/2 bg-primary/10 rounded-lg z-0" />
{/* 피커 내부 */}
<div
className="relative transition-transform duration-150 ease-out"
style={{ transform: `translateY(${offset + centerOffset}px)` }}
>
{items.map((item) => (
<div
key={item}
className={`h-10 leading-10 text-center select-none transition-all duration-150 ${
isItemInCenter(item)
? "text-primary text-lg font-bold"
: "text-gray-300 text-base"
}`}
>
{item}
</div>
))}
</div>
</div>
);
}
// (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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<span className={value ? "text-gray-900" : "text-gray-400"}>
{displayValue()}
</span>
<Clock size={18} className="text-gray-400" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-200 z-50 overflow-hidden"
>
{/* 피커 영역 */}
<div className="flex items-center justify-center px-4 py-4">
{/* 오전/오후 (맨 앞) */}
<NumberPicker
items={periods}
value={selectedPeriod}
onChange={setSelectedPeriod}
/>
{/* 시간 */}
<NumberPicker
items={hours}
value={selectedHour}
onChange={setSelectedHour}
/>
<span className="text-xl text-gray-300 font-medium mx-0.5">
:
</span>
{/* 분 */}
<NumberPicker
items={minutes}
value={selectedMinute}
onChange={setSelectedMinute}
/>
</div>
{/* 푸터 버튼 */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
<button
type="button"
onClick={handleClear}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
초기화
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCancel}
className="px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg transition-colors"
>
취소
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-1.5 text-sm bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors"
>
저장
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function AdminScheduleForm() { function AdminScheduleForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();