diff options
Diffstat (limited to 'components/common')
4 files changed, 692 insertions, 1 deletions
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/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx new file mode 100644 index 00000000..aeefbb84 --- /dev/null +++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader, Database, Check } from "lucide-react" +import { toast } from "sonner" +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" + +import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service" + +interface CPVWWabQustListViewDialogProps { + onConfirm?: (selectedRows: CPVWWabQustListView[]) => void + requireSingleSelection?: boolean + triggerDisabled?: boolean + triggerTitle?: string +} + +export function CPVWWabQustListViewDialog({ + onConfirm, + requireSingleSelection = false, + triggerDisabled = false, + triggerTitle, +}: CPVWWabQustListViewDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [data, setData] = React.useState<CPVWWabQustListView[]>([]) + const [error, setError] = React.useState<string | null>(null) + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}) + + const loadData = async () => { + setIsLoading(true) + setError(null) + try { + const result = await getCPVWWabQustListViewData() + if (result.success) { + setData(result.data) + if (result.isUsingFallback) { + toast.info("테스트 데이터를 표시합니다.") + } + } else { + setError(result.error || "데이터 로딩 실패") + toast.error(result.error || "데이터 로딩 실패") + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류" + setError(errorMessage) + toast.error(errorMessage) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (open) { + loadData() + } else { + // 다이얼로그 닫힐 때 데이터 초기화 + setData([]) + setError(null) + setRowSelection({}) + } + }, [open]) + + // 테이블 컬럼 정의 (동적 생성) + const columns = React.useMemo<ColumnDef<CPVWWabQustListView>[]>(() => { + if (data.length === 0) return [] + + const dataKeys = Object.keys(data[0]) + + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...dataKeys.map((key) => ({ + accessorKey: key, + header: key, + cell: ({ getValue }: any) => { + const value = getValue() + return value !== null && value !== undefined ? String(value) : "" + }, + })), + ] + }, [data]) + + // 테이블 인스턴스 생성 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + // 확인 버튼 핸들러 + const handleConfirm = () => { + if (selectedRows.length === 0) { + toast.error("행을 선택해주세요.") + return + } + + if (requireSingleSelection && selectedRows.length !== 1) { + toast.error("하나의 행만 선택해주세요.") + return + } + + if (onConfirm) { + onConfirm(selectedRows) + toast.success( + requireSingleSelection + ? "선택한 행으로 준법문의 상태를 동기화합니다." + : `${selectedRows.length}개의 행을 선택했습니다.` + ) + } else { + // 임시로 선택된 데이터 콘솔 출력 + console.log("선택된 행들:", selectedRows) + toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`) + } + + setOpen(false) + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + disabled={triggerDisabled} + title={triggerTitle} + > + <Database className="mr-2 size-4" aria-hidden="true" /> + 준법문의 요청 데이터 조회 + </Button> + </DialogTrigger> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col overflow-hidden"> + <DialogHeader> + <DialogTitle>준법문의 요청 데이터</DialogTitle> + <DialogDescription> + 준법문의 요청 데이터를 조회합니다. + {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`} + </DialogDescription> + </DialogHeader> + + <div className="flex flex-col flex-1 min-h-0"> + {isLoading ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px]"> + <Loader className="mr-2 size-6 animate-spin" /> + <span>데이터 로딩 중...</span> + </div> + ) : error ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-red-500"> + <span>오류: {error}</span> + </div> + ) : data.length === 0 ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-muted-foreground"> + <span>데이터가 없습니다.</span> + </div> + ) : ( + <div className="flex flex-col flex-1 min-h-0"> + {/* 테이블 영역 - 스크롤 가능 */} + <ScrollArea className="flex-1 overflow-auto border rounded-md"> + <Table className="min-w-full"> + <TableHeader className="sticky top-0 bg-background z-10"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} className="font-medium bg-background"> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id} className="text-sm"> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + + {/* 페이지네이션 컨트롤 - 고정 영역 */} + <div className="flex items-center justify-between px-2 py-4 border-t bg-background flex-shrink-0"> + <div className="flex-1 text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨 + </div> + <div className="flex items-center space-x-6 lg:space-x-8"> + <div className="flex items-center space-x-2"> + <p className="text-sm font-medium">페이지당 행 수</p> + <select + value={table.getState().pagination.pageSize} + onChange={(e) => { + table.setPageSize(Number(e.target.value)) + }} + className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + > + {[10, 20, 30, 40, 50].map((pageSize) => ( + <option key={pageSize} value={pageSize}> + {pageSize} + </option> + ))} + </select> + </div> + <div className="flex w-[100px] items-center justify-center text-sm font-medium"> + {table.getState().pagination.pageIndex + 1} /{" "} + {table.getPageCount()} + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + {"<<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지</span> + {"<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지</span> + {">"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + {">>"} + </Button> + </div> + </div> + </div> + </div> + )} + </div> + + <DialogFooter className="gap-2 flex-shrink-0"> + <Button variant="outline" onClick={() => setOpen(false)}> + 닫기 + </Button> + <Button onClick={loadData} disabled={isLoading} variant="outline"> + {isLoading ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + 로딩 중... + </> + ) : ( + "새로고침" + )} + </Button> + <Button + onClick={handleConfirm} + disabled={ + requireSingleSelection + ? selectedRows.length !== 1 + : selectedRows.length === 0 + } + > + <Check className="mr-2 size-4" /> + 확인 ({selectedRows.length}) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 84fd85ff..a1b98468 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps { title?: string; description?: string; showConfirmButtons?: boolean; + disabled?: boolean; } /** @@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({ title = "1회성 품목 선택", description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, + disabled = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = @@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} size={triggerSize}> + <Button variant={triggerVariant} size={triggerSize} disabled={disabled}> {selectedProcurementItem ? ( <span className="truncate"> {`${selectedProcurementItem.itemCode}`} |
