견적 시스템: - QuoteRegistrationV2: 할인 모달, 거래명세서 모달, vatType 필드 추가 - DiscountModal: 할인율/할인금액 상호 계산 모달 - QuoteTransactionModal: 거래명세서 미리보기 모달 - LocationDetailPanel, LocationListPanel 개선 템플릿 기능: - UniversalListPage: 엑셀 다운로드 기능 추가 (전체/선택 다운로드) - DocumentViewer: PDF 생성 기능 개선 신규 API: - /api/pdf/generate: Puppeteer 기반 PDF 생성 엔드포인트 UI 개선: - 입력 컴포넌트 placeholder 스타일 개선 (opacity 50%) - 각종 리스트 컴포넌트 정렬/필터링 개선 패키지 추가: - html2canvas, jspdf, puppeteer, dom-to-image-more Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { formatPersonalNumber, formatPersonalNumberMasked, parsePersonalNumber } from "@/lib/formatters";
|
|
|
|
export interface PersonalNumberInputProps
|
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "value" | "type"> {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
error?: boolean;
|
|
maskBack?: boolean;
|
|
}
|
|
|
|
/**
|
|
* PersonalNumberInput - 주민번호 자동 포맷팅 입력 컴포넌트
|
|
*
|
|
* 특징:
|
|
* - 입력 시 자동 하이픈 삽입 (000000-0000000)
|
|
* - 숫자만 입력 허용 (최대 13자리)
|
|
* - onChange에는 숫자만 전달 (DB 저장용)
|
|
* - 뒷자리 마스킹 옵션 (000000-*******)
|
|
*
|
|
* @example
|
|
* <PersonalNumberInput
|
|
* value={personalNumber}
|
|
* onChange={setPersonalNumber}
|
|
* maskBack
|
|
* />
|
|
*/
|
|
const PersonalNumberInput = React.forwardRef<HTMLInputElement, PersonalNumberInputProps>(
|
|
({ className, value, onChange, error, maskBack = false, ...props }, ref) => {
|
|
// 표시용 포맷된 값
|
|
const displayValue = React.useMemo(() => {
|
|
if (!value) return "";
|
|
return maskBack
|
|
? formatPersonalNumberMasked(value)
|
|
: formatPersonalNumber(value);
|
|
}, [value, maskBack]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const inputValue = e.target.value;
|
|
// 숫자만 추출하여 전달
|
|
const numbersOnly = parsePersonalNumber(inputValue);
|
|
onChange(numbersOnly);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
// 숫자, 백스페이스, 탭, 화살표, 복사/붙여넣기 허용
|
|
const allowedKeys = [
|
|
"Backspace", "Delete", "Tab", "Escape", "Enter",
|
|
"ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown",
|
|
"Home", "End"
|
|
];
|
|
|
|
if (allowedKeys.includes(e.key)) return;
|
|
|
|
// Ctrl/Cmd + A, C, V, X 허용
|
|
if ((e.ctrlKey || e.metaKey) && ["a", "c", "v", "x"].includes(e.key.toLowerCase())) {
|
|
return;
|
|
}
|
|
|
|
// 숫자가 아니면 차단
|
|
if (!/^\d$/.test(e.key)) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
e.preventDefault();
|
|
const pastedText = e.clipboardData.getData("text");
|
|
const numbersOnly = parsePersonalNumber(pastedText);
|
|
onChange(numbersOnly);
|
|
};
|
|
|
|
return (
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
ref={ref}
|
|
data-slot="input"
|
|
className={cn(
|
|
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
error && "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20",
|
|
className
|
|
)}
|
|
value={displayValue}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
PersonalNumberInput.displayName = "PersonalNumberInput";
|
|
|
|
export { PersonalNumberInput };
|