feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,13 +12,18 @@ import {
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
|
||||
interface TimePickerProps {
|
||||
value?: string; // "HH:mm" format
|
||||
/** "HH:mm" 또는 showSeconds 시 "HH:mm:ss" 형식 */
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** 분 단위 간격 (기본값: 5) */
|
||||
minuteStep?: number;
|
||||
/** 초 선택 표시 여부 (기본값: false) */
|
||||
showSeconds?: boolean;
|
||||
/** 초 단위 간격 (기본값: 5) */
|
||||
secondStep?: number;
|
||||
}
|
||||
|
||||
function TimePicker({
|
||||
@@ -28,14 +33,16 @@ function TimePicker({
|
||||
disabled = false,
|
||||
className,
|
||||
minuteStep = 5,
|
||||
showSeconds = false,
|
||||
secondStep = 5,
|
||||
}: TimePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// 현재 선택된 시/분 파싱
|
||||
const [selectedHour, selectedMinute] = React.useMemo(() => {
|
||||
if (!value) return [null, null];
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
return [h, m];
|
||||
// 현재 선택된 시/분/초 파싱
|
||||
const [selectedHour, selectedMinute, selectedSecond] = React.useMemo(() => {
|
||||
if (!value) return [null, null, null];
|
||||
const parts = value.split(":").map(Number);
|
||||
return [parts[0] ?? null, parts[1] ?? null, parts[2] ?? null];
|
||||
}, [value]);
|
||||
|
||||
// 시간 배열 생성 (0-23)
|
||||
@@ -47,18 +54,35 @@ function TimePicker({
|
||||
(_, i) => i * minuteStep
|
||||
);
|
||||
|
||||
// 초 배열 생성 (secondStep 간격)
|
||||
const seconds = React.useMemo(
|
||||
() => Array.from({ length: Math.ceil(60 / secondStep) }, (_, i) => i * secondStep),
|
||||
[secondStep]
|
||||
);
|
||||
|
||||
// 시간 문자열 빌더
|
||||
const buildTimeValue = React.useCallback(
|
||||
(h: number, m: number, s?: number) => {
|
||||
const base = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||||
if (showSeconds) return `${base}:${(s ?? 0).toString().padStart(2, "0")}`;
|
||||
return base;
|
||||
},
|
||||
[showSeconds]
|
||||
);
|
||||
|
||||
// 시간 선택 핸들러
|
||||
const handleHourSelect = (hour: number) => {
|
||||
const minute = selectedMinute ?? 0;
|
||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
onChange?.(newValue);
|
||||
onChange?.(buildTimeValue(hour, selectedMinute ?? 0, selectedSecond ?? 0));
|
||||
};
|
||||
|
||||
// 분 선택 핸들러
|
||||
const handleMinuteSelect = (minute: number) => {
|
||||
const hour = selectedHour ?? 0;
|
||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
onChange?.(newValue);
|
||||
onChange?.(buildTimeValue(selectedHour ?? 0, minute, selectedSecond ?? 0));
|
||||
};
|
||||
|
||||
// 초 선택 핸들러
|
||||
const handleSecondSelect = (second: number) => {
|
||||
onChange?.(buildTimeValue(selectedHour ?? 0, selectedMinute ?? 0, second));
|
||||
};
|
||||
|
||||
// 표시할 시간 텍스트
|
||||
@@ -67,6 +91,7 @@ function TimePicker({
|
||||
// 스크롤 영역 ref
|
||||
const hourScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const minuteScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const secondScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 팝오버 열릴 때 선택된 시간으로 스크롤
|
||||
React.useEffect(() => {
|
||||
@@ -84,9 +109,15 @@ function TimePicker({
|
||||
);
|
||||
minuteElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
if (showSeconds && selectedSecond !== null && secondScrollRef.current) {
|
||||
const secondElement = secondScrollRef.current.querySelector(
|
||||
`[data-second="${selectedSecond}"]`
|
||||
);
|
||||
secondElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [open, selectedHour, selectedMinute]);
|
||||
}, [open, selectedHour, selectedMinute, selectedSecond, showSeconds]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -171,6 +202,40 @@ function TimePicker({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 초 선택 (showSeconds일 때만) */}
|
||||
{showSeconds && (
|
||||
<>
|
||||
<div className="flex items-center justify-center pt-5">
|
||||
<span className="text-2xl font-bold text-muted-foreground">:</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground text-center mb-1 font-semibold">
|
||||
초
|
||||
</span>
|
||||
<ScrollArea className="h-[200px] w-[70px] rounded-md border">
|
||||
<div className="p-1" ref={secondScrollRef}>
|
||||
{seconds.map((second) => (
|
||||
<button
|
||||
key={second}
|
||||
data-second={second}
|
||||
onClick={() => handleSecondSelect(second)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm rounded-md transition-colors",
|
||||
"hover:bg-primary/10",
|
||||
selectedSecond === second
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{second.toString().padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 현재 선택된 시간 표시 */}
|
||||
|
||||
Reference in New Issue
Block a user