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:
parent
0b00055773
commit
07a0c30f0f
3 changed files with 371 additions and 363 deletions
178
frontend/src/components/admin/CustomTimePicker.jsx
Normal file
178
frontend/src/components/admin/CustomTimePicker.jsx
Normal 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;
|
||||||
192
frontend/src/components/admin/NumberPicker.jsx
Normal file
192
frontend/src/components/admin/NumberPicker.jsx
Normal 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;
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue