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:
유병철
2026-02-15 23:18:45 +09:00
parent 7ce4efa146
commit 7f39f3066f
81 changed files with 12848 additions and 2749 deletions

View File

@@ -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>
{/* 현재 선택된 시간 표시 */}