diff options
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx | 5 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/edp-progress/page.tsx | 4 | ||||
| -rw-r--r-- | components/common/date-picker/date-picker-with-input.tsx | 322 | ||||
| -rw-r--r-- | components/common/date-picker/index.ts | 3 | ||||
| -rw-r--r-- | i18n/locales/en/menu.json | 5 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stage-dialogs.tsx | 87 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stages-service.ts | 158 |
7 files changed, 574 insertions, 10 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx index e7109dcb..799c3b5a 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-investigation/page.tsx @@ -13,13 +13,14 @@ import { InformationButton } from "@/components/information/information-button" import { useTranslation } from "@/i18n" interface IndexPageProps { searchParams: Promise<SearchParams> + params: Promise<{ lng: string }> } export default async function IndexPage(props: IndexPageProps) { const searchParams = await props.searchParams const search = searchParamsInvestigationCache.parse(searchParams) - const {lng} = await props.params - const {t} = await useTranslation(lng, 'menu') + const { lng } = await props.params + const { t } = await useTranslation(lng, 'menu') const validFilters = getValidFilters(search.filters) diff --git a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx index 9464c037..b205dd77 100644 --- a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx +++ b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx @@ -10,8 +10,8 @@ interface edpProgressPageProps { params: Promise<{ lng: string }> } -export default async function IndexPage({ params: edpProgressPageProps }) { - const { lng } = await params +export default async function IndexPage(props: edpProgressPageProps) { + const { lng } = await props.params const { t } = await useTranslation(lng, 'menu') return ( diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx new file mode 100644 index 00000000..6e768601 --- /dev/null +++ b/components/common/date-picker/date-picker-with-input.tsx @@ -0,0 +1,322 @@ +"use client" + +import * as React from "react" +import { format, parse, isValid } from "date-fns" +import { ko } from "date-fns/locale" +import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +export interface DatePickerWithInputProps { + value?: Date + onChange?: (date: Date | undefined) => void + disabled?: boolean + placeholder?: string + className?: string + minDate?: Date + maxDate?: Date + dateFormat?: string + inputClassName?: string + locale?: "ko" | "en" +} + +/** + * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기 + * + * 사용법: + * ```tsx + * <DatePickerWithInput + * value={selectedDate} + * onChange={(date) => setSelectedDate(date)} + * placeholder="날짜를 선택하세요" + * minDate={new Date()} + * /> + * ``` + */ +export function DatePickerWithInput({ + value, + onChange, + disabled = false, + placeholder = "YYYY-MM-DD", + className, + minDate, + maxDate, + dateFormat = "yyyy-MM-dd", + inputClassName, + locale: localeProp = "en", +}: DatePickerWithInputProps) { + const [open, setOpen] = React.useState(false) + const [inputValue, setInputValue] = React.useState<string>("") + const [month, setMonth] = React.useState<Date>(value || new Date()) + const [hasError, setHasError] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState<string>("") + + // 외부 value가 변경되면 inputValue도 업데이트 + React.useEffect(() => { + if (value && isValid(value)) { + setInputValue(format(value, dateFormat)) + setMonth(value) + setHasError(false) + setErrorMessage("") + } else { + setInputValue("") + } + }, [value, dateFormat]) + + // 날짜 유효성 검사 및 에러 메시지 설정 + const validateDate = (date: Date): { valid: boolean; message: string } => { + if (minDate) { + const minDateStart = new Date(minDate) + minDateStart.setHours(0, 0, 0, 0) + const dateToCheck = new Date(date) + dateToCheck.setHours(0, 0, 0, 0) + if (dateToCheck < minDateStart) { + return { + valid: false, + message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요` + } + } + } + if (maxDate) { + const maxDateEnd = new Date(maxDate) + maxDateEnd.setHours(23, 59, 59, 999) + if (date > maxDateEnd) { + return { + valid: false, + message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요` + } + } + } + return { valid: true, message: "" } + } + + // 캘린더에서 날짜 선택 + const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => { + // 이벤트 전파 중지 + if (e) { + e.preventDefault() + e.stopPropagation() + } + + if (date) { + const validation = validateDate(date) + if (validation.valid) { + setInputValue(format(date, dateFormat)) + setHasError(false) + setErrorMessage("") + onChange?.(date) + setMonth(date) + } else { + setHasError(true) + setErrorMessage(validation.message) + } + } + setOpen(false) + }, [dateFormat, onChange, minDate, maxDate]) + + // 직접 입력값 변경 + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const newValue = e.target.value + setInputValue(newValue) + + // 입력 중에는 에러 상태 초기화 + if (hasError) { + setHasError(false) + setErrorMessage("") + } + + // YYYY-MM-DD 형식인 경우에만 파싱 시도 + if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) { + const parsedDate = parse(newValue, dateFormat, new Date()) + + if (isValid(parsedDate)) { + const validation = validateDate(parsedDate) + if (validation.valid) { + setHasError(false) + setErrorMessage("") + onChange?.(parsedDate) + setMonth(parsedDate) + } else { + setHasError(true) + setErrorMessage(validation.message) + } + } else { + setHasError(true) + setErrorMessage("유효하지 않은 날짜 형식입니다") + } + } + } + + // 입력 완료 시 (blur) 유효성 검사 + const handleInputBlur = () => { + if (!inputValue) { + setHasError(false) + setErrorMessage("") + onChange?.(undefined) + return + } + + const parsedDate = parse(inputValue, dateFormat, new Date()) + + if (isValid(parsedDate)) { + const validation = validateDate(parsedDate) + if (validation.valid) { + setHasError(false) + setErrorMessage("") + onChange?.(parsedDate) + } else { + setHasError(true) + setErrorMessage(validation.message) + // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시 + } + } else { + // 유효하지 않은 형식인 경우 + setHasError(true) + setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요") + } + } + + // 키보드 이벤트 처리 (Enter 키로 완료) + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + handleInputBlur() + } + } + + // 날짜 비활성화 체크 (캘린더용) + const isDateDisabled = (date: Date) => { + if (disabled) return true + if (minDate) { + const minDateStart = new Date(minDate) + minDateStart.setHours(0, 0, 0, 0) + const dateToCheck = new Date(date) + dateToCheck.setHours(0, 0, 0, 0) + if (dateToCheck < minDateStart) return true + } + if (maxDate) { + const maxDateEnd = new Date(maxDate) + maxDateEnd.setHours(23, 59, 59, 999) + if (date > maxDateEnd) return true + } + return false + } + + // 캘린더 버튼 클릭 핸들러 + const handleCalendarButtonClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setOpen(!open) + } + + // Popover 상태 변경 핸들러 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + } + + return ( + <div className={cn("relative", className)}> + <div className="flex items-center gap-1"> + <Input + type="text" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className={cn( + "pr-10", + hasError && "border-red-500 focus-visible:ring-red-500", + inputClassName + )} + /> + <Popover open={open} onOpenChange={handleOpenChange} modal={true}> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="icon" + className="absolute right-0 h-full px-3 hover:bg-transparent" + disabled={disabled} + type="button" + onClick={handleCalendarButtonClick} + > + <CalendarIcon className={cn( + "h-4 w-4", + hasError ? "text-red-500" : "text-muted-foreground" + )} /> + </Button> + </PopoverTrigger> + <PopoverContent + className="w-auto p-0" + align="end" + onPointerDownOutside={(e) => e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + <div + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + <DayPicker + mode="single" + selected={value} + onSelect={(date, selectedDay, activeModifiers, e) => { + handleCalendarSelect(date, e as unknown as React.MouseEvent) + }} + month={month} + onMonthChange={setMonth} + disabled={isDateDisabled} + locale={localeProp === "ko" ? ko : undefined} + showOutsideDays + className="p-3" + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md", + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_hidden: "invisible", + }} + components={{ + IconLeft: () => <ChevronLeft className="h-4 w-4" />, + IconRight: () => <ChevronRight className="h-4 w-4" />, + }} + /> + </div> + </PopoverContent> + </Popover> + </div> + {/* 에러 메시지 표시 */} + {hasError && errorMessage && ( + <p className="text-xs text-red-500 mt-1">{errorMessage}</p> + )} + </div> + ) +} + diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts new file mode 100644 index 00000000..85c0c259 --- /dev/null +++ b/components/common/date-picker/index.ts @@ -0,0 +1,3 @@ +// 공용 날짜 선택기 컴포넌트 +export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input' + diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index bee0a946..e4835c37 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -94,8 +94,9 @@ "tbe": "TBE", "tbe_desc": "Technical Bid Evaluation", "itb": "RFQ Creation", - "itb_desc": "Create RFQ before PR Issue" - + "itb_desc": "Create RFQ before PR Issue", + "vendor_progress": "Vendor Progress", + "vendor_progress_desc": "Vendor EDP input progress" }, "vendor_management": { "title": "Vendor", diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index d4e0ff33..b6cf6d7a 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -44,7 +44,8 @@ import { updateDocument, deleteDocuments, updateStage, - getDocumentClassOptionsByContract + getDocumentClassOptionsByContract, + checkDuplicateDocuments } from "./document-stages-service" import { type Row } from "@tanstack/react-table" @@ -127,6 +128,14 @@ export function AddDocumentDialog({ const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([]) const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({}) + // Duplicate check states + const [duplicateWarning, setDuplicateWarning] = React.useState<{ + isDuplicate: boolean + type?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH' + message?: string + }>({ isDuplicate: false }) + const [isCheckingDuplicate, setIsCheckingDuplicate] = React.useState(false) + // Initialize react-hook-form const form = useForm<DocumentFormValues>({ resolver: zodResolver(documentFormSchema), @@ -167,6 +176,7 @@ export function AddDocumentDialog({ setShiComboBoxOptions({}) setCpyComboBoxOptions({}) setDocumentClassOptions([]) + setDuplicateWarning({ isDuplicate: false }) } }, [open]) @@ -359,6 +369,59 @@ export function AddDocumentDialog({ return preview && preview !== '' && !preview.includes('[value]') } + // Real-time duplicate check with debounce + const checkDuplicateDebounced = React.useMemo(() => { + let timeoutId: NodeJS.Timeout | null = null + + return (shiDocNo: string, cpyDocNo: string) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(async () => { + // Skip if both are empty or incomplete + if ((!shiDocNo || shiDocNo.includes('[value]')) && + (!cpyDocNo || cpyDocNo.includes('[value]'))) { + setDuplicateWarning({ isDuplicate: false }) + return + } + + setIsCheckingDuplicate(true) + try { + const result = await checkDuplicateDocuments( + contractId, + shiDocNo && !shiDocNo.includes('[value]') ? shiDocNo : undefined, + cpyDocNo && !cpyDocNo.includes('[value]') ? cpyDocNo : undefined + ) + + if (result.isDuplicate) { + setDuplicateWarning({ + isDuplicate: true, + type: result.duplicateType, + message: result.message + }) + } else { + setDuplicateWarning({ isDuplicate: false }) + } + } catch (error) { + console.error('Duplicate check error:', error) + } finally { + setIsCheckingDuplicate(false) + } + }, 500) // 500ms debounce + } + }, [contractId]) + + // Trigger duplicate check when document numbers change + React.useEffect(() => { + const shiPreview = generateShiPreview() + const cpyPreview = generateCpyPreview() + + if (shiPreview || cpyPreview) { + checkDuplicateDebounced(shiPreview, cpyPreview) + } + }, [shiFieldValues, cpyFieldValues]) + const onSubmit = async (data: DocumentFormValues) => { // Validate that at least one document number is configured and complete if (shiType && !isShiComplete()) { @@ -520,6 +583,24 @@ export function AddDocumentDialog({ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0"> <div className="flex-1 overflow-y-auto pr-2 space-y-4"> + {/* Duplicate Warning Alert */} + {duplicateWarning.isDuplicate && ( + <Alert variant="destructive" className="border-red-300 bg-red-50 dark:bg-red-950/50"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="font-medium"> + {duplicateWarning.message} + </AlertDescription> + </Alert> + )} + + {/* Checking Duplicate Indicator */} + {isCheckingDuplicate && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + Checking for duplicates... + </div> + )} + {/* SHI Document Number Card */} {shiType && ( <Card className="border-blue-200 dark:border-blue-800"> @@ -719,7 +800,9 @@ export function AddDocumentDialog({ form.formState.isSubmitting || !hasAvailableTypes || (shiType && !isShiComplete()) || - (cpyType && !isCpyComplete()) + (cpyType && !isCpyComplete()) || + duplicateWarning.isDuplicate || + isCheckingDuplicate } > {form.formState.isSubmitting ? ( diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index ed4099b3..cf19eb41 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -878,6 +878,127 @@ interface CreateDocumentData { vendorDocNumber?: string } +// ═══════════════════════════════════════════════════════════════════════════════ +// 문서번호 중복 체크 함수 (SHI_DOC_NO / OWN_DOC_NO 각각 중복 방지) +// ═══════════════════════════════════════════════════════════════════════════════ +interface CheckDuplicateResult { + isDuplicate: boolean + duplicateType?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH' + existingDocNumbers?: { + shiDocNo?: string + ownDocNo?: string + } + message?: string +} + +/** + * 프로젝트 내에서 SHI_DOC_NO (docNumber)와 OWN_DOC_NO (vendorDocNumber) 중복 체크 + * @param contractId 계약 ID (프로젝트 ID를 가져오기 위함) + * @param shiDocNo SHI 문서번호 (docNumber) + * @param ownDocNo CPY 문서번호 (vendorDocNumber) + * @param excludeDocumentId 수정 시 제외할 문서 ID (선택) + */ +export async function checkDuplicateDocuments( + contractId: number, + shiDocNo?: string, + ownDocNo?: string, + excludeDocumentId?: number +): Promise<CheckDuplicateResult> { + try { + // 1. 계약에서 프로젝트 ID 가져오기 + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + columns: { projectId: true }, + }) + + if (!contract) { + return { isDuplicate: false, message: "유효하지 않은 계약입니다." } + } + + const { projectId } = contract + let shiDuplicate = false + let ownDuplicate = false + const existingDocNumbers: { shiDocNo?: string; ownDocNo?: string } = {} + + // 2. SHI_DOC_NO 중복 체크 (docNumber) + if (shiDocNo && shiDocNo.trim() !== '') { + const shiConditions = [ + eq(stageDocuments.projectId, projectId), + eq(stageDocuments.docNumber, shiDocNo.trim()), + eq(stageDocuments.status, "ACTIVE"), + ] + + if (excludeDocumentId) { + shiConditions.push(ne(stageDocuments.id, excludeDocumentId)) + } + + const existingShiDoc = await db + .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber }) + .from(stageDocuments) + .where(and(...shiConditions)) + .limit(1) + + if (existingShiDoc.length > 0) { + shiDuplicate = true + existingDocNumbers.shiDocNo = existingShiDoc[0].docNumber + } + } + + // 3. OWN_DOC_NO 중복 체크 (vendorDocNumber) + if (ownDocNo && ownDocNo.trim() !== '') { + const ownConditions = [ + eq(stageDocuments.projectId, projectId), + eq(stageDocuments.vendorDocNumber, ownDocNo.trim()), + eq(stageDocuments.status, "ACTIVE"), + ] + + if (excludeDocumentId) { + ownConditions.push(ne(stageDocuments.id, excludeDocumentId)) + } + + const existingOwnDoc = await db + .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber }) + .from(stageDocuments) + .where(and(...ownConditions)) + .limit(1) + + if (existingOwnDoc.length > 0) { + ownDuplicate = true + existingDocNumbers.ownDocNo = existingOwnDoc[0].vendorDocNumber || undefined + } + } + + // 4. 결과 반환 + if (shiDuplicate && ownDuplicate) { + return { + isDuplicate: true, + duplicateType: 'BOTH', + existingDocNumbers, + message: `SHI Document Number '${shiDocNo}' and CPY Document Number '${ownDocNo}' already exist in this project.` + } + } else if (shiDuplicate) { + return { + isDuplicate: true, + duplicateType: 'SHI_DOC_NO', + existingDocNumbers, + message: `SHI Document Number '${shiDocNo}' already exists in this project.` + } + } else if (ownDuplicate) { + return { + isDuplicate: true, + duplicateType: 'OWN_DOC_NO', + existingDocNumbers, + message: `CPY Document Number '${ownDocNo}' already exists in this project.` + } + } + + return { isDuplicate: false } + } catch (error) { + console.error("중복 체크 실패:", error) + return { isDuplicate: false, message: "중복 체크 중 오류가 발생했습니다." } + } +} + // 문서 생성 export async function createDocument(data: CreateDocumentData) { try { @@ -907,6 +1028,20 @@ export async function createDocument(data: CreateDocumentData) { return { success: false, error: configsResult.error } } + /* ──────────────────────────────── 2. 중복 체크 (SHI_DOC_NO & OWN_DOC_NO) ─────────────────────────────── */ + const duplicateCheck = await checkDuplicateDocuments( + data.contractId, + data.docNumber, + data.vendorDocNumber + ) + + if (duplicateCheck.isDuplicate) { + return { + success: false, + error: duplicateCheck.message || "Document number already exists in this project.", + duplicateType: duplicateCheck.duplicateType, + } + } /* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */ const insertData = { @@ -1403,7 +1538,7 @@ export async function uploadImportData(data: UploadData) { try { // 개별 트랜잭션으로 각 문서 처리 const result = await db.transaction(async (tx) => { - // 먼저 문서가 이미 존재하는지 확인 + // 먼저 SHI_DOC_NO (docNumber)가 이미 존재하는지 확인 const [existingDoc] = await tx .select({ id: stageDocuments.id }) .from(stageDocuments) @@ -1417,7 +1552,26 @@ export async function uploadImportData(data: UploadData) { .limit(1) if (existingDoc) { - throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`) + throw new Error(`SHI Document Number "${doc.docNumber}" already exists in this project`) + } + + // OWN_DOC_NO (vendorDocNumber) 중복 체크 + if (doc.vendorDocNumber && doc.vendorDocNumber.trim() !== '') { + const [existingVendorDoc] = await tx + .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, contract.projectId), + eq(stageDocuments.vendorDocNumber, doc.vendorDocNumber.trim()), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .limit(1) + + if (existingVendorDoc) { + throw new Error(`CPY Document Number "${doc.vendorDocNumber}" already exists in this project`) + } } // 3-1. 문서 생성 |
