1fb438bdcb
- 공용 DateInput (YYYY-MM-DD 통일): text input + Popover Calendar, 숫자 8자리 자동 - 삽입. CompactDateRange / 다이얼로그 입고요청일 적용. - DataGrid 헤더 라벨 truncate + TableHead 패딩 축소(!px-1.5): 좁은 컬럼에서 라벨 겹침/잘림 해소. - 구매요청서관리 그리드 컬럼 너비 합리화 (총 ~300px 절감)로 품명까지 화면 안에 표시. - 구매요청서 수정모드: 선택 1건 시 [구매요청서수정] 분기 → getDetail 로 헤더/라인 채워 다이얼로그 오픈. 확정·품의서생성 가드. - 공급업체 옵션을 client_mng 기반 listVendorOptions 로 신설 (운영 supply_mng=0 / client_mng=8946, M-BOM vendor 매칭). - 주문유형 CommCodeSelect groupId 0000005 → 0000167 (계약구분). - 고객사 셀렉트 → CustomerSelect 공용 컴포넌트로 교체. - 그리드 delivery_request_date 점 형식 → YYYY-MM-DD 정규화. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
5.4 KiB
TypeScript
166 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* DateInput — YYYY-MM-DD 형식 통일 공용 날짜 입력 컴포넌트
|
|
*
|
|
* 브라우저 `<input type="date">` 는 OS/로케일에 따라 "연도. 월. 일." 등 다른 placeholder 를
|
|
* 보여주는 문제가 있어, text input + Popover Calendar 로 표시·저장을 YYYY-MM-DD 로 통일.
|
|
*
|
|
* - 직접 타이핑: YYYY-MM-DD (8자리 숫자 입력 시 자동으로 - 삽입)
|
|
* - 캘린더 아이콘 클릭 → Popover Calendar 에서 날짜 선택
|
|
* - 항상 onChange 에 'YYYY-MM-DD' 또는 빈 문자열 전달
|
|
*/
|
|
|
|
import React, { useMemo, useState, useEffect, useRef } from "react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Calendar as CalendarIcon, X } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { format, parse, isValid } from "date-fns";
|
|
|
|
const FMT = "yyyy-MM-dd";
|
|
|
|
export interface DateInputProps {
|
|
value: string; // 'YYYY-MM-DD' 또는 ''
|
|
onChange: (v: string) => void;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
className?: string;
|
|
/** sm = h-7 (CompactFilter 행), md = h-9 (다이얼로그 폼) */
|
|
size?: "sm" | "md";
|
|
/** 값이 있을 때 ✕ 노출 (기본 true). 필수 필드는 false. */
|
|
clearable?: boolean;
|
|
}
|
|
|
|
function toDate(v: string): Date | null {
|
|
if (!v) return null;
|
|
const d = parse(v, FMT, new Date());
|
|
return isValid(d) ? d : null;
|
|
}
|
|
|
|
function isCompleteDate(v: string): boolean {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
|
|
const d = parse(v, FMT, new Date());
|
|
return isValid(d);
|
|
}
|
|
|
|
/** 사용자 타이핑을 8자리 숫자로 받아 YYYY-MM-DD 로 슬라이스 */
|
|
function autoFormat(raw: string): string {
|
|
const digits = raw.replace(/\D/g, "").slice(0, 8);
|
|
if (digits.length <= 4) return digits;
|
|
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
|
|
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
|
|
}
|
|
|
|
export function DateInput({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
placeholder = "YYYY-MM-DD",
|
|
className,
|
|
size = "md",
|
|
clearable = true,
|
|
}: DateInputProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [text, setText] = useState(value || "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 외부 value 동기화
|
|
useEffect(() => {
|
|
setText(value || "");
|
|
}, [value]);
|
|
|
|
const selectedDate = useMemo(() => toDate(value), [value]);
|
|
const invalid = text.length > 0 && !isCompleteDate(text);
|
|
const showClear = clearable && !disabled && !!value;
|
|
|
|
const onTextChange = (raw: string) => {
|
|
const formatted = autoFormat(raw);
|
|
setText(formatted);
|
|
if (formatted === "") {
|
|
onChange("");
|
|
} else if (isCompleteDate(formatted)) {
|
|
onChange(formatted);
|
|
}
|
|
// 아직 미완성이면 onChange 호출 안 함 (마지막 유효값 유지)
|
|
};
|
|
|
|
const onBlur = () => {
|
|
// 미완성 텍스트는 마지막 유효값으로 복귀
|
|
if (text && !isCompleteDate(text)) setText(value || "");
|
|
};
|
|
|
|
const handleCalendarSelect = (d: Date | undefined) => {
|
|
if (!d) { onChange(""); setText(""); }
|
|
else { const v = format(d, FMT); onChange(v); setText(v); }
|
|
setOpen(false);
|
|
};
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onChange("");
|
|
setText("");
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const h = size === "sm" ? "h-7" : "h-9";
|
|
const textCls = size === "sm" ? "text-xs" : "text-sm";
|
|
|
|
return (
|
|
<div className={cn("relative inline-flex items-center", className)}>
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
placeholder={placeholder}
|
|
value={text}
|
|
disabled={disabled}
|
|
onChange={(e) => onTextChange(e.target.value)}
|
|
onBlur={onBlur}
|
|
className={cn(
|
|
h, textCls, "pr-16 w-full",
|
|
invalid && "border-destructive focus-visible:ring-destructive",
|
|
)}
|
|
aria-invalid={invalid || undefined}
|
|
/>
|
|
{showClear && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label="날짜 지우기"
|
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
onClick={handleClear}
|
|
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-label="캘린더 열기"
|
|
className={cn(
|
|
"absolute right-1 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
|
|
size === "sm" ? "h-5 w-5" : "h-6 w-6",
|
|
)}
|
|
>
|
|
<CalendarIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="end">
|
|
<Calendar
|
|
mode="single"
|
|
selected={selectedDate || undefined}
|
|
onSelect={handleCalendarSelect}
|
|
initialFocus
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|