diff options
Diffstat (limited to 'components')
44 files changed, 6460 insertions, 976 deletions
diff --git a/components/BidProjectSelector.tsx b/components/BidProjectSelector.tsx new file mode 100644 index 00000000..8e229b10 --- /dev/null +++ b/components/BidProjectSelector.tsx @@ -0,0 +1,124 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { getBidProjects, type Project } from "@/lib/rfqs/service" + +interface ProjectSelectorProps { + selectedProjectId?: number | null; + onProjectSelect: (project: Project) => void; + placeholder?: string; +} + +export function EstimateProjectSelector ({ + selectedProjectId, + onProjectSelect, + placeholder = "프로젝트 선택..." +}: ProjectSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [projects, setProjects] = React.useState<Project[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + + // 모든 프로젝트 데이터 로드 (한 번만) + React.useEffect(() => { + async function loadAllProjects() { + setIsLoading(true); + try { + const allProjects = await getBidProjects(); + setProjects(allProjects); + + // 초기 선택된 프로젝트가 있으면 설정 + if (selectedProjectId) { + const selected = allProjects.find(p => p.id === selectedProjectId); + if (selected) { + setSelectedProject(selected); + } + } + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error); + } finally { + setIsLoading(false); + } + } + + loadAllProjects(); + }, [selectedProjectId]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredProjects = React.useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowerSearch = searchTerm.toLowerCase(); + return projects.filter( + project => + project.projectCode.toLowerCase().includes(lowerSearch) || + project.projectName.toLowerCase().includes(lowerSearch) + ); + }, [projects, searchTerm]); + + // 프로젝트 선택 처리 + const handleSelectProject = (project: Project) => { + setSelectedProject(project); + onProjectSelect(project); + setOpen(false); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedProject + ? `${selectedProject.projectCode} - ${selectedProject.projectName}` + : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="프로젝트 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredProjects.map((project) => ( + <CommandItem + key={project.id} + value={`${project.projectCode} ${project.projectName}`} + onSelect={() => handleSelectProject(project)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{project.projectCode}</span> + <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index 2cd385c3..4a9a3379 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -41,7 +41,7 @@ import { cn } from "@/lib/utils" import { useTranslation } from "@/i18n/client" import { getVendorDetailById, downloadVendorAttachments, updateVendorInfo } from "@/lib/vendors/service" -import { updateVendorSchema, type UpdateVendorInfoSchema } from "@/lib/vendors/validations" +import { updateVendorSchema, updateVendorSchemaWithConditions, type UpdateVendorInfoSchema } from "@/lib/vendors/validations" import { Select, SelectContent, @@ -104,6 +104,14 @@ const creditRatingScaleMap: Record<string, string[]> = { SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], } +const cashFlowRatingScaleMap: Record<string, string[]> = { + NICE: ["우수", "양호", "보통", "미흡", "불량"], + KIS: ["A+", "A", "B+", "B", "C", "D"], + KED: ["1등급", "2등급", "3등급", "4등급", "5등급"], + SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], +} + + const MAX_FILE_SIZE = 3e9 // 파일 타입 정의 @@ -124,7 +132,7 @@ export function InfoForm() { const companyId = session?.user?.companyId || "17" - // 벤더 데이터 상태 + // 협력업체 데이터 상태 const [vendor, setVendor] = React.useState<any>(null) const [isLoading, setIsLoading] = React.useState(true) const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -137,10 +145,11 @@ export function InfoForm() { const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) const [creditRatingFile, setCreditRatingFile] = React.useState<File[]>([]) const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState<File[]>([]) + const [isDownloading, setIsDownloading] = React.useState(false); // React Hook Form const form = useForm<UpdateVendorInfoSchema>({ - resolver: zodResolver(updateVendorSchema), + resolver: zodResolver(updateVendorSchemaWithConditions), defaultValues: { vendorName: "", taxId: "", @@ -181,21 +190,21 @@ export function InfoForm() { name: "contacts", }) - // 벤더 정보 가져오기 + // 협력업체 정보 가져오기 React.useEffect(() => { async function fetchVendorData() { if (!companyId) return try { setIsLoading(true) - // 벤더 상세 정보 가져오기 (view 사용) + // 협력업체 상세 정보 가져오기 (view 사용) const vendorData = await getVendorDetailById(Number(companyId)) if (!vendorData) { toast({ variant: "destructive", title: "오류", - description: "벤더 정보를 찾을 수 없습니다.", + description: "협력업체 정보를 찾을 수 없습니다.", }) return } @@ -258,7 +267,7 @@ export function InfoForm() { toast({ variant: "destructive", title: "데이터 로드 오류", - description: "벤더 정보를 불러오는 중 오류가 발생했습니다.", + description: "협력업체 정보를 불러오는 중 오류가 발생했습니다.", }) } finally { setIsLoading(false) @@ -268,43 +277,82 @@ export function InfoForm() { fetchVendorData() }, [companyId, form, replaceContacts]) - // 파일 다운로드 처리 - const handleDownloadFile = async (fileId: number) => { + const handleDownloadFile = async (file: AttachmentFile) => { try { - const downloadInfo = await downloadVendorAttachments(Number(companyId), fileId) - - if (downloadInfo && downloadInfo.url) { - // 브라우저에서 다운로드 링크 열기 - window.open(downloadInfo.url, '_blank') - } - } catch (error) { - console.error("Error downloading file:", error) + setIsDownloading(true); + + // 파일이 객체인지 ID인지 확인하고 처리 + const fileId = typeof file === 'object' ? file.id : file; + const fileName = typeof file === 'object' ? file.fileName : `file-${fileId}`; + + // 다운로드 링크 생성 (URL 인코딩 적용) + const downloadUrl = `/api/vendors/attachments/download?id=${fileId}&vendorId=${Number(companyId)}`; + + // a 태그를 사용한 다운로드 + const downloadLink = document.createElement('a'); + downloadLink.href = downloadUrl; + downloadLink.download = fileName; + downloadLink.target = '_blank'; // 추가: 새 탭에서 열도록 설정 (일부 브라우저에서 더 안정적) + document.body.appendChild(downloadLink); + downloadLink.click(); + + // 정리 (메모리 누수 방지) + setTimeout(() => { + document.body.removeChild(downloadLink); + }, 100); + toast({ - variant: "destructive", - title: "다운로드 오류", - description: "파일 다운로드 중 오류가 발생했습니다.", - }) - } - } - - // 모든 첨부파일 다운로드 - const handleDownloadAllFiles = async () => { - try { - const downloadInfo = await downloadVendorAttachments(Number(companyId)) - - if (downloadInfo && downloadInfo.url) { - window.open(downloadInfo.url, '_blank') - } + title: "다운로드 시작", + description: "파일 다운로드가 시작되었습니다.", + }); } catch (error) { - console.error("Error downloading files:", error) + console.error("Error downloading file:", error); toast({ variant: "destructive", title: "다운로드 오류", description: "파일 다운로드 중 오류가 발생했습니다.", - }) + }); + } finally { + setIsDownloading(false); } + }; + + // 전체 파일 다운로드 함수 +const handleDownloadAllFiles = async () => { + try { + setIsDownloading(true); + + // 다운로드 URL 생성 + const downloadUrl = `/api/vendors/attachments/download-all?vendorId=${Number(companyId)}`; + + // a 태그를 사용한 다운로드 + const downloadLink = document.createElement('a'); + downloadLink.href = downloadUrl; + downloadLink.download = `vendor-${companyId}-files.zip`; + downloadLink.target = '_blank'; + document.body.appendChild(downloadLink); + downloadLink.click(); + + // 정리 + setTimeout(() => { + document.body.removeChild(downloadLink); + }, 100); + + toast({ + title: "다운로드 시작", + description: "전체 파일 다운로드가 시작되었습니다.", + }); + } catch (error) { + console.error("Error downloading files:", error); + toast({ + variant: "destructive", + title: "다운로드 오류", + description: "파일 다운로드 중 오류가 발생했습니다.", + }); + } finally { + setIsDownloading(false); } - +}; // Dropzone handlers const handleDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] @@ -476,7 +524,7 @@ export function InfoForm() { return ( <div className="container py-10 flex justify-center items-center"> <Loader2 className="h-8 w-8 animate-spin text-primary" /> - <span className="ml-2">벤더 정보를 불러오는 중입니다...</span> + <span className="ml-2">협력업체 정보를 불러오는 중입니다...</span> </div> ) } @@ -543,7 +591,7 @@ export function InfoForm() { </FileListDescription> </FileListInfo> <div className="flex items-center space-x-2"> - <FileListAction onClick={() => handleDownloadFile(file.id)}> + <FileListAction onClick={() => handleDownloadFile(file)}> <Download className="h-4 w-4" /> </FileListAction> <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> @@ -574,7 +622,7 @@ export function InfoForm() { </FileListDescription> </FileListInfo> <div className="flex items-center space-x-2"> - <FileListAction onClick={() => handleDownloadFile(file.id)}> + <FileListAction onClick={() => handleDownloadFile(file)}> <Download className="h-4 w-4" /> </FileListAction> <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> @@ -605,7 +653,7 @@ export function InfoForm() { </FileListDescription> </FileListInfo> <div className="flex items-center space-x-2"> - <FileListAction onClick={() => handleDownloadFile(file.id)}> + <FileListAction onClick={() => handleDownloadFile(file)}> <Download className="h-4 w-4" /> </FileListAction> <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> @@ -1095,8 +1143,8 @@ export function InfoForm() { render={({ field }) => { const selectedAgency = form.watch("creditAgency") const ratingScale = - creditRatingScaleMap[ - selectedAgency as keyof typeof creditRatingScaleMap + cashFlowRatingScaleMap[ + selectedAgency as keyof typeof cashFlowRatingScaleMap ] || [] return ( <FormItem> diff --git a/components/client-data-table/data-table-toolbar.tsx b/components/client-data-table/data-table-toolbar.tsx index 286cffd6..6c246c1a 100644 --- a/components/client-data-table/data-table-toolbar.tsx +++ b/components/client-data-table/data-table-toolbar.tsx @@ -33,24 +33,6 @@ export function ClientDataTableAdvancedToolbar<TData>({ ...props }: DataTableAdvancedToolbarProps<TData>) { - // 전체 엑셀 내보내기 - const handleExportAll = async () => { - try { - await exportTableToExcel(table, { - filename: "my-data", - onlySelected: false, - excludeColumns: ["select", "actions", "validation", "requestedAmount", "update"], - useGroupHeader: false, - allPages: true, - - }) - } catch (err) { - console.error("Export error:", err) - // 필요하면 토스트나 알림 처리 - } - } - - return ( <div @@ -69,13 +51,6 @@ export function ClientDataTableAdvancedToolbar<TData>({ <ClientDataTableSortList table={table} /> <ClientDataTableViewOptions table={table} /> <ClientDataTableGroupList table={table}/> - <Button variant="outline" size="sm" onClick={handleExportAll}> - <Download className="size-4" /> - {/* i18n 버튼 문구 */} - <span className="hidden sm:inline"> - {"Export All" } - </span> - </Button> </div> {/* 오른쪽: Export 버튼 + children */} diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx index 9336db62..9067a475 100644 --- a/components/client-data-table/data-table.tsx +++ b/components/client-data-table/data-table.tsx @@ -64,7 +64,7 @@ export function ClientDataTable<TData, TValue>({ const [grouping, setGrouping] = React.useState<string[]>([]) const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({}) const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ - left: [], + left: ["TAG_NO", "TAG_DESC"], right: ["update"], }) diff --git a/components/data-table/data-table-advanced-toolbar.tsx b/components/data-table/data-table-advanced-toolbar.tsx index 7c126c51..256dc125 100644 --- a/components/data-table/data-table-advanced-toolbar.tsx +++ b/components/data-table/data-table-advanced-toolbar.tsx @@ -3,6 +3,8 @@ import * as React from "react" import type { DataTableAdvancedFilterField } from "@/types/table" import { type Table } from "@tanstack/react-table" +import { LayoutGrid, TableIcon } from "lucide-react" +import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { DataTableFilterList } from "@/components/data-table/data-table-filter-list" @@ -14,6 +16,36 @@ import { PinRightButton } from "./data-table-pin-right" import { DataTableGlobalFilter } from "./data-table-grobal-filter" import { DataTableGroupList } from "./data-table-group-list" +// 로컬 스토리지 사용을 위한 훅 +const useLocalStorage = <T,>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => { + const [storedValue, setStoredValue] = React.useState<T>(() => { + if (typeof window === "undefined") { + return initialValue + } + try { + const item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : initialValue + } catch (error) { + console.error(error) + return initialValue + } + }) + + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value + setStoredValue(valueToStore) + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + } + } catch (error) { + console.error(error) + } + } + + return [storedValue, setValue] +} + interface DataTableAdvancedToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> { /** @@ -58,6 +90,29 @@ interface DataTableAdvancedToolbarProps<TData> * @default true */ shallow?: boolean + + /** + * 컴팩트 모드를 사용할지 여부 (토글 버튼을 숨기려면 null) + * @default true + */ + enableCompactToggle?: boolean | null + + /** + * 초기 컴팩트 모드 상태 + * @default false + */ + initialCompact?: boolean + + /** + * 컴팩트 모드가 변경될 때 호출될 콜백 함수 + */ + onCompactChange?: (isCompact: boolean) => void + + /** + * 컴팩트 모드 상태를 저장할 로컬 스토리지 키 + * @default "dataTableCompact" + */ + compactStorageKey?: string } export function DataTableAdvancedToolbar<TData>({ @@ -65,10 +120,30 @@ export function DataTableAdvancedToolbar<TData>({ filterFields = [], debounceMs = 300, shallow = true, + enableCompactToggle = true, + initialCompact = false, + onCompactChange, + compactStorageKey = "dataTableCompact", children, className, ...props }: DataTableAdvancedToolbarProps<TData>) { + // 컴팩트 모드 상태 관리 + const [isCompact, setIsCompact] = useLocalStorage<boolean>( + compactStorageKey, + initialCompact + ) + + // 컴팩트 모드 변경 시 콜백 호출 + React.useEffect(() => { + onCompactChange?.(isCompact) + }, [isCompact, onCompactChange]) + + // 컴팩트 모드 토글 핸들러 + const handleToggleCompact = React.useCallback(() => { + setIsCompact(prev => !prev) + }, [setIsCompact]) + return ( <div className={cn( @@ -78,7 +153,19 @@ export function DataTableAdvancedToolbar<TData>({ {...props} > <div className="flex items-center gap-2"> - <DataTableViewOptions table={table} /> + {enableCompactToggle && ( + <Button + variant="outline" + size="sm" + onClick={handleToggleCompact} + title={isCompact ? "확장 보기로 전환" : "컴팩트 보기로 전환"} + className="h-8 px-2" + > + {isCompact ? <LayoutGrid size={16} /> : <TableIcon size={16} />} + {/* <span className="ml-2 text-xs">{isCompact ? "확장 보기" : "컴팩트 보기"}</span> */} + </Button> + )} + <DataTableViewOptions table={table} /> <DataTableFilterList table={table} filterFields={filterFields} @@ -90,15 +177,16 @@ export function DataTableAdvancedToolbar<TData>({ debounceMs={debounceMs} shallow={shallow} /> - <DataTableGroupList table={table} debounceMs={debounceMs}/> - <PinLeftButton table={table}/> - <PinRightButton table={table}/> + <DataTableGroupList table={table} debounceMs={debounceMs} /> + <PinLeftButton table={table} /> + <PinRightButton table={table} /> <DataTableGlobalFilter /> </div> <div className="flex items-center gap-2"> - {children} + {/* 컴팩트 모드 토글 버튼 */} + {children} </div> </div> ) -} +}
\ No newline at end of file diff --git a/components/data-table/data-table-compact-toggle.tsx b/components/data-table/data-table-compact-toggle.tsx new file mode 100644 index 00000000..5c162a03 --- /dev/null +++ b/components/data-table/data-table-compact-toggle.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { TableIcon, LayoutGrid } from "lucide-react" + +interface DataTableCompactToggleProps { + /** + * 현재 컴팩트 모드 상태 + */ + isCompact: boolean + + /** + * 컴팩트 모드 토글 시 호출될 함수 + */ + onToggleCompact: () => void +} + +export function DataTableCompactToggle({ + isCompact, + onToggleCompact +}: DataTableCompactToggleProps) { + return ( + <Button + variant="ghost" + size="sm" + onClick={onToggleCompact} + title={isCompact ? "확장 보기로 전환" : "컴팩트 보기로 전환"} + className="h-8 px-2" + > + {isCompact ? <LayoutGrid size={16} /> : <TableIcon size={16} />} + <span className="ml-2 text-xs">{isCompact ? "확장 보기" : "컴팩트 보기"}</span> + </Button> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx index c51d4374..db9f8af9 100644 --- a/components/data-table/data-table-filter-list.tsx +++ b/components/data-table/data-table-filter-list.tsx @@ -82,7 +82,7 @@ export function DataTableFilterList<TData>({ }: DataTableFilterListProps<TData>) { const params = useParams(); - const lng = params.lng as string; + const lng = params ? (params.lng as string) : 'en'; const { t, i18n } = useTranslation(lng); diff --git a/components/data-table/data-table-group-list.tsx b/components/data-table/data-table-group-list.tsx index cde1cadd..fcae9a79 100644 --- a/components/data-table/data-table-group-list.tsx +++ b/components/data-table/data-table-group-list.tsx @@ -156,7 +156,7 @@ export function DataTableGroupList<TData>({ aria-controls={`${id}-group-dialog`} > <Layers className="size-3" aria-hidden="true" /> - <span className="hidden sm:inline">Group</span> + <span className="hidden sm:inline">그룹</span> {uniqueGrouping.length > 0 && ( <Badge variant="secondary" diff --git a/components/data-table/data-table-pin-left.tsx b/components/data-table/data-table-pin-left.tsx index 81e83564..e79e01eb 100644 --- a/components/data-table/data-table-pin-left.tsx +++ b/components/data-table/data-table-pin-left.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { type Table } from "@tanstack/react-table" +import { type Column, type Table } from "@tanstack/react-table" import { Check, ChevronsUpDown, MoveLeft } from "lucide-react" import { cn, toSentenceCase } from "@/lib/utils" @@ -13,6 +13,7 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, } from "@/components/ui/command" import { Popover, @@ -21,14 +22,152 @@ import { } from "@/components/ui/popover" /** - * “Pin Left” Popover. Lists columns that can be pinned. - * If pinned===‘left’ → checked, if pinned!==‘left’ → unchecked. - * Toggling check => pin(‘left’) or pin(false). + * Helper function to check if a column is a parent column (has subcolumns) + */ +function isParentColumn<TData>(column: Column<TData>): boolean { + return column.columns && column.columns.length > 0 +} + +/** + * Helper function to pin all subcolumns of a parent column + */ +function pinSubColumns<TData>( + column: Column<TData>, + pinType: false | "left" | "right" +): void { + // If this is a parent column, pin all its subcolumns + if (isParentColumn(column)) { + column.columns.forEach((subColumn) => { + // Recursively handle nested columns + pinSubColumns(subColumn, pinType) + }) + } else { + // For leaf columns, apply the pin if possible + if (column.getCanPin?.()) { + column.pin?.(pinType) + } + } +} + +/** + * Checks if all subcolumns of a parent column are pinned to the specified side + */ +function areAllSubColumnsPinned<TData>( + column: Column<TData>, + pinType: "left" | "right" +): boolean { + if (isParentColumn(column)) { + // Check if all subcolumns are pinned + return column.columns.every((subColumn) => + areAllSubColumnsPinned(subColumn, pinType) + ) + } else { + // For leaf columns, check if it's pinned to the specified side + return column.getIsPinned?.() === pinType + } +} + +/** + * Helper function to get the display name of a column + */ +function getColumnDisplayName<TData>(column: Column<TData>): string { + // First try to use excelHeader from meta if available + const excelHeader = column.columnDef.meta?.excelHeader + if (excelHeader) { + return excelHeader + } + + // Fall back to converting the column ID to sentence case + return toSentenceCase(column.id) +} + +/** + * Array of column IDs that should be auto-pinned to the left when available + */ +const AUTO_PIN_LEFT_COLUMNS = ['select'] + +/** + * "Pin Left" Popover. Supports pinning both individual columns and header groups. */ export function PinLeftButton<TData>({ table }: { table: Table<TData> }) { const [open, setOpen] = React.useState(false) const triggerRef = React.useRef<HTMLButtonElement>(null) + // Try to auto-pin select and action columns if they exist + React.useEffect(() => { + AUTO_PIN_LEFT_COLUMNS.forEach((columnId) => { + const column = table.getColumn(columnId) + if (column?.getCanPin?.()) { + column.pin?.("left") + } + }) + }, [table]) + + // Get all columns that can be pinned (excluding auto-pinned columns) + const pinnableColumns = React.useMemo(() => { + return table.getAllColumns().filter((column) => { + // Skip auto-pinned columns + if (AUTO_PIN_LEFT_COLUMNS.includes(column.id)) { + return false + } + + // If it's a leaf column, check if it can be pinned + if (!isParentColumn(column)) { + return column.getCanPin?.() + } + + // If it's a parent column, check if at least one subcolumn can be pinned + return column.columns.some((subCol) => { + if (isParentColumn(subCol)) { + // Recursively check nested columns + return subCol.columns.some(c => c.getCanPin?.()) + } + return subCol.getCanPin?.() + }) + }) + }, [table]) + + // Get flat list of all leaf columns for display + const allPinnableLeafColumns = React.useMemo(() => { + const leafColumns: Column<TData>[] = [] + + // Function to recursively collect leaf columns + const collectLeafColumns = (column: Column<TData>) => { + if (isParentColumn(column)) { + column.columns.forEach(collectLeafColumns) + } else if (column.getCanPin?.() && !AUTO_PIN_LEFT_COLUMNS.includes(column.id)) { + leafColumns.push(column) + } + } + + // Process all columns + table.getAllColumns().forEach(collectLeafColumns) + + return leafColumns + }, [table]) + + // Handle column pinning + const handleColumnPin = React.useCallback((column: Column<TData>) => { + // For parent columns, pin/unpin all subcolumns + if (isParentColumn(column)) { + const allPinned = areAllSubColumnsPinned(column, "left") + pinSubColumns(column, allPinned ? false : "left") + } else { + // For leaf columns, toggle pin state + const isPinned = column.getIsPinned?.() === "left" + column.pin?.(isPinned ? false : "left") + } + }, []) + + // Check if a column or its subcolumns are pinned left + const isColumnPinned = React.useCallback((column: Column<TData>): boolean => { + if (isParentColumn(column)) { + return areAllSubColumnsPinned(column, "left") + } else { + return column.getIsPinned?.() === "left" + } + }, []) + return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> @@ -39,53 +178,105 @@ export function PinLeftButton<TData>({ table }: { table: Table<TData> }) { className="h-8 gap-2" > <MoveLeft className="size-4" /> - + <span className="hidden sm:inline"> - Left + 왼쪽 고정 </span> - <ChevronsUpDown className="ml-1 size-4 opacity-50 hidden sm:inline" /> </Button> </PopoverTrigger> - + <PopoverContent align="end" - className="w-44 p-0" + className="w-56 p-0" onCloseAutoFocus={() => triggerRef.current?.focus()} > <Command> <CommandInput placeholder="Search columns..." /> - <CommandList> + <CommandList className="max-h-[300px]"> <CommandEmpty>No columns found.</CommandEmpty> <CommandGroup> - {table - .getAllLeafColumns() - .filter((col) => col.getCanPin?.()) - .map((column) => { - const pinned = column.getIsPinned?.() // 'left'|'right'|false - // => pinned === 'left' => checked - return ( + {/* Parent Columns with subcolumns */} + {pinnableColumns + .filter(isParentColumn) + .map((parentColumn) => ( + <React.Fragment key={parentColumn.id}> + {/* Parent column header - can pin/unpin all children at once */} <CommandItem - key={column.id} onSelect={() => { - // if currently pinned===left => unpin - // else => pin left - column.pin?.(pinned === "left" ? false : "left") + handleColumnPin(parentColumn) }} + className="font-medium bg-muted/50" > <span className="truncate"> - {toSentenceCase(column.id)} + {getColumnDisplayName(parentColumn)} </span> - {/* Check if pinned===‘left’ */} <Check className={cn( "ml-auto size-4 shrink-0", - pinned === "left" ? "opacity-100" : "opacity-0" + isColumnPinned(parentColumn) ? "opacity-100" : "opacity-0" )} /> </CommandItem> - ) - })} + + {/* Individual subcolumns */} + {parentColumn.columns + .filter(col => !isParentColumn(col) && col.getCanPin?.()) + .map(subColumn => ( + <CommandItem + key={subColumn.id} + onSelect={() => { + handleColumnPin(subColumn) + }} + className="pl-6 text-sm" + > + <span className="truncate"> + {getColumnDisplayName(subColumn)} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + isColumnPinned(subColumn) ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </React.Fragment> + ))} + </CommandGroup> + + {/* Separator if we have both parent columns and standalone leaf columns */} + {pinnableColumns.some(isParentColumn) && + allPinnableLeafColumns.some(col => !pinnableColumns.find(parent => + isParentColumn(parent) && parent.columns.includes(col)) + ) && ( + <CommandSeparator /> + )} + + {/* Standalone leaf columns (not part of any parent column group) */} + <CommandGroup> + {allPinnableLeafColumns + .filter(col => !pinnableColumns.find(parent => + isParentColumn(parent) && parent.columns.includes(col) + )) + .map((column) => ( + <CommandItem + key={column.id} + onSelect={() => { + handleColumnPin(column) + }} + > + <span className="truncate"> + {getColumnDisplayName(column)} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + isColumnPinned(column) ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} </CommandGroup> </CommandList> </Command> diff --git a/components/data-table/data-table-pin-right.tsx b/components/data-table/data-table-pin-right.tsx index 3ed42402..ad52e44d 100644 --- a/components/data-table/data-table-pin-right.tsx +++ b/components/data-table/data-table-pin-right.tsx @@ -68,15 +68,48 @@ function areAllSubColumnsPinned<TData>( } /** + * Helper function to get the display name of a column + */ +function getColumnDisplayName<TData>(column: Column<TData>): string { + // First try to use excelHeader from meta if available + const excelHeader = column.columnDef.meta?.excelHeader + if (excelHeader) { + return excelHeader + } + + // Fall back to converting the column ID to sentence case + return toSentenceCase(column.id) +} + +/** + * Array of column IDs that should be auto-pinned to the right when available + */ +const AUTO_PIN_RIGHT_COLUMNS = ['actions'] + +/** * "Pin Right" Popover. Supports pinning both individual columns and header groups. */ export function PinRightButton<TData>({ table }: { table: Table<TData> }) { const [open, setOpen] = React.useState(false) const triggerRef = React.useRef<HTMLButtonElement>(null) - // Get all columns that can be pinned, including parent columns + // Try to auto-pin actions columns if they exist + React.useEffect(() => { + AUTO_PIN_RIGHT_COLUMNS.forEach((columnId) => { + const column = table.getColumn(columnId) + if (column?.getCanPin?.()) { + column.pin?.("right") + } + }) + }, [table]) + // Get all columns that can be pinned (excluding auto-pinned columns) const pinnableColumns = React.useMemo(() => { return table.getAllColumns().filter((column) => { + // Skip auto-pinned columns + if (AUTO_PIN_RIGHT_COLUMNS.includes(column.id)) { + return false + } + // If it's a leaf column, check if it can be pinned if (!isParentColumn(column)) { return column.getCanPin?.() @@ -93,6 +126,25 @@ export function PinRightButton<TData>({ table }: { table: Table<TData> }) { }) }, [table]) + // Get flat list of all leaf columns for display + const allPinnableLeafColumns = React.useMemo(() => { + const leafColumns: Column<TData>[] = [] + + // Function to recursively collect leaf columns + const collectLeafColumns = (column: Column<TData>) => { + if (isParentColumn(column)) { + column.columns.forEach(collectLeafColumns) + } else if (column.getCanPin?.() && !AUTO_PIN_RIGHT_COLUMNS.includes(column.id)) { + leafColumns.push(column) + } + } + + // Process all columns + table.getAllColumns().forEach(collectLeafColumns) + + return leafColumns + }, [table]) + // Handle column pinning const handleColumnPin = React.useCallback((column: Column<TData>) => { // For parent columns, pin/unpin all subcolumns @@ -127,7 +179,7 @@ export function PinRightButton<TData>({ table }: { table: Table<TData> }) { <MoveRight className="size-4" /> <span className="hidden sm:inline"> - Right + 오른 고정 </span> <ChevronsUpDown className="ml-1 size-4 opacity-50 hidden sm:inline" /> </Button> @@ -140,42 +192,72 @@ export function PinRightButton<TData>({ table }: { table: Table<TData> }) { > <Command> <CommandInput placeholder="Search columns..." /> - <CommandList> + <CommandList className="max-h-[300px]"> <CommandEmpty>No columns found.</CommandEmpty> <CommandGroup> - {/* Header Columns (Parent Columns) */} + {/* Parent Columns with subcolumns */} {pinnableColumns .filter(isParentColumn) - .map((column) => ( - <CommandItem - key={column.id} - onSelect={() => { - handleColumnPin(column) - }} - className="font-medium" - > - <span className="truncate"> - {column.id === "Basic Info" || column.id === "Metadata" - ? column.id // Use column ID directly for common groups - : toSentenceCase(column.id)} - </span> - <Check - className={cn( - "ml-auto size-4 shrink-0", - isColumnPinned(column) ? "opacity-100" : "opacity-0" - )} - /> - </CommandItem> + .map((parentColumn) => ( + <React.Fragment key={parentColumn.id}> + {/* Parent column header - can pin/unpin all children at once */} + <CommandItem + onSelect={() => { + handleColumnPin(parentColumn) + }} + className="font-medium bg-muted/50" + > + <span className="truncate"> + {getColumnDisplayName(parentColumn)} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + isColumnPinned(parentColumn) ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + + {/* Individual subcolumns */} + {parentColumn.columns + .filter(col => !isParentColumn(col) && col.getCanPin?.()) + .map(subColumn => ( + <CommandItem + key={subColumn.id} + onSelect={() => { + handleColumnPin(subColumn) + }} + className="pl-6 text-sm" + > + <span className="truncate"> + {getColumnDisplayName(subColumn)} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + isColumnPinned(subColumn) ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </React.Fragment> ))} - - {pinnableColumns.some(isParentColumn) && - pinnableColumns.some(col => !isParentColumn(col)) && ( - <CommandSeparator /> - )} - - {/* Leaf Columns (individual columns) */} - {pinnableColumns - .filter(col => !isParentColumn(col)) + </CommandGroup> + + {/* Separator if we have both parent columns and standalone leaf columns */} + {pinnableColumns.some(isParentColumn) && + allPinnableLeafColumns.some(col => !pinnableColumns.find(parent => + isParentColumn(parent) && parent.columns.includes(col)) + ) && ( + <CommandSeparator /> + )} + + {/* Standalone leaf columns (not part of any parent column group) */} + <CommandGroup> + {allPinnableLeafColumns + .filter(col => !pinnableColumns.find(parent => + isParentColumn(parent) && parent.columns.includes(col) + )) .map((column) => ( <CommandItem key={column.id} @@ -184,7 +266,7 @@ export function PinRightButton<TData>({ table }: { table: Table<TData> }) { }} > <span className="truncate"> - {toSentenceCase(column.id)} + {getColumnDisplayName(column)} </span> <Check className={cn( diff --git a/components/data-table/data-table-sort-list.tsx b/components/data-table/data-table-sort-list.tsx index 686545fc..c3c537ac 100644 --- a/components/data-table/data-table-sort-list.tsx +++ b/components/data-table/data-table-sort-list.tsx @@ -167,7 +167,7 @@ export function DataTableSortList<TData>({ <ArrowDownUp className="size-3" aria-hidden="true" /> <span className="hidden sm:inline"> - Sort + 정렬 </span> {uniqueSorting.length > 0 && ( diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx index c55617ec..69666237 100644 --- a/components/data-table/data-table-view-options.tsx +++ b/components/data-table/data-table-view-options.tsx @@ -24,6 +24,12 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" // Sortable import { @@ -36,9 +42,11 @@ import { /** * ViewOptionsProps: * - table: TanStack Table instance + * - resetAutoSize: Function to reset autosize calculations (optional) */ interface DataTableViewOptionsProps<TData> { table: Table<TData> + resetAutoSize?: () => void } declare module "@tanstack/table-core" { @@ -56,13 +64,12 @@ declare module "@tanstack/table-core" { */ export function DataTableViewOptions<TData>({ table, + resetAutoSize, }: DataTableViewOptionsProps<TData>) { const triggerRef = React.useRef<HTMLButtonElement>(null) // 1) Identify columns that can be hidden const hideableCols = React.useMemo(() => { - - return table .getAllLeafColumns() .filter((col) => col.getCanHide()) @@ -103,7 +110,10 @@ export function DataTableViewOptions<TData>({ // Now we set the table's official column order table.setColumnOrder(finalOrder) - }, [columnOrder, hideableCols, table]) + + // Reset auto-size when column order changes + resetAutoSize?.() + }, [columnOrder, hideableCols, table, resetAutoSize]) return ( @@ -118,7 +128,7 @@ export function DataTableViewOptions<TData>({ className="gap-2" > <Settings2 className="size-4" /> - <span className="hidden sm:inline">View</span> + <span className="hidden sm:inline">보기</span> <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50 hidden sm:inline" /> </Button> </PopoverTrigger> @@ -145,16 +155,19 @@ export function DataTableViewOptions<TData>({ {columnOrder.map((colId) => { // find column instance const column = hideableCols.find((c) => c.id === colId) - if (!column) return null + const columnLabel = column?.columnDef?.meta?.excelHeader || column.id + return ( <SortableItem key={colId} value={colId} asChild> <CommandItem - onSelect={() => + onSelect={() => { column.toggleVisibility(!column.getIsVisible()) - } + // Reset autosize calculations when toggling columns + resetAutoSize?.() + }} > {/* Drag handle on the left */} <SortableDragHandle @@ -165,10 +178,19 @@ export function DataTableViewOptions<TData>({ <GripVertical className="size-3.5" aria-hidden="true" /> </SortableDragHandle> - {/* label */} - <span className="truncate"> - {column?.columnDef?.meta?.excelHeader} - </span> + {/* label with tooltip for long names */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="truncate"> + {columnLabel} + </span> + </TooltipTrigger> + <TooltipContent> + {columnLabel} + </TooltipContent> + </Tooltip> + </TooltipProvider> {/* check if visible */} <Check diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index b1027cc0..5aeefe21 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -22,15 +22,17 @@ interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> { table: TanstackTable<TData> floatingBar?: React.ReactNode | null autoSizeColumns?: boolean + compact?: boolean // 컴팩트 모드 옵션 추가 } /** - * 멀티 그룹핑 + 그룹 토글 + 그룹 컬럼/헤더 숨김 + Indent + 리사이징 + * 멀티 그룹핑 + 그룹 토글 + 그룹 컬럼/헤더 숨김 + Indent + 리사이징 + 컴팩트 모드 */ export function DataTable<TData>({ table, floatingBar = null, autoSizeColumns = true, + compact = false, // 기본값은 false로 설정 children, className, ...props @@ -38,20 +40,29 @@ export function DataTable<TData>({ useAutoSizeColumns(table, autoSizeColumns) + // 컴팩트 모드를 위한 클래스 정의 + const compactStyles = compact ? { + row: "h-7", // 행 높이 축소 + cell: "py-1 px-2 text-sm", // 셀 패딩 축소 및 폰트 크기 조정 + groupRow: "py-1 bg-muted/20 text-sm", // 그룹 행 패딩 축소 + emptyRow: "h-16", // 데이터 없을 때 행 높이 조정 + } : { + row: "", + cell: "", + groupRow: "bg-muted/20", + emptyRow: "h-24", + } + return ( <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}> {children} - <div className="max-w-[100vw] overflow-auto" style={{ maxHeight: '36.1rem' }}> + <div className="max-w-[100vw] overflow-auto" style={{ maxHeight: '35rem' }}> <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> - {/* ------------------------------- - Table Header - → 그룹핑된 컬럼의 헤더는 숨김 처리 - ------------------------------- */} + {/* 테이블 헤더 */} <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id}> + <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}> {headerGroup.headers.map((header) => { - // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 if (header.column.getIsGrouped()) { return null } @@ -61,10 +72,10 @@ export function DataTable<TData>({ key={header.id} colSpan={header.colSpan} data-column-id={header.column.id} + className={compact ? "py-1 px-2 text-sm" : ""} style={{ ...getCommonPinningStyles({ column: header.column }), - width: header.getSize(), // 리사이징을 위한 너비 설정 - // position: "relative" // 리사이저를 위한 포지셔닝 + width: header.getSize(), }} > <div style={{ position: "relative" }}> @@ -75,7 +86,6 @@ export function DataTable<TData>({ header.getContext() )} - {/* 리사이즈 핸들 - 별도의 컴포넌트로 분리 */} {header.column.getCanResize() && ( <DataTableResizer header={header} /> )} @@ -87,21 +97,15 @@ export function DataTable<TData>({ ))} </TableHeader> - {/* ------------------------------- - Table Body - ------------------------------- */} + {/* 테이블 바디 */} <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => { - // --------------------------------------------------- - // 1) "그룹핑 헤더" Row인지 확인 - // --------------------------------------------------- + // 그룹핑 헤더 Row if (row.getIsGrouped()) { - // row.groupingColumnId로 어떤 컬럼을 기준으로 그룹화 되었는지 알 수 있음 const groupingColumnId = row.groupingColumnId ?? "" - const groupingColumn = table.getColumn(groupingColumnId) // 해당 column 객체 + const groupingColumn = table.getColumn(groupingColumnId) - // 컬럼 라벨 가져오기 let columnLabel = groupingColumnId if (groupingColumn) { const headerDef = groupingColumn.columnDef.meta?.excelHeader @@ -113,30 +117,29 @@ export function DataTable<TData>({ return ( <TableRow key={row.id} - className="bg-muted/20" + className={compactStyles.groupRow} data-state={row.getIsExpanded() && "expanded"} > - {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} - <TableCell colSpan={table.getVisibleFlatColumns().length}> - {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} + <TableCell + colSpan={table.getVisibleFlatColumns().length} + className={compact ? "py-1 px-2" : ""} + > {row.getCanExpand() && ( <button onClick={row.getToggleExpandedHandler()} className="inline-flex items-center justify-center mr-2 w-5 h-5" style={{ - // row.depth: 0이면 top-level, 1이면 그 하위 등 marginLeft: `${row.depth * 1.5}rem`, }} > {row.getIsExpanded() ? ( - <ChevronUp size={16} /> + <ChevronUp size={compact ? 14 : 16} /> ) : ( - <ChevronRight size={16} /> + <ChevronRight size={compact ? 14 : 16} /> )} </button> )} - {/* Group Label + 값 */} <span className="font-semibold"> {columnLabel}: {row.getValue(groupingColumnId)} </span> @@ -148,17 +151,14 @@ export function DataTable<TData>({ ) } - // --------------------------------------------------- - // 2) 일반 Row - // → "그룹핑된 컬럼"은 숨긴다 - // --------------------------------------------------- + // 일반 Row return ( <TableRow key={row.id} + className={compactStyles.row} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => { - // 이 셀의 컬럼이 grouped라면 숨긴다 if (cell.column.getIsGrouped()) { return null } @@ -167,9 +167,10 @@ export function DataTable<TData>({ <TableCell key={cell.id} data-column-id={cell.column.id} + className={compactStyles.cell} style={{ ...getCommonPinningStyles({ column: cell.column }), - width: cell.column.getSize(), // 리사이징을 위한 너비 설정 + width: cell.column.getSize(), }} > {flexRender( @@ -183,13 +184,11 @@ export function DataTable<TData>({ ) }) ) : ( - // --------------------------------------------------- - // 3) 데이터가 없을 때 - // --------------------------------------------------- + // 데이터가 없을 때 <TableRow> <TableCell colSpan={table.getAllColumns().length} - className="h-24 text-center" + className={compactStyles.emptyRow + " text-center"} > No results. </TableCell> diff --git a/components/date-range-picker.tsx b/components/date-range-picker.tsx index 295160a5..a489d656 100644 --- a/components/date-range-picker.tsx +++ b/components/date-range-picker.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { format } from "date-fns" -import { CalendarIcon } from "lucide-react" +import { CalendarIcon, X } from "lucide-react" import { parseAsString, useQueryStates } from "nuqs" import { type DateRange } from "react-day-picker" @@ -59,6 +59,12 @@ interface DateRangePickerProps * @default true */ shallow?: boolean + + /** + * Show a clear button to reset the date range + * @default false + */ + showClearButton?: boolean } export function DateRangePicker({ @@ -67,6 +73,7 @@ export function DateRangePicker({ triggerVariant = "outline", triggerSize = "default", triggerClassName, + showClearButton = false, shallow = true, className, ...props @@ -97,6 +104,18 @@ export function DateRangePicker({ } }, [dateParams, defaultDateRange]) + // 날짜 초기화 함수 + const clearDates = (e: React.MouseEvent) => { + e.stopPropagation() // 팝오버 토글 방지 + void setDateParams({ + from: "", + to: "", + }) + } + + // 날짜가 선택되었는지 확인 + const hasSelectedDate = Boolean(date?.from || date?.to) + return ( <div className="grid gap-2"> <Popover> @@ -105,7 +124,7 @@ export function DateRangePicker({ variant={triggerVariant} size={triggerSize} className={cn( - "w-full justify-start gap-2 truncate text-left font-normal", + "relative w-full justify-start gap-2 truncate text-left font-normal", !date && "text-muted-foreground", triggerClassName )} @@ -123,6 +142,20 @@ export function DateRangePicker({ ) : ( <span>{placeholder}</span> )} + + {/* 초기화 버튼 */} + {showClearButton && hasSelectedDate && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full rounded-l-none px-3 hover:bg-background" + onClick={clearDates} + > + <X className="size-4" /> + <span className="sr-only">초기화</span> + </Button> + )} </Button> </PopoverTrigger> <PopoverContent className={cn("w-auto p-0", className)} {...props}> @@ -143,4 +176,4 @@ export function DateRangePicker({ </Popover> </div> ) -} +}
\ No newline at end of file diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx new file mode 100644 index 00000000..a327523b --- /dev/null +++ b/components/form-data/add-formTag-dialog.tsx @@ -0,0 +1,957 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm, useFieldArray } from "react-hook-form" +import { toast } from "sonner" +import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react" + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormField, + FormItem, + FormControl, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" + +import { createTagInForm } from "@/lib/tags/service" +import { + getFormTagTypeMappings, + getTagTypeByDescription, + getSubfieldsByTagTypeForForm +} from "@/lib/forms/services" + +// Form-specific tag mapping interface +interface FormTagMapping { + id: number; + tagTypeLabel: string; + classLabel: string; + formCode: string; + formName: string; + remark?: string | null; +} + +// Updated to support multiple rows +interface MultiTagFormValues { + class: string; + tagType: string; + rows: Array<{ + [key: string]: string; + tagNo: string; + description: string; + }>; +} + +// SubFieldDef for clarity +interface SubFieldDef { + name: string; + label: string; + type: string; + options?: { value: string; label: string }[]; + expression?: string; + delimiter?: string; +} + +interface AddFormTagDialogProps { + projectId: number; + formCode: string; + formName?: string; + contractItemId: number; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function AddFormTagDialog({ + projectId, + formCode, + formName, + contractItemId, + open: externalOpen, + onOpenChange: externalOnOpenChange +}: AddFormTagDialogProps) { + const router = useRouter() + + // Use external control if provided, otherwise use internal state + const [internalOpen, setInternalOpen] = React.useState(false); + const isOpen = externalOpen !== undefined ? externalOpen : internalOpen; + const setIsOpen = externalOnOpenChange || setInternalOpen; + + const [mappings, setMappings] = React.useState<FormTagMapping[]>([]) + const [selectedTagTypeLabel, setSelectedTagTypeLabel] = React.useState<string | null>(null) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // ID management for React keys + const selectIdRef = React.useRef(0) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + // --------------- + // Load Form Tag Mappings + // --------------- + React.useEffect(() => { + const loadMappings = async () => { + if (!formCode || !projectId) return; + + setIsLoadingClasses(true); + try { + const result = await getFormTagTypeMappings(formCode, projectId); + // Type safety casting + const typedMappings: FormTagMapping[] = result.map(item => ({ + id: item.id, + tagTypeLabel: item.tagTypeLabel, + classLabel: item.classLabel, + formCode: item.formCode, + formName: item.formName, + remark: item.remark + })); + setMappings(typedMappings); + } catch (err) { + toast.error("폼 태그 매핑 로드에 실패했습니다."); + } finally { + setIsLoadingClasses(false); + } + }; + + loadMappings(); + }, [formCode, projectId]); + + // Load mappings when dialog opens + React.useEffect(() => { + if (isOpen) { + const loadMappings = async () => { + if (!formCode || !projectId) return; + + setIsLoadingClasses(true); + try { + const result = await getFormTagTypeMappings(formCode, projectId); + // Type safety casting + const typedMappings: FormTagMapping[] = result.map(item => ({ + id: item.id, + tagTypeLabel: item.tagTypeLabel, + classLabel: item.classLabel, + formCode: item.formCode, + formName: item.formName, + remark: item.remark + })); + setMappings(typedMappings); + } catch (err) { + toast.error("폼 태그 매핑 로드에 실패했습니다."); + } finally { + setIsLoadingClasses(false); + } + }; + + loadMappings(); + } + }, [isOpen, formCode, projectId]); + + // --------------- + // react-hook-form with fieldArray support for multiple rows + // --------------- + const form = useForm<MultiTagFormValues>({ + defaultValues: { + tagType: "", + class: "", + rows: [{ + tagNo: "", + description: "" + }] + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "rows" + }); + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true); + try { + const { subFields: apiSubFields } = await getSubfieldsByTagTypeForForm(tagTypeCode, projectId); + const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })); + setSubFields(formattedSubFields); + + // Initialize the rows with these subfields + const currentRows = form.getValues("rows"); + const updatedRows = currentRows.map(row => { + const newRow = { ...row }; + formattedSubFields.forEach(field => { + if (!newRow[field.name]) { + newRow[field.name] = ""; + } + }); + return newRow; + }); + + form.setValue("rows", updatedRows); + return true; + } catch (err) { + toast.error("서브필드를 불러오는데 실패했습니다."); + setSubFields([]); + return false; + } finally { + setIsLoadingSubFields(false); + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classLabel: string) { + form.setValue("class", classLabel); + + // Find the mapping for this class + const mapping = mappings.find(m => m.classLabel === classLabel); + if (mapping) { + setSelectedTagTypeLabel(mapping.tagTypeLabel); + form.setValue("tagType", mapping.tagTypeLabel); + + // Get the tagTypeCode for this tagTypeLabel + try { + const tagType = await getTagTypeByDescription(mapping.tagTypeLabel, projectId); + if (tagType) { + setSelectedTagTypeCode(tagType.code); + await loadSubFieldsByTagTypeCode(tagType.code); + } else { + toast.error("선택한 태그 유형을 찾을 수 없습니다."); + } + } catch (error) { + toast.error("태그 유형 정보를 불러오는데 실패했습니다."); + } + } + } + + // --------------- + // Build TagNo from subfields automatically for each row + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + return; + } + + const subscription = form.watch((value) => { + if (!value.rows || subFields.length === 0) { + return; + } + + const rows = [...value.rows]; + rows.forEach((row, rowIndex) => { + if (!row) return; + + let combined = ""; + subFields.forEach((sf, idx) => { + const fieldValue = row[sf.name] || ""; + combined += fieldValue; + if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + combined += sf.delimiter; + } + }); + + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); + if (currentTagNo !== combined) { + form.setValue(`rows.${rowIndex}.tagNo`, combined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + } + }); + }); + + return () => subscription.unsubscribe(); + }, [subFields, form]); + + // --------------- + // Check if tag numbers are valid + // --------------- + const areAllTagNosValid = React.useMemo(() => { + const rows = form.getValues("rows"); + return rows.every(row => { + const tagNo = row.tagNo; + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }); + }, [form.watch()]); + + // --------------- + // Submit handler for multiple tags + // --------------- + async function onSubmit(data: MultiTagFormValues) { + if (!contractItemId || !projectId) { + toast.error("필요한 정보가 없습니다."); + return; + } + + setIsSubmitting(true); + try { + const successfulTags = []; + const failedTags = []; + + // Process each row + for (const row of data.rows) { + // Create tag data + const tagData = { + tagType: data.tagType, + class: data.class, + tagNo: row.tagNo, + description: row.description, + ...Object.fromEntries( + subFields.map(field => [field.name, row[field.name] || ""]) + ), + functionCode: row.functionCode || "", + seqNumber: row.seqNumber || "", + valveAcronym: row.valveAcronym || "", + processUnit: row.processUnit || "", + }; + + try { + const res = await createTagInForm(tagData, contractItemId, formCode); + if ("error" in res) { + failedTags.push({ tag: row.tagNo, error: res.error }); + } else { + successfulTags.push(row.tagNo); + } + } catch (err) { + failedTags.push({ tag: row.tagNo, error: "Unknown error" }); + } + } + + // Show results to the user + if (successfulTags.length > 0) { + toast.success(`${successfulTags.length}개의 태그가 성공적으로 생성되었습니다!`); + } + + if (failedTags.length > 0) { + console.log("Failed tags:", failedTags); + toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); + } + + // Refresh the page + router.refresh(); + + // Reset the form and close dialog if all successful + if (failedTags.length === 0) { + form.reset(); + setIsOpen(false); + } + } catch (err) { + toast.error("태그 생성 처리에 실패했습니다."); + } finally { + setIsSubmitting(false); + } + } + + // --------------- + // Add a new row + // --------------- + function addRow() { + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { + tagNo: "", + description: "" + }; + + // Add all subfields with empty values + subFields.forEach(field => { + newRow[field.name] = ""; + }); + + append(newRow); + + // Force form validation after row is added + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Duplicate row + // --------------- + function duplicateRow(index: number) { + const rowToDuplicate = form.getValues(`rows.${index}`); + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { ...rowToDuplicate }; + + // Clear the tagNo field as it will be auto-generated + newRow.tagNo = ""; + append(newRow); + + // Force form validation after row is duplicated + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Render Class field + // --------------- + function renderClassField(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + const buttonId = React.useMemo( + () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const popoverContentId = React.useMemo( + () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const commandId = React.useMemo( + () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + + // Get unique class labels from mappings + const classOptions = Array.from(new Set(mappings.map(m => m.classLabel))); + + return ( + <FormItem className="w-1/2"> + <FormLabel>Class</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>클래스 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {field.value || "클래스 선택..."} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="클래스 검색..." + /> + <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((className, optIndex) => { + if (!classOptionIdsRef.current[className]) { + classOptionIdsRef.current[className] = + `class-${className}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[className] + + return ( + <CommandItem + key={`${optionId}-${optIndex}`} + onSelect={() => { + field.onChange(className) + setPopoverOpen(false) + handleSelectClass(className) + }} + value={className} + className="truncate" + title={className} + > + <span className="truncate">{className}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === className ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render TagType field (readonly after class selection) + // --------------- + function renderTagTypeField(field: any) { + const isReadOnly = !!selectedTagTypeLabel + const inputId = React.useMemo( + () => + `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`, + [isReadOnly] + ) + + return ( + <FormItem className="w-1/2"> + <FormLabel>Tag Type</FormLabel> + <FormControl> + {isReadOnly ? ( + <div className="relative"> + <Input + key={`tag-type-readonly-${inputId}`} + {...field} + readOnly + className="h-9 bg-muted" + /> + </div> + ) : ( + <Input + key={`tag-type-placeholder-${inputId}`} + {...field} + readOnly + placeholder="클래스 선택시 자동으로 결정됩니다" + className="h-9 bg-muted" + /> + )} + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render the table of subfields + // --------------- + function renderTagTable() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> + <div className="ml-3 text-muted-foreground">필드 로딩 중...</div> + </div> + ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 이 태그 유형에 대한 필드가 없습니다. + </div> + ) + } + + if (subFields.length === 0) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요. + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3> + {!areAllTagNosValid && ( + <Badge variant="destructive" className="ml-2"> + 유효하지 않은 태그 존재 + </Badge> + )} + </div> + + {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */} + <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}> + <div className="min-w-full overflow-x-auto"> + <Table className="w-full table-fixed"> + <TableHeader className="sticky top-0 bg-muted z-10"> + <TableRow> + <TableHead className="w-10 text-center">#</TableHead> + <TableHead className="w-[120px]"> + <div className="font-medium">Tag No</div> + </TableHead> + <TableHead className="w-[180px]"> + <div className="font-medium">Description</div> + </TableHead> + + {/* Subfields */} + {subFields.map((field, fieldIndex) => ( + <TableHead + key={`header-${field.name}-${fieldIndex}`} + className="w-[120px]" + > + <div className="flex flex-col"> + <div className="font-medium" title={field.label}> + {field.label} + </div> + {field.expression && ( + <div className="text-[10px] text-muted-foreground truncate" title={field.expression}> + {field.expression} + </div> + )} + </div> + </TableHead> + ))} + + <TableHead className="w-[100px] text-center sticky right-0 bg-muted">Actions</TableHead> + </TableRow> + </TableHeader> + + <TableBody> + {fields.map((item, rowIndex) => ( + <TableRow + key={`row-${item.id}-${rowIndex}`} + className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"} + > + {/* Row number */} + <TableCell className="text-center text-muted-foreground font-mono"> + {rowIndex + 1} + </TableCell> + + {/* Tag No cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.tagNo`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className={cn( + "bg-muted h-8 w-full font-mono text-sm", + field.value?.includes("??") && "border-red-500 bg-red-50" + )} + title={field.value || ""} + /> + {field.value?.includes("??") && ( + <div className="absolute right-2 top-1/2 transform -translate-y-1/2"> + <Badge variant="destructive" className="text-xs"> + ! + </Badge> + </div> + )} + </div> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Description cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.description`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <Input + {...field} + className="h-8 w-full" + placeholder="항목 이름 입력" + title={field.value || ""} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Subfield cells */} + {subFields.map((sf, sfIndex) => ( + <TableCell + key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`} + className="p-1" + > + <FormField + control={form.control} + name={`rows.${rowIndex}.${sf.name}`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger + className="w-full h-8 truncate" + title={field.value || ""} + > + <SelectValue placeholder={`선택...`} className="truncate" /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + className="max-h-[200px]" + style={{ minWidth: "250px", maxWidth: "350px" }} + > + {sf.options?.map((opt, index) => ( + <SelectItem + key={`${rowIndex}-${sf.name}-${opt.value}-${index}`} + value={opt.value} + title={opt.label} + className="whitespace-normal py-2 break-words" + > + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + {...field} + className="h-8 w-full" + placeholder={`입력...`} + title={field.value || ""} + /> + )} + </FormControl> + </FormItem> + )} + /> + </TableCell> + ))} + + {/* Actions cell */} + <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]"> + <div className="flex justify-center space-x-1"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => duplicateRow(rowIndex)} + > + <Copy className="h-3.5 w-3.5 text-muted-foreground" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>행 복제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className={cn( + "h-7 w-7", + fields.length <= 1 && "opacity-50" + )} + onClick={() => fields.length > 1 && remove(rowIndex)} + disabled={fields.length <= 1} + > + <Trash2 className="h-3.5 w-3.5 text-red-500" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>행 삭제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + {/* 행 추가 버튼 */} + <Button + type="button" + variant="outline" + className="w-full border-dashed" + onClick={addRow} + disabled={!selectedTagTypeCode || isLoadingSubFields} + > + <Plus className="h-4 w-4 mr-2" /> + 새 행 추가 + </Button> + </div> + </div> + ); + } + + // --------------- + // Reset IDs/states when dialog closes + // --------------- + React.useEffect(() => { + if (!isOpen) { + fieldIdsRef.current = {} + classOptionIdsRef.current = {} + selectIdRef.current = 0 + } + }, [isOpen]) + + return ( + <Dialog + open={isOpen} + onOpenChange={(o) => { + if (!o) { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setSelectedTagTypeLabel(null); + setSelectedTagTypeCode(null); + setSubFields([]); + } + setIsOpen(o); + }} + > + {/* Only show the trigger if external control is not being used */} + {externalOnOpenChange === undefined && ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Plus className="mr-2 size-4" /> + 태그 추가 + </Button> + </DialogTrigger> + )} + + <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> + <DialogHeader> + <DialogTitle>폼 태그 추가 - {formName || formCode}</DialogTitle> + <DialogDescription> + 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 클래스 및 태그 유형 선택 */} + <div className="flex gap-4"> + <FormField + key="class-field" + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + <FormField + key="tag-type-field" + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + </div> + + {/* 태그 테이블 */} + {renderTagTable()} + + {/* 버튼 */} + <DialogFooter> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setIsOpen(false); + setSubFields([]); + setSelectedTagTypeLabel(null); + setSelectedTagTypeCode(null); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting || !areAllTagNosValid || fields.length < 1} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 처리 중... + </> + ) : ( + `${fields.length}개 태그 생성` + )} + </Button> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx new file mode 100644 index 00000000..c4010df2 --- /dev/null +++ b/components/form-data/export-excel-form.tsx @@ -0,0 +1,197 @@ +// lib/excelUtils.ts +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; + +// Define the column type enum +export type ColumnType = "STRING" | "NUMBER" | "LIST" | string; + +// Define the column structure +export interface DataTableColumnJSON { + key: string; + label: string; + type: ColumnType; + options?: string[]; + // Add any other properties that might be in columnsJSON +} + +// Define a generic data interface +export interface GenericData { + [key: string]: any; + TAG_NO?: string; // Since TAG_NO seems important in the code +} + +// Define the options interface for the export function +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + onPendingChange?: (isPending: boolean) => void; +} + +// Define the return type +export interface ExportExcelResult { + success: boolean; + error?: any; +} + +/** + * Export table data to Excel with data validation for select columns + * @param options Configuration options for Excel export + * @returns Promise with success/error information + */ +export async function exportExcelData({ + tableData, + columnsJSON, + formCode, + onPendingChange +}: ExportExcelOptions): Promise<ExportExcelResult> { + try { + if (onPendingChange) onPendingChange(true); + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data"); + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData"); + validationSheet.state = "hidden"; // 시트 숨김 처리 + + // 1. 유효성 검사 시트에 select 옵션 추가 + const selectColumns = columnsJSON.filter( + (col) => col.type === "LIST" && col.options && col.options.length > 0 + ); + + // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) + const validationRanges = new Map<string, string>(); + + selectColumns.forEach((col, idx) => { + const colIndex = idx + 1; + const colLetter = validationSheet.getColumn(colIndex).letter; + + // 헤더 추가 (컬럼 레이블) + validationSheet.getCell(`${colLetter}1`).value = col.label; + + // 옵션 추가 + if (col.options) { + col.options.forEach((option, optIdx) => { + validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; + }); + + // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) + validationRanges.set( + col.key, + `ValidationData!${colLetter}$2:${colLetter}${ + col.options.length + 1 + }` + ); + } + }); + + // 2. 데이터 시트에 헤더 추가 + const headers = columnsJSON.map((col) => col.label); + worksheet.addRow(headers); + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 3. 데이터 행 추가 + tableData.forEach((row) => { + const rowValues = columnsJSON.map((col) => { + const value = row[col.key]; + return value !== undefined && value !== null ? value : ""; + }); + worksheet.addRow(rowValues); + }); + + // 4. 데이터 유효성 검사 적용 + const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 + + columnsJSON.forEach((col, idx) => { + if (col.type === "LIST" && validationRanges.has(col.key)) { + const colLetter = worksheet.getColumn(idx + 1).letter; + const validationRange = validationRanges.get(col.key)!; + + // 유효성 검사 정의 + const validation = { + type: "list" as const, + allowBlank: true, + formulae: [validationRange], + showErrorMessage: true, + errorStyle: "warning" as const, + errorTitle: "유효하지 않은 값", + error: "목록에서 값을 선택해주세요.", + }; + + // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) + for ( + let rowIdx = 2; + rowIdx <= Math.min(tableData.length + 1, maxRows); + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; + } + + // 빈 행에도 적용 (최대 maxRows까지) + if (tableData.length + 1 < maxRows) { + for ( + let rowIdx = tableData.length + 2; + rowIdx <= maxRows; + rowIdx++ + ) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = + validation; + } + } + } + }); + + // 5. 컬럼 너비 자동 조정 + columnsJSON.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1); + + // 최적 너비 계산 + let maxLength = col.label.length; + tableData.forEach((row) => { + const value = row[col.key]; + if (value !== undefined && value !== null) { + const valueLength = String(value).length; + if (valueLength > maxLength) { + maxLength = valueLength; + } + } + }); + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // 6. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + saveAs( + new Blob([buffer]), + `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` + ); + + toast.success("Excel 내보내기 완료!"); + return { success: true }; + } catch (err) { + console.error("Excel export error:", err); + toast.error("Excel 내보내기 실패."); + return { success: false, error: err }; + } finally { + if (onPendingChange) onPendingChange(false); + } +}
\ No newline at end of file diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index 6a76784c..ef921a91 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -139,11 +139,11 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ const reportValueMapping: { [key: string]: any } = {}; columnsJSON.forEach((c2) => { - const { key, label } = c2; + const { key } = c2; - const objKey = label.split(" ").join("_"); + // const objKey = label.split(" ").join("_"); - reportValueMapping[objKey] = reportValue?.[key] ?? ""; + reportValueMapping[key] = reportValue?.[key] ?? ""; }); return reportValueMapping; @@ -351,4 +351,4 @@ const stringifyAllValues = (obj: any): any => { } else { return obj !== null && obj !== undefined ? String(obj) : ""; } -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index 52262bf5..3cfbbeb3 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -32,6 +32,7 @@ import { import { Button } from "@/components/ui/button"; import { getReportTempList } from "@/lib/forms/services"; import { DataTableColumnJSON } from "./form-data-table-columns"; +import { PublishDialog } from "./publish-dialog"; type ReportData = { [key: string]: any; @@ -64,6 +65,10 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ const [selectTemp, setSelectTemp] = useState<string>(""); const [instance, setInstance] = useState<null | WebViewerInstance>(null); const [fileLoading, setFileLoading] = useState<boolean>(true); + + // Add new state for publish dialog + const [publishDialogOpen, setPublishDialogOpen] = useState<boolean>(false); + const [generatedFileBlob, setGeneratedFileBlob] = useState<Blob | null>(null); useEffect(() => { updateReportTempList(packageId, formId, setTempList); @@ -98,61 +103,103 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ toast.success("Report 다운로드 완료!"); } }; + + // New function to prepare the file for publishing + const prepareFileForPublishing = async () => { + if (instance) { + try { + const { Core } = instance; + const { documentViewer } = Core; + + const doc = documentViewer.getDocument(); + const fileData = await doc.getFileData({ + includeAnnotations: true, + }); + + setGeneratedFileBlob(new Blob([fileData])); + setPublishDialogOpen(true); + } catch (error) { + console.error("Error preparing file for publishing:", error); + toast.error("Failed to prepare document for publishing"); + } + } + }; return ( - <Dialog open={reportData.length > 0} onOpenChange={onClose}> - <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> - <DialogHeader> - <DialogTitle>Create Vendor Document</DialogTitle> - <DialogDescription> - 사용하시고자 하는 Vendor Document Template를 선택하여 주시기 바랍니다. - </DialogDescription> - </DialogHeader> - <div className="h-[60px]"> - <Label>Vendor Document Template Select</Label> - <Select - value={selectTemp} - onValueChange={setSelectTemp} - disabled={instance === null} - > - <SelectTrigger className="w-[100%]"> - <SelectValue placeholder="사용하시고자하는 Vendor Document Template을 선택하여 주시기 바랍니다." /> - </SelectTrigger> - <SelectContent> - {tempList.map((c) => { - const { fileName, filePath } = c; - - return ( - <SelectItem key={filePath} value={filePath}> - {fileName} - </SelectItem> - ); - })} - </SelectContent> - </Select> - </div> - <div className="h-[calc(70vh-60px)]"> - <ReportWebViewer - columnsJSON={columnsJSON} - reportTempPath={selectTemp} - reportDatas={reportData} - instance={instance} - setInstance={setInstance} - setFileLoading={setFileLoading} - formCode={formCode} - /> - </div> - - <DialogFooter> - <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> - Create Vendor Document - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + <> + <Dialog open={reportData.length > 0} onOpenChange={onClose}> + <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>Create Vendor Document</DialogTitle> + <DialogDescription> + 사용하시고자 하는 Vendor Document Template를 선택하여 주시기 바랍니다. + </DialogDescription> + </DialogHeader> + <div className="h-[60px]"> + <Label>Vendor Document Template Select</Label> + <Select + value={selectTemp} + onValueChange={setSelectTemp} + disabled={instance === null} + > + <SelectTrigger className="w-[100%]"> + <SelectValue placeholder="사용하시고자하는 Vendor Document Template을 선택하여 주시기 바랍니다." /> + </SelectTrigger> + <SelectContent> + {tempList.map((c) => { + const { fileName, filePath } = c; + + return ( + <SelectItem key={filePath} value={filePath}> + {fileName} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div className="h-[calc(70vh-60px)]"> + <ReportWebViewer + columnsJSON={columnsJSON} + reportTempPath={selectTemp} + reportDatas={reportData} + instance={instance} + setInstance={setInstance} + setFileLoading={setFileLoading} + formCode={formCode} + /> + </div> + + <DialogFooter> + {/* Add the new Publish button */} + <Button + onClick={prepareFileForPublishing} + disabled={selectTemp.length === 0} + variant="outline" + className="mr-2" + > + Publish + </Button> + <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> + Create Vendor Document + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Add the PublishDialog component */} + <PublishDialog + open={publishDialogOpen} + onOpenChange={setPublishDialogOpen} + packageId={packageId} + formCode={formCode} + fileBlob={generatedFileBlob || undefined} + /> + </> ); }; +// Keep the rest of the component as is... interface ReportWebViewerProps { columnsJSON: DataTableColumnJSON[]; reportTempPath: string; @@ -310,9 +357,9 @@ const importReportData: ImportReportData = async ( columnsJSON.forEach((c) => { const { key, label } = c; - const objKey = label.split(" ").join("_"); + // const objKey = label.split(" ").join("_"); - reportValueMapping[objKey] = reportValue?.[key] ?? ""; + reportValueMapping[key] = reportValue?.[key] ?? ""; }); const doc = await createDocument(reportFileBlob, { @@ -357,4 +404,4 @@ const updateReportTempList: UpdateReportTempList = async ( return { fileName, filePath }; }) ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index 74cfe7c3..e4d78248 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -8,6 +8,7 @@ import { DialogTitle, DialogDescription, } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -48,53 +49,33 @@ export const FormDataReportTempUploadDialog: FC< return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> - <DialogHeader> + <DialogHeader className="gap-2"> <DialogTitle>Vendor Document Template</DialogTitle> - <DialogDescription> + <DialogDescription className="flex justify-around gap-[16px] "> {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 하여주시기 바랍니다. */} + <TempDownloadBtn /> + <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> </DialogDescription> </DialogHeader> <Tabs value={tabValue}> <div className="flex justify-between items-center"> - <TabsList> - <TabsTrigger value="upload" onClick={() => setTabValue("upload")}> + <TabsList className="w-full"> + <TabsTrigger + value="upload" + onClick={() => setTabValue("upload")} + className="flex-1" + > Upload Template File </TabsTrigger> <TabsTrigger value="uploaded" onClick={() => setTabValue("uploaded")} + className="flex-1" > Uploaded Template File List </TabsTrigger> </TabsList> - <div className="flex flex-row gap-2"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <div> - <TempDownloadBtn /> - </div> - </TooltipTrigger> - <TooltipContent> - <Label>Template Sample File Download</Label> - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <div> - <VarListDownloadBtn - columnsJSON={columnsJSON} - formCode={formCode} - /> - </div> - </TooltipTrigger> - <TooltipContent> - <Label>Variable List File Download</Label> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> </div> <TabsContent value="upload"> <FormDataReportTempUploadTab @@ -113,4 +94,4 @@ export const FormDataReportTempUploadDialog: FC< </DialogContent> </Dialog> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-upload-tab.tsx b/components/form-data/form-data-report-temp-upload-tab.tsx index 5e6179a8..32161e49 100644 --- a/components/form-data/form-data-report-temp-upload-tab.tsx +++ b/components/form-data/form-data-report-temp-upload-tab.tsx @@ -225,4 +225,4 @@ const UploadProgressBox: FC<{ uploadProgress: number }> = ({ </div> </div> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx index 7379a312..a5c3c7a5 100644 --- a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx +++ b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx @@ -208,4 +208,4 @@ const UploadedTempFiles: FC<UploadedTempFiles> = ({ </FileList> </ScrollArea> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/form-data-table copy.tsx b/components/form-data/form-data-table copy.tsx new file mode 100644 index 00000000..aa16513a --- /dev/null +++ b/components/form-data/form-data-table copy.tsx @@ -0,0 +1,539 @@ +"use client"; + +import * as React from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; + +import { ClientDataTable } from "../client-data-table/data-table"; +import { + getColumns, + DataTableRowAction, + DataTableColumnJSON, + ColumnType, +} from "./form-data-table-columns"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { Button } from "../ui/button"; +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, + FileSpreadsheet, + FileOutput, + Clipboard, + Send +} from "lucide-react"; +import { toast } from "sonner"; +import { + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, + updateFormDataInDB, +} from "@/lib/forms/services"; +import { UpdateTagSheet } from "./update-form-sheet"; +import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; +import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { AddFormTagDialog } from "./add-formTag-dialog"; +import { importExcelData } from "./import-excel-form"; +import { exportExcelData } from "./export-excel-form"; +import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; + +interface GenericData { + [key: string]: any; +} + +export interface DynamicTableProps { + dataJSON: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + contractItemId: number; + formCode: string; + formId: number; + projectId: number; + formName?: string; + objectCode?: string; +} + +export default function DynamicTable({ + dataJSON, + columnsJSON, + contractItemId, + formCode, + formId, + projectId, + formName = `VD)${formCode}`, // Default form name based on formCode + objectCode = "LO_PT_CLAS", // Default object code +}: DynamicTableProps) { + const params = useParams(); + const router = useRouter(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "translation"); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<GenericData> | null>(null); + const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); + + + console.log(tableData) + console.log(columnsJSON) + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + const [sedpStatusData, setSedpStatusData] = React.useState({ + status: 'success' as 'success' | 'error' | 'partial', + message: '', + successCount: 0, + errorCount: 0, + totalCount: 0 + }); + + const [tempUpDialog, setTempUpDialog] = React.useState(false); + const [reportData, setReportData] = React.useState<GenericData[]>([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); + const [tempCount, setTempCount] = React.useState(0); + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + const columns = React.useMemo( + () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] + ); + + function mapColumnTypeToAdvancedFilterType( + columnType: ColumnType + ): DataTableAdvancedFilterField<GenericData>["type"] { + switch (columnType) { + case "STRING": + return "text"; + case "NUMBER": + return "number"; + case "LIST": + return "select"; + default: + return "text"; + } + } + + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField<GenericData>[] + >(() => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })); + }, [columnsJSON]); + + // 태그 불러오기 + async function handleSyncTags() { + try { + setIsSyncingTags(true); + const result = await syncMissingTags(contractItemId, formCode); + + // Prepare the toast messages based on what changed + const changes = []; + if (result.createdCount > 0) + changes.push(`${result.createdCount}건 태그 생성`); + if (result.updatedCount > 0) + changes.push(`${result.updatedCount}건 태그 업데이트`); + if (result.deletedCount > 0) + changes.push(`${result.deletedCount}건 태그 삭제`); + + if (changes.length > 0) { + // If any changes were made, show success message and reload + toast.success(`동기화 완료: ${changes.join(", ")}`); + router.refresh(); // Use router.refresh instead of location.reload + } else { + // If no changes were made, show an info message + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); + } + } catch (err) { + console.error(err); + toast.error("태그 동기화 중 에러가 발생했습니다."); + } finally { + setIsSyncingTags(false); + } + } + + // Excel Import - Modified to directly save to DB + async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0]; + if (!file) return; + + try { + setIsImporting(true); + + // Call the updated importExcelData function with direct save capability + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, // Pass formCode for direct save + contractItemId, // Pass contractItemId for direct save + onPendingChange: setIsImporting, + onDataUpdate: (newData) => { + // This is called only after successful DB save + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } + }); + + // If import and save was successful, refresh the page + if (result.success) { + router.refresh(); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); + } finally { + // Always clear the file input value + e.target.value = ""; + setIsImporting(false); + } + } + + // SEDP Send handler (with confirmation) + function handleSEDPSendClick() { + if (tableData.length === 0) { + toast.error("No data to send to SEDP"); + return; + } + + // Open confirmation dialog + setSedpConfirmOpen(true); + } + + // Actual SEDP send after confirmation +// In your DynamicTable component, update the handler for SEDP sending + +async function handleSEDPSendConfirmed() { + try { + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); + if (invalidData.length > 0) { + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); + return; + } + + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + tableData, // Table data + columnsJSON // Column definitions + ); + + // Close confirmation dialog + setSedpConfirmOpen(false); + + // Set status data based on result + if (sedpResult.success) { + setSedpStatusData({ + status: 'success', + message: "Data successfully sent to SEDP", + successCount: tableData.length, + errorCount: 0, + totalCount: tableData.length + }); + } else { + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + } + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: any) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err.message || "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + + } finally { + setIsSendingSEDP(false); + } +} + // Template Export + async function handleExportExcel() { + try { + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + onPendingChange: setIsExporting + }); + } finally { + setIsExporting(false); + } + } + + // Handle batch document check + const handleBatchDocument = () => { + if (tempCount > 0) { + setBatchDownDialog(true); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }; + + return ( + <> + <ClientDataTable + data={tableData} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + {/* 버튼 그룹 */} + <div className="flex items-center gap-2"> + {/* 태그 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + {isSyncingTags && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + <TagsIcon className="size-4" /> + Tag Operations + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}> + <Tag className="mr-2 h-4 w-4" /> + Sync Tags + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setAddTagDialogOpen(true)} disabled={isAnyOperationPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Tags + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 리포트 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + <Clipboard className="size-4" /> + Report Operations + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}> + <Upload className="mr-2 h-4 w-4" /> + Upload Template + </DropdownMenuItem> + <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> + <FileOutput className="mr-2 h-4 w-4" /> + Batch Document + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* IMPORT 버튼 (파일 선택) */} + <Button asChild variant="outline" size="sm" disabled={isAnyOperationPending}> + <label> + {isImporting ? ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" /> + )} + Import + <input + type="file" + accept=".xlsx,.xls" + onChange={handleImportExcel} + style={{ display: "none" }} + disabled={isAnyOperationPending} + /> + </label> + </Button> + + {/* EXPORT 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExportExcel} + disabled={isAnyOperationPending} + > + {isExporting ? ( + <Loader className="mr-2 size-4 animate-spin" /> + ) : ( + <Download className="mr-2 size-4" /> + )} + Export + </Button> + + + {/* SEDP 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSEDPSendClick} + disabled={isAnyOperationPending} + > + {isSendingSEDP ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + SEDP 전송 중... + </> + ) : ( + <> + <Send className="size-4" /> + Send to SHI + </> + )} + </Button> + </div> + </ClientDataTable> + + {/* Modal dialog for tag update */} + <UpdateTagSheet + open={rowAction?.type === "update"} + onOpenChange={(open) => { + if (!open) setRowAction(null); + }} + columns={columnsJSON} + rowData={rowAction?.row.original ?? null} + formCode={formCode} + contractItemId={contractItemId} + onUpdateSuccess={(updatedValues) => { + // Update the specific row in tableData when a single row is updated + if (rowAction?.row.original?.TAG_NO) { + const tagNo = rowAction.row.original.TAG_NO; + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + }} + /> + + {/* Dialog for adding tags */} + <AddFormTagDialog + projectId={projectId} + formCode={formCode} + formName={`Form ${formCode}`} + contractItemId={contractItemId} + open={addTagDialogOpen} + onOpenChange={setAddTagDialogOpen} + /> + + {/* SEDP Confirmation Dialog */} + <SEDPConfirmationDialog + isOpen={sedpConfirmOpen} + onClose={() => setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + <SEDPStatusDialog + isOpen={sedpStatusOpen} + onClose={() => setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* Other dialogs */} + {tempUpDialog && ( + <FormDataReportTempUploadDialog + columnsJSON={columnsJSON} + open={tempUpDialog} + setOpen={setTempUpDialog} + packageId={contractItemId} + formCode={formCode} + formId={formId} + uploaderType="vendor" + /> + )} + + {reportData.length > 0 && ( + <FormDataReportDialog + columnsJSON={columnsJSON} + reportData={reportData} + setReportData={setReportData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + + {batchDownDialog && ( + <FormDataReportBatchDialog + open={batchDownDialog} + setOpen={setBatchDownDialog} + columnsJSON={columnsJSON} + reportData={tableData} + packageId={contractItemId} + formCode={formCode} + formId={formId} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index a136b5d3..4db3a724 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -16,6 +16,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { toast } from 'sonner'; /** row 액션 관련 타입 */ export interface DataTableRowAction<TData> { row: Row<TData>; @@ -36,6 +37,7 @@ export interface DataTableColumnJSON { type: ColumnType; options?: string[]; uom?: string; + uomId?: string; } /** * getColumns 함수에 필요한 props @@ -47,6 +49,7 @@ interface GetColumnsProps<TData> { React.SetStateAction<DataTableRowAction<TData> | null> >; setReportData: React.Dispatch<React.SetStateAction<{ [key: string]: any }[]>>; + tempCount: number; } /** @@ -58,6 +61,7 @@ export function getColumns<TData extends object>({ columnsJSON, setRowAction, setReportData, + tempCount, }: GetColumnsProps<TData>): ColumnDef<TData>[] { // (1) 기본 컬럼들 const baseColumns: ColumnDef<TData>[] = columnsJSON.map((col) => ({ @@ -73,7 +77,7 @@ export function getColumns<TData extends object>({ excelHeader: col.label, minWidth: 80, paddingFactor: 1.2, - maxWidth: col.key === "tagNumber" ? 120 : 150, + maxWidth: col.key === "TAG_NO" ? 120 : 150, }, // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { @@ -129,22 +133,23 @@ export function getColumns<TData extends object>({ </DropdownMenuItem> <DropdownMenuItem onSelect={() => { + if(tempCount > 0){ const { original } = row; setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } }} > - Create Vendor Document + Create Document </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), - size: 40, - meta: { - maxWidth: 40, - }, + minSize: 50, enablePinning: true, }; // (4) 최종 반환 return [...baseColumns, actionColumn]; -} +}
\ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 4caee44f..05278375 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslation } from "@/i18n/client"; import { ClientDataTable } from "../client-data-table/data-table"; @@ -13,20 +13,88 @@ import { } from "./form-data-table-columns"; import type { DataTableAdvancedFilterField } from "@/types/table"; import { Button } from "../ui/button"; -import { Download, Loader, Save, Upload } from "lucide-react"; +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, + FileSpreadsheet, + FileOutput, + Clipboard, + Send, + GitCompareIcon, + RefreshCcw +} from "lucide-react"; import { toast } from "sonner"; -import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"; +import { + getProjectCodeById, + getReportTempList, + sendFormDataToSEDP, + syncMissingTags, + updateFormDataInDB, +} from "@/lib/forms/services"; import { UpdateTagSheet } from "./update-form-sheet"; -import ExcelJS from "exceljs"; -import { saveAs } from "file-saver"; import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; import { FormDataReportDialog } from "./form-data-report-dialog"; import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { AddFormTagDialog } from "./add-formTag-dialog"; +import { importExcelData } from "./import-excel-form"; +import { exportExcelData } from "./export-excel-form"; +import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; +import { SEDPCompareDialog } from "./sedp-compare-dialog"; +import { getSEDPToken } from "@/lib/sedp/sedp-token"; + + +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); + } +} interface GenericData { [key: string]: any; @@ -38,6 +106,10 @@ export interface DynamicTableProps { contractItemId: number; formCode: string; formId: number; + projectId: number; + formName?: string; + objectCode?: string; + mode: "IM" | "ENG"; // 모드 속성 } export default function DynamicTable({ @@ -46,28 +118,98 @@ export default function DynamicTable({ contractItemId, formCode, formId, + projectId, + mode = "IM", // 기본값 설정 + formName = `${formCode}`, // Default form name based on formCode }: DynamicTableProps) { const params = useParams(); + const router = useRouter(); const lng = (params?.lng as string) || "ko"; const { t } = useTranslation(lng, "translation"); const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null); - const [tableData, setTableData] = React.useState<GenericData[]>( - () => dataJSON - ); - const [isPending, setIsPending] = React.useState(false); + const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 폴링 상태 관리를 위한 ref + const pollingRef = React.useRef<NodeJS.Timeout | null>(null); + const [syncId, setSyncId] = React.useState<string | null>(null); + + // Separate loading states for different operations + const [isSyncingTags, setIsSyncingTags] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); + const [isLoadingTags, setIsLoadingTags] = React.useState(false); + + // Any operation in progress + const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags; + + // SEDP dialogs state + const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); + const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); + const [sedpStatusData, setSedpStatusData] = React.useState({ + status: 'success' as 'success' | 'error' | 'partial', + message: '', + successCount: 0, + errorCount: 0, + totalCount: 0 + }); + + // SEDP compare dialog state + const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); + const [projectCode, setProjectCode] = React.useState<string>(''); + const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState<GenericData[]>([]); const [batchDownDialog, setBatchDownDialog] = React.useState(false); + const [tempCount, setTempCount] = React.useState(0); + const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false); + + // Clean up polling on unmount + React.useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); + + React.useEffect(() => { + const getTempCount = async () => { + const tempList = await getReportTempList(contractItemId, formId); + setTempCount(tempList.length); + }; + + getTempCount(); + }, [contractItemId, formId, tempUpDialog]); + + // Get project code when component mounts + React.useEffect(() => { + const getProjectCode = async () => { + try { + const code = await getProjectCodeById(projectId); + setProjectCode(code); + } catch (error) { + console.error("Error fetching project code:", error); + toast.error("Failed to fetch project code"); + } + }; - // Reference to the table instance - const tableRef = React.useRef(null); + if (projectId) { + getProjectCode(); + } + }, [projectId]); const columns = React.useMemo( - () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData }), - [columnsJSON, setRowAction, setReportData] + () => getColumns<GenericData>({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] ); function mapColumnTypeToAdvancedFilterType( @@ -79,11 +221,8 @@ export default function DynamicTable({ case "NUMBER": return "number"; case "LIST": - // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. return "select"; - // 그 외 다른 타입들도 적절히 추가 매핑 default: - // 예: 못 매핑한 경우 기본적으로 "text" 적용 return "text"; } } @@ -102,10 +241,10 @@ export default function DynamicTable({ })); }, [columnsJSON]); - // 1) 태그 불러오기 (기존) + // IM 모드: 태그 동기화 함수 async function handleSyncTags() { try { - setIsPending(true); + setIsSyncingTags(true); const result = await syncMissingTags(contractItemId, formCode); // Prepare the toast messages based on what changed @@ -120,7 +259,7 @@ export default function DynamicTable({ if (changes.length > 0) { // If any changes were made, show success message and reload toast.success(`동기화 완료: ${changes.join(", ")}`); - location.reload(); + router.refresh(); // Use router.refresh instead of location.reload } else { // If no changes were made, show an info message toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다."); @@ -129,487 +268,393 @@ export default function DynamicTable({ console.error(err); toast.error("태그 동기화 중 에러가 발생했습니다."); } finally { - setIsPending(false); + setIsSyncingTags(false); } } - // 2) Excel Import (새로운 기능) - async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { - const file = e.target.files?.[0]; - if (!file) return; - + + // ENG 모드: 태그 가져오기 함수 + const handleGetTags = async () => { try { - setIsPending(true); - - // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) - const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)); - - const workbook = new ExcelJS.Workbook(); - const arrayBuffer = await file.arrayBuffer(); - await workbook.xlsx.load(arrayBuffer); - - const worksheet = workbook.worksheets[0]; - - // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) - const headerRow = worksheet.getRow(1); - const headerRowValues = headerRow.values as ExcelJS.CellValue[]; - - // 디버깅용 로그 - console.log("원본 헤더 값:", headerRowValues); - - // Excel의 헤더와 columnsJSON의 label 매핑 생성 - // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined - const headerToIndexMap = new Map<string, number>(); - for (let i = 1; i < headerRowValues.length; i++) { - const headerValue = String(headerRowValues[i] || "").trim(); - if (headerValue) { - headerToIndexMap.set(headerValue, i); - } - } - - // (B) 헤더 검사 - let headerErrorMessage = ""; - - // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 - columnsJSON.forEach((col) => { - const label = col.label; - if (!headerToIndexMap.has(label)) { - headerErrorMessage += `Column "${label}" is missing. `; - } - }); - - // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 - headerToIndexMap.forEach((index, headerLabel) => { - const found = columnsJSON.some((col) => col.label === headerLabel); - if (!found) { - headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; - } + setIsLoadingTags(true); + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/form-tags/start', { + method: 'POST', + body: JSON.stringify({ projectCode ,formCode ,contractItemId }) }); - - // (C) 이제 Error 열 추가 - const lastColIndex = worksheet.columnCount + 1; - worksheet.getRow(1).getCell(lastColIndex).value = "Error"; - - // 헤더 에러가 있으면 기록 후 다운로드하고 중단 - if (headerErrorMessage) { - headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); - - const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - - toast.error(`Header mismatch found. Please check downloaded file.`); - return; + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to start tag import'); } - - // -- 여기까지 왔다면, 헤더는 문제 없음 -- - - // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) - // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 - const keyToIndexMap = new Map<string, number>(); - columnsJSON.forEach((col) => { - const index = headerToIndexMap.get(col.label); - if (index !== undefined) { - keyToIndexMap.set(col.key, index); + + const data = await response.json(); + + // 작업 ID 저장 + if (data.syncId) { + setSyncId(data.syncId); + toast.info('Tag import started. This may take a while...'); + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId); + } else { + throw new Error('No import ID returned from server'); + } + } catch (error) { + console.error('Error starting tag import:', error); + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while starting tag import' + ); + setIsLoadingTags(false); + } + }; + + const startPolling = (id: string) => { + // 이전 폴링이 있다면 제거 + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + + // 5초마다 상태 확인 + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/form-tags/status?id=${id}`); + + if (!response.ok) { + throw new Error('Failed to get tag import status'); } - }); - - // 데이터 파싱 - const importedData: GenericData[] = []; - const lastRowNumber = worksheet.lastRow?.number || 1; - let errorCount = 0; - - // 실제 데이터 행 파싱 - for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { - const row = worksheet.getRow(rowNum); - const rowValues = row.values as ExcelJS.CellValue[]; - if (!rowValues || rowValues.length <= 1) continue; // 빈 행 스킵 - - let errorMessage = ""; - const rowObj: Record<string, any> = {}; - - // 각 열에 대해 처리 - columnsJSON.forEach((col) => { - const colIndex = keyToIndexMap.get(col.key); - if (colIndex === undefined) return; - - const cellValue = rowValues[colIndex] ?? ""; - let stringVal = String(cellValue).trim(); - - // 타입별 검사 - switch (col.type) { - case "STRING": - if (!stringVal && col.key === "tagNumber") { - errorMessage += `[${col.label}] is empty. `; - } - rowObj[col.key] = stringVal; - break; - - case "NUMBER": - if (stringVal) { - const num = parseFloat(stringVal); - if (isNaN(num)) { - errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; - } else { - rowObj[col.key] = num; - } - } else { - rowObj[col.key] = null; - } - break; - - case "LIST": - if ( - stringVal && - col.options && - !col.options.includes(stringVal) - ) { - errorMessage += `[${ - col.label - }] '${stringVal}' not in ${col.options.join(", ")}. `; - } - rowObj[col.key] = stringVal; - break; - - default: - rowObj[col.key] = stringVal; - break; + + const data = await response.json(); + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + router.refresh(); + + // 상태 초기화 + setIsLoadingTags(false); + setSyncId(null); + + // 성공 메시지 표시 + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ); + + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + setIsLoadingTags(false); + setSyncId(null); + toast.error(data.error || 'Import failed'); + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Import in progress: ${data.progress}%`, { + id: `import-progress-${id}`, + }); } - }); - - // tagNumber 검사 - const tagNum = rowObj["tagNumber"]; - if (!tagNum) { - errorMessage += `No tagNumber found. `; - } else if (!existingTagNumbers.has(tagNum)) { - errorMessage += `TagNumber '${tagNum}' is not in current data. `; - } - - if (errorMessage) { - row.getCell(lastColIndex).value = errorMessage.trim(); - errorCount++; - } else { - importedData.push(rowObj); } + } catch (error) { + console.error('Error checking importing status:', error); } + }, 5000); // 5초마다 체크 + }; + + // Excel Import - Modified to directly save to DB + async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0]; + if (!file) return; - // 에러가 있으면 재다운로드 후 import 중단 - if (errorCount > 0) { - const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); - toast.error( - `There are ${errorCount} error row(s). Please check downloaded file.` - ); - return; - } - - // 에러 없으니 tableData 병합 - setTableData((prev) => { - const newDataMap = new Map<string, GenericData>(); - - // 기존 데이터를 맵에 추가 - prev.forEach((item) => { - if (item.tagNumber) { - newDataMap.set(item.tagNumber, { ...item }); - } - }); - - // 임포트 데이터로 기존 데이터 업데이트 - importedData.forEach((item) => { - const tag = item.tagNumber; - if (!tag) return; - const oldItem = newDataMap.get(tag) || {}; - newDataMap.set(tag, { ...oldItem, ...item }); - }); - - return Array.from(newDataMap.values()); + try { + setIsImporting(true); + + // Call the updated importExcelData function with direct save capability + const result = await importExcelData({ + file, + tableData, + columnsJSON, + formCode, // Pass formCode for direct save + contractItemId, // Pass contractItemId for direct save + onPendingChange: setIsImporting, + onDataUpdate: (newData) => { + // This is called only after successful DB save + setTableData(Array.isArray(newData) ? newData : newData(tableData)); + } }); - - toast.success(`Imported ${importedData.length} rows successfully.`); - } catch (err) { - console.error("Excel import error:", err); - toast.error("Excel import failed."); + + // If import and save was successful, refresh the page + if (result.success) { + router.refresh(); + } + } catch (error) { + console.error("Import failed:", error); + toast.error("Failed to import Excel data"); } finally { - setIsPending(false); + // Always clear the file input value e.target.value = ""; + setIsImporting(false); + } + } + + // SEDP Send handler (with confirmation) + function handleSEDPSendClick() { + if (tableData.length === 0) { + toast.error("No data to send to SEDP"); + return; } + + // Open confirmation dialog + setSedpConfirmOpen(true); + } + + // Handle SEDP compare button click + function handleSEDPCompareClick() { + if (tableData.length === 0) { + toast.error("No data to compare with SEDP"); + return; + } + + if (!projectCode) { + toast.error("Project code is not available"); + return; + } + + // Open compare dialog + setSedpCompareOpen(true); } - // 3) Save -> 서버에 전체 tableData를 저장 - async function handleSave() { + // Actual SEDP send after confirmation + async function handleSEDPSendConfirmed() { try { - setIsSaving(true); - - // 유효성 검사 - const invalidData = tableData.filter((item) => !item.tagNumber?.trim()); + setIsSendingSEDP(true); + + // Validate data + const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); if (invalidData.length > 0) { - toast.error( - `태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.` - ); + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`); + setSedpConfirmOpen(false); return; } - // 서버 액션 호출 - const result = await updateFormDataInDB( - formCode, - contractItemId, - tableData + // Then send to SEDP - pass formCode instead of formName + const sedpResult = await sendFormDataToSEDP( + formCode, // Send formCode instead of formName + projectId, // Project ID + tableData, // Table data + columnsJSON // Column definitions ); - if (result.success) { - toast.success(result.message); + // Close confirmation dialog + setSedpConfirmOpen(false); + + // Set status data based on result + if (sedpResult.success) { + setSedpStatusData({ + status: 'success', + message: "Data successfully sent to SEDP", + successCount: tableData.length, + errorCount: 0, + totalCount: tableData.length + }); } else { - toast.error(result.message); + setSedpStatusData({ + status: 'error', + message: sedpResult.message || "Failed to send data to SEDP", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); } - } catch (err) { - console.error("Save error:", err); - toast.error("데이터 저장 중 오류가 발생했습니다."); + + // Open status dialog to show result + setSedpStatusOpen(true); + + // Refresh the route to get fresh data + router.refresh(); + + } catch (err: any) { + console.error("SEDP error:", err); + + // Set error status + setSedpStatusData({ + status: 'error', + message: err.message || "An unexpected error occurred", + successCount: 0, + errorCount: tableData.length, + totalCount: tableData.length + }); + + // Close confirmation and open status + setSedpConfirmOpen(false); + setSedpStatusOpen(true); + } finally { - setIsSaving(false); + setIsSendingSEDP(false); } } - - // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet + + // Template Export async function handleExportExcel() { try { - setIsPending(true); - - // Create a new workbook - const workbook = new ExcelJS.Workbook(); - - // 데이터 시트 생성 - const worksheet = workbook.addWorksheet("Data"); - - // 유효성 검사용 숨김 시트 생성 - const validationSheet = workbook.addWorksheet("ValidationData"); - validationSheet.state = "hidden"; // 시트 숨김 처리 - - // 1. 유효성 검사 시트에 select 옵션 추가 - const selectColumns = columnsJSON.filter( - (col) => col.type === "LIST" && col.options && col.options.length > 0 - ); - - // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) - const validationRanges = new Map<string, string>(); - - selectColumns.forEach((col, idx) => { - const colIndex = idx + 1; - const colLetter = validationSheet.getColumn(colIndex).letter; - - // 헤더 추가 (컬럼 레이블) - validationSheet.getCell(`${colLetter}1`).value = col.label; - - // 옵션 추가 - if (col.options) { - col.options.forEach((option, optIdx) => { - validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option; - }); - - // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) - validationRanges.set( - col.key, - `ValidationData!${colLetter}$2:${colLetter}${ - col.options.length + 1 - }` - ); - } - }); - - // 2. 데이터 시트에 헤더 추가 - const headers = columnsJSON.map((col) => col.label); - worksheet.addRow(headers); - - // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.alignment = { horizontal: "center" }; - headerRow.eachCell((cell) => { - cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFCCCCCC" }, - }; - }); - - // 3. 데이터 행 추가 - tableData.forEach((row) => { - const rowValues = columnsJSON.map((col) => { - const value = row[col.key]; - return value !== undefined && value !== null ? value : ""; - }); - worksheet.addRow(rowValues); - }); - - // 4. 데이터 유효성 검사 적용 - const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 - - columnsJSON.forEach((col, idx) => { - if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter; - const validationRange = validationRanges.get(col.key)!; - - // 유효성 검사 정의 - const validation = { - type: "list" as const, - allowBlank: true, - formulae: [validationRange], - showErrorMessage: true, - errorStyle: "warning" as const, - errorTitle: "유효하지 않은 값", - error: "목록에서 값을 선택해주세요.", - }; - - // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) - for ( - let rowIdx = 2; - rowIdx <= Math.min(tableData.length + 1, maxRows); - rowIdx++ - ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; - } - - // 빈 행에도 적용 (최대 maxRows까지) - if (tableData.length + 1 < maxRows) { - for ( - let rowIdx = tableData.length + 2; - rowIdx <= maxRows; - rowIdx++ - ) { - worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = - validation; - } - } - } - }); - - // 5. 컬럼 너비 자동 조정 - columnsJSON.forEach((col, idx) => { - const column = worksheet.getColumn(idx + 1); - - // 최적 너비 계산 - let maxLength = col.label.length; - tableData.forEach((row) => { - const value = row[col.key]; - if (value !== undefined && value !== null) { - const valueLength = String(value).length; - if (valueLength > maxLength) { - maxLength = valueLength; - } - } - }); - - // 너비 설정 (최소 10, 최대 50) - column.width = Math.min(Math.max(maxLength + 2, 10), 50); + setIsExporting(true); + await exportExcelData({ + tableData, + columnsJSON, + formCode, + onPendingChange: setIsExporting }); - - // 6. 파일 다운로드 - const buffer = await workbook.xlsx.writeBuffer(); - saveAs( - new Blob([buffer]), - `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` - ); - - toast.success("Excel 내보내기 완료!"); - } catch (err) { - console.error("Excel export error:", err); - toast.error("Excel 내보내기 실패."); } finally { - setIsPending(false); + setIsExporting(false); } } + // Handle batch document check + const handleBatchDocument = () => { + if (tempCount > 0) { + setBatchDownDialog(true); + } else { + toast.error("업로드된 Template File이 없습니다."); + } + }; + return ( <> <ClientDataTable data={tableData} columns={columns} advancedFilterFields={advancedFilterFields} - // tableRef={tableRef} > {/* 버튼 그룹 */} <div className="flex items-center gap-2"> - {/* 태그 불러오기 버튼 */} - <Popover> - <PopoverTrigger asChild> - <Button variant="default" size="sm"> - Vendor Document + {/* 태그 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + {(isSyncingTags || isLoadingTags) && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + <TagsIcon className="size-4" /> + Tag Operations </Button> - </PopoverTrigger> - <PopoverContent className="flex flex-row gap-2 w-auto"> - <Button - variant="outline" - size="sm" - onClick={() => setTempUpDialog(true)} - > - Template Upload + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 모드에 따라 다른 태그 작업 표시 */} + {mode === "IM" ? ( + <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}> + <Tag className="mr-2 h-4 w-4" /> + Sync Tags + </DropdownMenuItem> + ) : ( + <DropdownMenuItem onClick={handleGetTags} disabled={isAnyOperationPending}> + <RefreshCcw className="mr-2 h-4 w-4" /> + Get Tags + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => setAddTagDialogOpen(true)} disabled={isAnyOperationPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Tags + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 리포트 관리 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isAnyOperationPending}> + <Clipboard className="size-4" /> + Report Operations </Button> - <Button - variant="outline" - size="sm" - onClick={() => setBatchDownDialog(true)} - > - Vendor Document Create - </Button> - </PopoverContent> - </Popover> - <Button - variant="default" - size="sm" - onClick={handleSyncTags} - disabled={isPending} - > - {isPending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Sync Tags - </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}> + <Upload className="mr-2 h-4 w-4" /> + Upload Template + </DropdownMenuItem> + <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> + <FileOutput className="mr-2 h-4 w-4" /> + Batch Document + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> {/* IMPORT 버튼 (파일 선택) */} - <Button asChild variant="outline" size="sm" disabled={isPending}> + <Button asChild variant="outline" size="sm" disabled={isAnyOperationPending}> <label> - <Upload className="size-4" /> + {isImporting ? ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4" /> + )} Import <input type="file" accept=".xlsx,.xls" onChange={handleImportExcel} style={{ display: "none" }} + disabled={isAnyOperationPending} /> </label> </Button> - {/* EXPORT 버튼 (새로 추가) */} + {/* EXPORT 버튼 */} <Button variant="outline" size="sm" onClick={handleExportExcel} - disabled={isPending} + disabled={isAnyOperationPending} > - <Download className="mr-2 size-4" /> - Export Template + {isExporting ? ( + <Loader className="mr-2 size-4 animate-spin" /> + ) : ( + <Download className="mr-2 size-4" /> + )} + Export </Button> - {/* SAVE 버튼 */} + {/* COMPARE WITH SEDP 버튼 */} <Button variant="outline" size="sm" - onClick={handleSave} - disabled={isPending || isSaving} + onClick={handleSEDPCompareClick} + disabled={isAnyOperationPending} > - {isSaving ? ( + <GitCompareIcon className="mr-2 size-4" /> + Compare with SEDP + </Button> + + {/* SEDP 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSEDPSendClick} + disabled={isAnyOperationPending} + > + {isSendingSEDP ? ( <> <Loader className="mr-2 size-4 animate-spin" /> - 저장 중... + SEDP 전송 중... </> ) : ( <> - <Save className="mr-2 size-4" /> - Save + <Send className="size-4" /> + Send to SHI </> )} </Button> </div> </ClientDataTable> + {/* Modal dialog for tag update */} <UpdateTagSheet open={rowAction?.type === "update"} onOpenChange={(open) => { @@ -619,7 +664,62 @@ export default function DynamicTable({ rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} + onUpdateSuccess={(updatedValues) => { + // Update the specific row in tableData when a single row is updated + if (rowAction?.row.original?.TAG_NO) { + const tagNo = rowAction.row.original.TAG_NO; + setTableData(prev => + prev.map(item => + item.TAG_NO === tagNo ? updatedValues : item + ) + ); + } + }} /> + + {/* Dialog for adding tags */} + <AddFormTagDialog + projectId={projectId} + formCode={formCode} + formName={`Form ${formCode}`} + contractItemId={contractItemId} + open={addTagDialogOpen} + onOpenChange={setAddTagDialogOpen} + /> + + {/* SEDP Confirmation Dialog */} + <SEDPConfirmationDialog + isOpen={sedpConfirmOpen} + onClose={() => setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + <SEDPStatusDialog + isOpen={sedpStatusOpen} + onClose={() => setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* SEDP Compare Dialog */} + <SEDPCompareDialog + isOpen={sedpCompareOpen} + onClose={() => setSedpCompareOpen(false)} + tableData={tableData} + columnsJSON={columnsJSON} + projectCode={projectCode} + formCode={formCode} + fetchTagDataFromSEDP={fetchTagDataFromSEDP} + /> + + {/* Other dialogs */} {tempUpDialog && ( <FormDataReportTempUploadDialog columnsJSON={columnsJSON} @@ -656,4 +756,4 @@ export default function DynamicTable({ )} </> ); -} +}
\ No newline at end of file diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx new file mode 100644 index 00000000..45e48312 --- /dev/null +++ b/components/form-data/import-excel-form.tsx @@ -0,0 +1,323 @@ +// lib/excelUtils.ts (continued) +import ExcelJS from "exceljs"; +import { saveAs } from "file-saver"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { updateFormDataInDB } from "@/lib/forms/services"; +// Assuming the previous types are defined above +export interface ImportExcelOptions { + file: File; + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode?: string; // Optional - provide to enable direct DB save + contractItemId?: number; // Optional - provide to enable direct DB save + onPendingChange?: (isPending: boolean) => void; + onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; +} + +export interface ImportExcelResult { + success: boolean; + importedCount?: number; + error?: any; + message?: string; +} + +export interface ExportExcelOptions { + tableData: GenericData[]; + columnsJSON: DataTableColumnJSON[]; + formCode: string; + onPendingChange?: (isPending: boolean) => void; +} + +// For typing consistency +interface GenericData { + [key: string]: any; +} + +export async function importExcelData({ + file, + tableData, + columnsJSON, + formCode, + contractItemId, + onPendingChange, + onDataUpdate +}: ImportExcelOptions): Promise<ImportExcelResult> { + if (!file) return { success: false, error: "No file provided" }; + + try { + if (onPendingChange) onPendingChange(true); + + // Get existing tag numbers + const existingTagNumbers = new Set(tableData.map((d) => d.TAG_NO)); + + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await file.arrayBuffer(); + await workbook.xlsx.load(arrayBuffer); + + const worksheet = workbook.worksheets[0]; + + // Parse headers + const headerRow = worksheet.getRow(1); + const headerRowValues = headerRow.values as ExcelJS.CellValue[]; + + console.log("Original headers:", headerRowValues); + + // Create mappings between Excel headers and column definitions + const headerToIndexMap = new Map<string, number>(); + for (let i = 1; i < headerRowValues.length; i++) { + const headerValue = String(headerRowValues[i] || "").trim(); + if (headerValue) { + headerToIndexMap.set(headerValue, i); + } + } + + // Validate headers + let headerErrorMessage = ""; + + // Check for missing required columns + columnsJSON.forEach((col) => { + const label = col.label; + if (!headerToIndexMap.has(label)) { + headerErrorMessage += `Column "${label}" is missing. `; + } + }); + + // Check for unexpected columns + headerToIndexMap.forEach((index, headerLabel) => { + const found = columnsJSON.some((col) => col.label === headerLabel); + if (!found) { + headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; + } + }); + + // Add error column + const lastColIndex = worksheet.columnCount + 1; + worksheet.getRow(1).getCell(lastColIndex).value = "Error"; + + // If header validation fails, download error report and exit + if (headerErrorMessage) { + headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); + + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + + toast.error(`Header mismatch found. Please check downloaded file.`); + return { success: false, error: "Header mismatch" }; + } + + // Create column key to Excel index mapping + const keyToIndexMap = new Map<string, number>(); + columnsJSON.forEach((col) => { + const index = headerToIndexMap.get(col.label); + if (index !== undefined) { + keyToIndexMap.set(col.key, index); + } + }); + + // Parse and validate data rows + const importedData: GenericData[] = []; + const lastRowNumber = worksheet.lastRow?.number || 1; + let errorCount = 0; + + // Process each data row + for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { + const row = worksheet.getRow(rowNum); + const rowValues = row.values as ExcelJS.CellValue[]; + if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows + + let errorMessage = ""; + const rowObj: Record<string, any> = {}; + + // Process each column + columnsJSON.forEach((col) => { + const colIndex = keyToIndexMap.get(col.key); + if (colIndex === undefined) return; + + const cellValue = rowValues[colIndex] ?? ""; + let stringVal = String(cellValue).trim(); + + // Type-specific validation + switch (col.type) { + case "STRING": + if (!stringVal && col.key === "TAG_NO") { + errorMessage += `[${col.label}] is empty. `; + } + rowObj[col.key] = stringVal; + break; + + case "NUMBER": + if (stringVal) { + const num = parseFloat(stringVal); + if (isNaN(num)) { + errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; + } else { + rowObj[col.key] = num; + } + } else { + rowObj[col.key] = null; + } + break; + + case "LIST": + if ( + stringVal && + col.options && + !col.options.includes(stringVal) + ) { + errorMessage += `[${ + col.label + }] '${stringVal}' not in ${col.options.join(", ")}. `; + } + rowObj[col.key] = stringVal; + break; + + default: + rowObj[col.key] = stringVal; + break; + } + }); + + // Validate TAG_NO + const tagNum = rowObj["TAG_NO"]; + if (!tagNum) { + errorMessage += `No TAG_NO found. `; + } else if (!existingTagNumbers.has(tagNum)) { + errorMessage += `TagNumber '${tagNum}' is not in current data. `; + } + + // Record errors or add to valid data + if (errorMessage) { + row.getCell(lastColIndex).value = errorMessage.trim(); + errorCount++; + } else { + importedData.push(rowObj); + } + } + + // If there are validation errors, download error report and exit + if (errorCount > 0) { + const outBuffer = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + toast.error( + `There are ${errorCount} error row(s). Please check downloaded file.` + ); + return { success: false, error: "Data validation errors" }; + } + + // If we reached here, all data is valid + // Create locally merged data for UI update + const mergedData = [...tableData]; + const dataMap = new Map<string, GenericData>(); + + // Map existing data by TAG_NO + mergedData.forEach(item => { + if (item.TAG_NO) { + dataMap.set(item.TAG_NO, item); + } + }); + + // Update with imported data + importedData.forEach(item => { + if (item.TAG_NO) { + const existingItem = dataMap.get(item.TAG_NO); + if (existingItem) { + // Update existing item with imported values + Object.assign(existingItem, item); + } + } + }); + + // If formCode and contractItemId are provided, save directly to DB + if (formCode && contractItemId) { + try { + // Process each imported row individually + let successCount = 0; + let errorCount = 0; + const errors = []; + + // Since updateFormDataInDB expects a single row at a time, + // we need to process each imported row individually + for (const importedRow of importedData) { + try { + const result = await updateFormDataInDB( + formCode, + contractItemId, + importedRow + ); + + if (result.success) { + successCount++; + } else { + errorCount++; + errors.push(`Error updating tag ${importedRow.TAG_NO}: ${result.message}`); + } + } catch (rowError) { + errorCount++; + errors.push(`Exception updating tag ${importedRow.TAG_NO}: ${rowError instanceof Error ? rowError.message : 'Unknown error'}`); + } + } + + // If any errors occurred + if (errorCount > 0) { + console.error("Errors during import:", errors); + + if (successCount > 0) { + toast.warning(`Partially successful: ${successCount} rows updated, ${errorCount} errors`); + } else { + toast.error(`Failed to update all ${errorCount} rows`); + } + + // If some rows were updated successfully, update the local state + if (successCount > 0) { + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + return { + success: true, + importedCount: successCount, + message: `Partially successful: ${successCount} rows updated, ${errorCount} errors` + }; + } else { + return { + success: false, + error: "All updates failed", + message: errors.join("\n") + }; + } + } + + // All rows were updated successfully + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.success(`Successfully updated ${successCount} rows`); + return { + success: true, + importedCount: successCount, + message: "All data imported and saved to database" + }; + } catch (saveError) { + console.error("Failed to save imported data:", saveError); + toast.error("Failed to save imported data to database"); + return { success: false, error: saveError }; + } + } else { + // Fall back to just updating local state if DB parameters aren't provided + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.success(`Imported ${importedData.length} rows successfully (local only)`); + return { success: true, importedCount: importedData.length }; + } + + } catch (err) { + console.error("Excel import error:", err); + toast.error("Excel import failed."); + return { success: false, error: err }; + } finally { + if (onPendingChange) onPendingChange(false); + } +}
\ No newline at end of file diff --git a/components/form-data/publish-dialog.tsx b/components/form-data/publish-dialog.tsx new file mode 100644 index 00000000..a3a2ef0b --- /dev/null +++ b/components/form-data/publish-dialog.tsx @@ -0,0 +1,470 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + createRevisionAction, + fetchDocumentsByPackageId, + fetchStagesByDocumentId, + fetchRevisionsByStageParams, + Document, + IssueStage, + Revision +} from "@/lib/vendor-document/service"; + +interface PublishDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + packageId: number; + formCode: string; + fileBlob?: Blob; +} + +export const PublishDialog: React.FC<PublishDialogProps> = ({ + open, + onOpenChange, + packageId, + formCode, + fileBlob, +}) => { + // Get current user session from next-auth + const { data: session } = useSession(); + + // State for form data + const [documents, setDocuments] = useState<Document[]>([]); + const [stages, setStages] = useState<IssueStage[]>([]); + const [latestRevision, setLatestRevision] = useState<string>(""); + + // State for document search + const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false); + const [documentSearchValue, setDocumentSearchValue] = useState(""); + + // Selected values + const [selectedDocId, setSelectedDocId] = useState<string>(""); + const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState<string>(""); + const [selectedStage, setSelectedStage] = useState<string>(""); + const [revisionInput, setRevisionInput] = useState<string>(""); + const [uploaderName, setUploaderName] = useState<string>(""); + const [comment, setComment] = useState<string>(""); + const [customFileName, setCustomFileName] = useState<string>(`${formCode}_document.docx`); + + // Loading states + const [isLoading, setIsLoading] = useState<boolean>(false); + const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + + // Filter documents by search + const filteredDocuments = documentSearchValue + ? documents.filter(doc => + doc.docNumber.toLowerCase().includes(documentSearchValue.toLowerCase()) || + doc.title.toLowerCase().includes(documentSearchValue.toLowerCase()) + ) + : documents; + + // Set uploader name from session when dialog opens + useEffect(() => { + if (open && session?.user?.name) { + setUploaderName(session.user.name); + } + }, [open, session]); + + // Reset all fields when dialog opens/closes + useEffect(() => { + if (open) { + setSelectedDocId(""); + setSelectedDocumentDisplay(""); + setSelectedStage(""); + setRevisionInput(""); + // Only set uploaderName if not already set from session + if (!session?.user?.name) setUploaderName(""); + setComment(""); + setLatestRevision(""); + setCustomFileName(`${formCode}_document.docx`); + setDocumentSearchValue(""); + } + }, [open, formCode, session]); + + // Fetch documents based on packageId + useEffect(() => { + async function loadDocuments() { + if (packageId && open) { + setIsLoading(true); + + try { + const docs = await fetchDocumentsByPackageId(packageId); + setDocuments(docs); + } catch (error) { + console.error("Error fetching documents:", error); + toast.error("Failed to load documents"); + } finally { + setIsLoading(false); + } + } + } + + loadDocuments(); + }, [packageId, open]); + + // Fetch stages when document is selected + useEffect(() => { + async function loadStages() { + if (selectedDocId) { + setIsLoading(true); + + // Reset dependent fields + setSelectedStage(""); + setRevisionInput(""); + setLatestRevision(""); + + try { + const stagesList = await fetchStagesByDocumentId(parseInt(selectedDocId, 10)); + setStages(stagesList); + } catch (error) { + console.error("Error fetching stages:", error); + toast.error("Failed to load stages"); + } finally { + setIsLoading(false); + } + } else { + setStages([]); + } + } + + loadStages(); + }, [selectedDocId]); + + // Fetch latest revision when stage is selected (for reference) + useEffect(() => { + async function loadLatestRevision() { + if (selectedDocId && selectedStage) { + setIsLoading(true); + + try { + const revsList = await fetchRevisionsByStageParams( + parseInt(selectedDocId, 10), + selectedStage + ); + + // Find the latest revision (assuming revisions are sorted by revision number) + if (revsList.length > 0) { + // Sort revisions if needed + const sortedRevisions = [...revsList].sort((a, b) => { + return b.revision.localeCompare(a.revision, undefined, { numeric: true }); + }); + + setLatestRevision(sortedRevisions[0].revision); + + // Pre-fill the revision input with an incremented value if possible + if (sortedRevisions[0].revision.match(/^\d+$/)) { + // If it's a number, increment it + const nextRevision = String(parseInt(sortedRevisions[0].revision, 10) + 1); + setRevisionInput(nextRevision); + } else if (sortedRevisions[0].revision.match(/^[A-Za-z]$/)) { + // If it's a single letter, get the next letter + const currentChar = sortedRevisions[0].revision.charCodeAt(0); + const nextChar = String.fromCharCode(currentChar + 1); + setRevisionInput(nextChar); + } else { + // For other formats, just show the latest as reference + setRevisionInput(""); + } + } else { + // If no revisions exist, set default values + setLatestRevision(""); + setRevisionInput("0"); + } + } catch (error) { + console.error("Error fetching revisions:", error); + toast.error("Failed to load revision information"); + } finally { + setIsLoading(false); + } + } else { + setLatestRevision(""); + setRevisionInput(""); + } + } + + loadLatestRevision(); + }, [selectedDocId, selectedStage]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedDocId || !selectedStage || !revisionInput || !fileBlob) { + toast.error("Please fill in all required fields"); + return; + } + + setIsSubmitting(true); + + try { + // Create FormData + const formData = new FormData(); + formData.append("documentId", selectedDocId); + formData.append("stage", selectedStage); + formData.append("revision", revisionInput); + formData.append("customFileName", customFileName); + formData.append("uploaderType", "vendor"); // Default value + + if (uploaderName) { + formData.append("uploaderName", uploaderName); + } + + if (comment) { + formData.append("comment", comment); + } + + // Append file as attachment + if (fileBlob) { + const file = new File([fileBlob], customFileName, { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + formData.append("attachment", file); + } + + // Call server action directly + const result = await createRevisionAction(formData); + + if (result) { + toast.success("Document published successfully!"); + onOpenChange(false); + } + } catch (error) { + console.error("Error publishing document:", error); + toast.error("Failed to publish document"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>Publish Document</DialogTitle> + <DialogDescription> + Select document, stage, and revision to publish the vendor document. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit}> + <div className="grid gap-4 py-4"> + {/* Document Selection with Search */} + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="document" className="text-right"> + Document + </Label> + <div className="col-span-3"> + <Popover + open={openDocumentCombobox} + onOpenChange={setOpenDocumentCombobox} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={openDocumentCombobox} + className="w-full justify-between" + disabled={isLoading || documents.length === 0} + > + {/* Add text-overflow handling for selected document display */} + <span className="truncate"> + {selectedDocumentDisplay + ? selectedDocumentDisplay + : "Select document..."} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="Search document..." + value={documentSearchValue} + onValueChange={setDocumentSearchValue} + /> + <CommandEmpty>No document found.</CommandEmpty> + <CommandGroup className="max-h-[300px] overflow-auto"> + {filteredDocuments.map((doc) => ( + <CommandItem + key={doc.id} + value={`${doc.docNumber} - ${doc.title}`} + onSelect={() => { + setSelectedDocId(String(doc.id)); + setSelectedDocumentDisplay(`${doc.docNumber} - ${doc.title}`); + setOpenDocumentCombobox(false); + }} + className="flex items-center" + > + <Check + className={cn( + "mr-2 h-4 w-4 flex-shrink-0", + selectedDocId === String(doc.id) + ? "opacity-100" + : "opacity-0" + )} + /> + {/* Add text-overflow handling for document items */} + <span className="truncate">{doc.docNumber} - {doc.title}</span> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + + {/* Stage Selection */} + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="stage" className="text-right"> + Stage + </Label> + <div className="col-span-3"> + <Select + value={selectedStage} + onValueChange={setSelectedStage} + disabled={isLoading || !selectedDocId || stages.length === 0} + > + <SelectTrigger> + <SelectValue placeholder="Select stage" /> + </SelectTrigger> + <SelectContent> + {stages.map((stage) => ( + <SelectItem key={stage.id} value={stage.stageName}> + {/* Add text-overflow handling for stage names */} + <span className="truncate">{stage.stageName}</span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {/* Revision Input */} + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="revision" className="text-right"> + Revision + </Label> + <div className="col-span-3"> + <Input + id="revision" + value={revisionInput} + onChange={(e) => setRevisionInput(e.target.value)} + placeholder="Enter revision" + disabled={isLoading || !selectedStage} + /> + {latestRevision && ( + <p className="text-xs text-muted-foreground mt-1"> + Latest revision: {latestRevision} + </p> + )} + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="fileName" className="text-right"> + File Name + </Label> + <div className="col-span-3"> + <Input + id="fileName" + value={customFileName} + onChange={(e) => setCustomFileName(e.target.value)} + placeholder="Custom file name" + /> + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="uploaderName" className="text-right"> + Uploader + </Label> + <div className="col-span-3"> + <Input + id="uploaderName" + value={uploaderName} + onChange={(e) => setUploaderName(e.target.value)} + placeholder="Your name" + // Disable input but show a filled style + className={session?.user?.name ? "opacity-70" : ""} + readOnly={!!session?.user?.name} + /> + {session?.user?.name && ( + <p className="text-xs text-muted-foreground mt-1"> + Using your account name from login + </p> + )} + </div> + </div> + + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="comment" className="text-right"> + Comment + </Label> + <div className="col-span-3"> + <Textarea + id="comment" + value={comment} + onChange={(e) => setComment(e.target.value)} + placeholder="Optional comment" + className="resize-none" + /> + </div> + </div> + </div> + + <DialogFooter> + <Button + type="submit" + disabled={isSubmitting || !selectedDocId || !selectedStage || !revisionInput} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Publishing... + </> + ) : ( + "Publish" + )} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +};
\ No newline at end of file diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx new file mode 100644 index 00000000..461a3630 --- /dev/null +++ b/components/form-data/sedp-compare-dialog.tsx @@ -0,0 +1,372 @@ +import * as React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Loader, RefreshCw, AlertCircle, CheckCircle, Info } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { DataTableColumnJSON } from "./form-data-table-columns"; +import { ExcelDownload } from "./sedp-excel-download"; + +interface SEDPCompareDialogProps { + isOpen: boolean; + onClose: () => void; + tableData: any[]; + columnsJSON: DataTableColumnJSON[]; + projectCode: string; + formCode: string; + fetchTagDataFromSEDP: (projectCode: string, formCode: string) => Promise<any>; +} + +interface ComparisonResult { + tagNo: string; + tagDesc: string; + isMatching: boolean; + attributes: { + key: string; + label: string; + localValue: any; + sedpValue: any; + isMatching: boolean; + uom?: string; + }[]; +} + +// Component for formatting display value with UOM +const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string; isSedp?: boolean }) => { + if (value === "" || value === null || value === undefined) { + return <span>(empty)</span>; + } + + // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정) + if (isSedp) { + return <span>{value}</span>; + } + + // 로컬 값은 UOM과 함께 표시 + return ( + <span> + {value} + {uom && <span className="text-xs text-muted-foreground ml-1">{uom}</span>} + </span> + ); +}; + +// 범례 컴포넌트 추가 +const ColorLegend = () => { + return ( + <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded"> + <div className="flex items-center gap-1.5"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">범례:</span> + </div> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-red-500"></div> + <span className="line-through text-red-500">로컬 값</span> + </div> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-green-500"></div> + <span className="text-green-500">SEDP 값</span> + </div> + </div> + </div> + ); +}; + +export function SEDPCompareDialog({ + isOpen, + onClose, + tableData, + columnsJSON, + projectCode, + formCode, + fetchTagDataFromSEDP, +}: SEDPCompareDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [comparisonResults, setComparisonResults] = React.useState<ComparisonResult[]>([]); + const [activeTab, setActiveTab] = React.useState("all"); + const [isExporting, setIsExporting] = React.useState(false); + + // Stats for summary + const totalTags = comparisonResults.length; + const matchingTags = comparisonResults.filter(r => r.isMatching).length; + const nonMatchingTags = totalTags - matchingTags; + + // Get column label map and UOM map for better display + const { columnLabelMap, columnUomMap } = React.useMemo(() => { + const labelMap: Record<string, string> = {}; + const uomMap: Record<string, string> = {}; + + columnsJSON.forEach(col => { + labelMap[col.key] = col.displayLabel || col.label; + if (col.uom) { + uomMap[col.key] = col.uom; + } + }); + + return { columnLabelMap: labelMap, columnUomMap: uomMap }; + }, [columnsJSON]); + + const fetchAndCompareData = React.useCallback(async () => { + if (!projectCode || !formCode) { + toast.error("Project code or form code is missing"); + return; + } + + try { + setIsLoading(true); + + // Fetch data from SEDP API + const sedpData = await fetchTagDataFromSEDP(projectCode, formCode); + + // Get the table name from the response + const tableName = Object.keys(sedpData)[0]; + const sedpTagEntries = sedpData[tableName] || []; + + // Create a map of SEDP data by TAG_NO for quick lookup + const sedpTagMap = new Map(); + sedpTagEntries.forEach((entry: any) => { + const tagNo = entry.TAG_NO; + const attributesMap = new Map(); + + // Convert attributes array to map for easier access + if (Array.isArray(entry.ATTRIBUTES)) { + entry.ATTRIBUTES.forEach((attr: any) => { + attributesMap.set(attr.ATT_ID, attr.VALUE); + }); + } + + sedpTagMap.set(tagNo, { + tagDesc: entry.TAG_DESC, + attributes: attributesMap + }); + }); + + // Compare with local table data + const results: ComparisonResult[] = tableData.map(localItem => { + const tagNo = localItem.TAG_NO; + const sedpItem = sedpTagMap.get(tagNo); + + // If tag not found in SEDP data + if (!sedpItem) { + return { + tagNo, + tagDesc: localItem.TAG_DESC || "", + isMatching: false, + attributes: columnsJSON + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") + .map(col => ({ + key: col.key, + label: columnLabelMap[col.key] || col.key, + localValue: localItem[col.key], + sedpValue: null, + isMatching: false, + uom: columnUomMap[col.key] + })) + }; + } + + // Compare attributes + const attributeComparisons = columnsJSON + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") + .map(col => { + const localValue = localItem[col.key]; + const sedpValue = sedpItem.attributes.get(col.key); + const uom = columnUomMap[col.key]; + + // Compare values (with type handling) + let isMatching = false; + + // 문자열 비교 + // Normalize empty values + const normalizedLocal = localValue === undefined || localValue === null ? "" : String(localValue).trim(); + const normalizedSedp = sedpValue === undefined || sedpValue === null ? "" : String(sedpValue).trim(); + isMatching = normalizedLocal === normalizedSedp; + + + return { + key: col.key, + label: columnLabelMap[col.key] || col.key, + localValue, + sedpValue, + isMatching, + uom + }; + }); + + // Item is matching if all attributes match + const isItemMatching = attributeComparisons.every(attr => attr.isMatching); + + return { + tagNo, + tagDesc: localItem.TAG_DESC || "", + isMatching: isItemMatching, + attributes: attributeComparisons + }; + }); + + setComparisonResults(results); + + // Show summary in toast + const matchCount = results.filter(r => r.isMatching).length; + const nonMatchCount = results.length - matchCount; + + if (nonMatchCount > 0) { + toast.warning(`Found ${nonMatchCount} tags with differences`); + } else if (results.length > 0) { + toast.success(`All ${results.length} tags match with SEDP data`); + } else { + toast.info("No tags to compare"); + } + + } catch (error) { + console.error("SEDP comparison error:", error); + toast.error(`Failed to compare with SEDP: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsLoading(false); + } + }, [projectCode, formCode, tableData, columnsJSON, fetchTagDataFromSEDP, columnLabelMap, columnUomMap]); + + // Fetch data when dialog opens + React.useEffect(() => { + if (isOpen) { + fetchAndCompareData(); + } + }, [isOpen, fetchAndCompareData]); + + // Filter results based on active tab + const filteredResults = React.useMemo(() => { + switch (activeTab) { + case "matching": + return comparisonResults.filter(r => r.isMatching); + case "differences": + return comparisonResults.filter(r => !r.isMatching); + case "all": + default: + return comparisonResults; + } + }, [comparisonResults, activeTab]); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center justify-between"> + <span>SEDP 데이터 비교</span> + <div className="flex items-center gap-2"> + <Badge variant={matchingTags === totalTags ? "default" : "destructive"}> + {matchingTags} / {totalTags} 일치 + </Badge> + <Button + variant="outline" + size="sm" + onClick={fetchAndCompareData} + disabled={isLoading} + > + {isLoading ? ( + <Loader className="h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="h-4 w-4" /> + )} + <span className="ml-2">새로고침</span> + </Button> + </div> + </DialogTitle> + </DialogHeader> + + {/* 범례 추가 */} + <div className="mb-4"> + <ColorLegend /> + </div> + + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> + <TabsList> + <TabsTrigger value="all">전체 태그 ({totalTags})</TabsTrigger> + <TabsTrigger value="differences">차이 있음 ({nonMatchingTags})</TabsTrigger> + <TabsTrigger value="matching">일치함 ({matchingTags})</TabsTrigger> + </TabsList> + + <TabsContent value={activeTab} className="flex-1 overflow-auto"> + {isLoading ? ( + <div className="flex items-center justify-center h-full"> + <Loader className="h-8 w-8 animate-spin mr-2" /> + <span>데이터 비교 중...</span> + </div> + ) : filteredResults.length > 0 ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[180px]">Tag Number</TableHead> + <TableHead className="w-[200px]">Tag Description</TableHead> + <TableHead className="w-[120px]">상태</TableHead> + <TableHead>차이점</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredResults.map((result) => { + // Find differences to display + const differences = result.attributes.filter(attr => !attr.isMatching); + + return ( + <TableRow key={result.tagNo} className={!result.isMatching ? "bg-muted/30" : ""}> + <TableCell className="font-medium">{result.tagNo}</TableCell> + <TableCell>{result.tagDesc}</TableCell> + <TableCell> + {result.isMatching ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + <span>일치</span> + </Badge> + ) : ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertCircle className="h-3 w-3" /> + <span>차이 있음</span> + </Badge> + )} + </TableCell> + <TableCell> + {differences.length > 0 ? ( + <div className="space-y-1"> + {differences.map((diff) => ( + <div key={diff.key} className="text-sm"> + <span className="font-medium">{diff.label}: </span> + <span className="line-through text-red-500 mr-2"> + <DisplayValue value={diff.localValue} uom={diff.uom} isSedp={false} /> + </span> + <span className="text-green-500"> + <DisplayValue value={diff.sedpValue} uom={diff.uom} isSedp={true} /> + </span> + </div> + ))} + </div> + ) : ( + <span className="text-muted-foreground">모든 값이 일치합니다</span> + )} + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + 현재 필터에 맞는 태그가 없습니다 + </div> + )} + </TabsContent> + </Tabs> + + <DialogFooter className="flex justify-between items-center gap-4 pt-4 border-t"> + <ExcelDownload + comparisonResults={comparisonResults} + formCode={formCode} + disabled={isLoading || nonMatchingTags === 0} + /> + <Button onClick={onClose}>닫기</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/sedp-components.tsx b/components/form-data/sedp-components.tsx new file mode 100644 index 00000000..4865e23d --- /dev/null +++ b/components/form-data/sedp-components.tsx @@ -0,0 +1,173 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Loader, Send, AlertTriangle, CheckCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; + +// SEDP Send Confirmation Dialog +export function SEDPConfirmationDialog({ + isOpen, + onClose, + onConfirm, + formName, + tagCount, + isLoading +}: { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + formName: string; + tagCount: number; + isLoading: boolean; +}) { + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Send Data to SEDP</DialogTitle> + <DialogDescription> + You are about to send form data to the Samsung Engineering Design Platform (SEDP). + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + <div className="grid grid-cols-2 gap-4 mb-4"> + <div className="text-muted-foreground">Form Name:</div> + <div className="font-medium">{formName}</div> + + <div className="text-muted-foreground">Total Tags:</div> + <div className="font-medium">{tagCount}</div> + </div> + + <div className="bg-amber-50 p-3 rounded-md border border-amber-200 flex items-start gap-2"> + <AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" /> + <div className="text-sm text-amber-800"> + Data sent to SEDP cannot be easily reverted. Please ensure all information is correct before proceeding. + </div> + </div> + </div> + + <DialogFooter className="gap-2 sm:gap-0"> + <Button variant="outline" onClick={onClose} disabled={isLoading}> + Cancel + </Button> + <Button + variant="samsung" + onClick={onConfirm} + disabled={isLoading} + className="gap-2" + > + {isLoading ? ( + <> + <Loader className="h-4 w-4 animate-spin" /> + Sending... + </> + ) : ( + <> + <Send className="h-4 w-4" /> + Send to SEDP + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// SEDP Status Dialog - shows the result of the SEDP operation +export function SEDPStatusDialog({ + isOpen, + onClose, + status, + message, + successCount, + errorCount, + totalCount +}: { + isOpen: boolean; + onClose: () => void; + status: 'success' | 'error' | 'partial'; + message: string; + successCount: number; + errorCount: number; + totalCount: number; +}) { + // Calculate percentage for the progress bar + const percentage = Math.round((successCount / totalCount) * 100); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {status === 'success' ? 'Data Sent Successfully' : + status === 'partial' ? 'Partially Successful' : + 'Failed to Send Data'} + </DialogTitle> + </DialogHeader> + + <div className="py-4"> + {/* Status Icon */} + <div className="flex justify-center mb-4"> + {status === 'success' ? ( + <div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"> + <CheckCircle className="h-8 w-8 text-green-600" /> + </div> + ) : status === 'partial' ? ( + <div className="h-12 w-12 rounded-full bg-amber-100 flex items-center justify-center"> + <AlertTriangle className="h-8 w-8 text-amber-600" /> + </div> + ) : ( + <div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center"> + <AlertTriangle className="h-8 w-8 text-red-600" /> + </div> + )} + </div> + + {/* Message */} + <p className="text-center mb-4">{message}</p> + + {/* Progress Stats */} + <div className="space-y-2 mb-4"> + <div className="flex justify-between text-sm"> + <span>Progress</span> + <span>{percentage}%</span> + </div> + <Progress value={percentage} className="h-2" /> + <div className="flex justify-between text-sm pt-1"> + <div> + <Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50"> + {successCount} Successful + </Badge> + </div> + {errorCount > 0 && ( + <div> + <Badge variant="outline" className="bg-red-50 text-red-700 hover:bg-red-50"> + {errorCount} Failed + </Badge> + </div> + )} + </div> + </div> + </div> + + <DialogFooter> + <Button onClick={onClose}> + Close + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/sedp-excel-download.tsx b/components/form-data/sedp-excel-download.tsx new file mode 100644 index 00000000..70f5c46a --- /dev/null +++ b/components/form-data/sedp-excel-download.tsx @@ -0,0 +1,163 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { FileDown, Loader } from "lucide-react"; +import { toast } from "sonner"; +import * as ExcelJS from 'exceljs'; + +interface ExcelDownloadProps { + comparisonResults: Array<{ + tagNo: string; + tagDesc: string; + isMatching: boolean; + attributes: Array<{ + key: string; + label: string; + localValue: any; + sedpValue: any; + isMatching: boolean; + uom?: string; + }>; + }>; + formCode: string; + disabled: boolean; +} + +export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDownloadProps) { + const [isExporting, setIsExporting] = React.useState(false); + + // Function to generate and download Excel file with differences + const handleExportDifferences = async () => { + try { + setIsExporting(true); + + // Get only items with differences + const itemsWithDifferences = comparisonResults.filter(item => !item.isMatching); + + if (itemsWithDifferences.length === 0) { + toast.info("차이가 없어 다운로드할 내용이 없습니다"); + return; + } + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SEDP Compare Tool'; + workbook.created = new Date(); + + // Add a worksheet + const worksheet = workbook.addWorksheet('SEDP Differences'); + + // Add headers + worksheet.columns = [ + { header: 'Tag Number', key: 'tagNo', width: 20 }, + { header: 'Tag Description', key: 'tagDesc', width: 30 }, + { header: 'Attribute', key: 'attribute', width: 25 }, + { header: 'Local Value', key: 'localValue', width: 20 }, + { header: 'SEDP Value', key: 'sedpValue', width: 20 } + ]; + + // Style the header row + const headerRow = worksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // Add data rows + let rowIndex = 2; + itemsWithDifferences.forEach(item => { + const differences = item.attributes.filter(attr => !attr.isMatching); + + if (differences.length === 0) return; + + differences.forEach(diff => { + const row = worksheet.getRow(rowIndex++); + + // Format local value with UOM + const localDisplay = diff.localValue === null || diff.localValue === undefined || diff.localValue === '' + ? "(empty)" + : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue; + + // SEDP value is displayed as-is + const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === '' + ? "(empty)" + : diff.sedpValue; + + // Set cell values + row.getCell('tagNo').value = item.tagNo; + row.getCell('tagDesc').value = item.tagDesc; + row.getCell('attribute').value = diff.label; + row.getCell('localValue').value = localDisplay; + row.getCell('sedpValue').value = sedpDisplay; + + // Style the row + row.getCell('localValue').font = { color: { argb: 'FFFF0000' } }; // Red for local value + row.getCell('sedpValue').font = { color: { argb: 'FF008000' } }; // Green for SEDP value + + // Add borders + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + // Add a blank row after each tag for better readability + rowIndex++; + }); + + // Generate Excel file + const buffer = await workbook.xlsx.writeBuffer(); + + // Create a Blob from the buffer + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + + // Create a download link and trigger the download + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `SEDP_Differences_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`; + document.body.appendChild(a); + a.click(); + + // Clean up + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success("차이점 Excel 다운로드 완료"); + } catch (error) { + console.error("Error exporting to Excel:", error); + toast.error("Excel 다운로드 실패"); + } finally { + setIsExporting(false); + } + }; + + return ( + <Button + variant="secondary" + onClick={handleExportDifferences} + disabled={disabled || isExporting} + className="flex items-center gap-2" + > + {isExporting ? ( + <Loader className="h-4 w-4 animate-spin" /> + ) : ( + <FileDown className="h-4 w-4" /> + )} + 차이점 Excel로 다운로드 + </Button> + ); +}
\ No newline at end of file diff --git a/components/form-data/temp-download-btn.tsx b/components/form-data/temp-download-btn.tsx index a5f963e4..793022d6 100644 --- a/components/form-data/temp-download-btn.tsx +++ b/components/form-data/temp-download-btn.tsx @@ -29,17 +29,18 @@ export const TempDownloadBtn = () => { }; return ( <Button - variant="ghost" - className="relative p-2" + variant="outline" + className="relative px-[8px] py-[6px] flex-1" aria-label="Template Sample Download" onClick={downloadTempFile} > <Image src="/icons/temp_sample_icon.svg" alt="Template Sample Download Icon" - width={20} - height={20} + width={16} + height={16} /> + <div className='text-[12px]'>Sample Template Download</div> </Button> ); -}; +};
\ No newline at end of file diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index c52b6833..27f426c1 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -4,8 +4,9 @@ import * as React from "react"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { Loader } from "lucide-react"; +import { Check, ChevronsUpDown, Loader } from "lucide-react"; import { toast } from "sonner"; +import { useRouter } from "next/navigation"; // Add this import import { Sheet, @@ -27,15 +28,22 @@ import { FormMessage, } from "@/components/ui/form"; import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, -} from "@/components/ui/select"; + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" import { DataTableColumnJSON } from "./form-data-table-columns"; import { updateFormDataInDB } from "@/lib/forms/services"; +import { cn } from "@/lib/utils"; interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { @@ -60,6 +68,7 @@ export function UpdateTagSheet({ ...props }: UpdateTagSheetProps) { const [isPending, startTransition] = React.useTransition(); + const router = useRouter(); // Add router hook // 1) zod 스키마 const dynamicSchema = React.useMemo(() => { @@ -104,27 +113,41 @@ export function UpdateTagSheet({ async function onSubmit(values: Record<string, any>) { startTransition(async () => { - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - values - ); - if (!success) { - toast.error(message); - return; - } - toast.success("Updated successfully!"); + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + values + ); + + if (!success) { + toast.error(message); + return; + } + + // Success handling + toast.success("Updated successfully!"); + + // Create a merged object of original rowData and new values + const updatedData = { + ...rowData, + ...values, + TAG_NO: rowData?.TAG_NO, + }; - // (A) 수정된 값(폼 데이터)을 부모 콜백에 전달 - onUpdateSuccess?.({ - // rowData(원본)와 values를 합쳐서 최종 "수정된 row"를 만든다. - // tagNumber는 기존 그대로 - ...rowData, - ...values, - tagNumber: rowData?.tagNumber, - }); + // Call the success callback + onUpdateSuccess?.(updatedData); - onOpenChange(false); + // Refresh the entire route to get fresh data + router.refresh(); + + // Close the sheet + onOpenChange(false); + + } catch (error) { + console.error("Error updating form data:", error); + toast.error("An unexpected error occurred while updating"); + } }); } @@ -147,7 +170,7 @@ export function UpdateTagSheet({ <div className="flex flex-col gap-4 pt-2"> {columns.map((col) => { const isTagNumberField = - col.key === "tagNumber" || col.key === "tagDescription"; + col.key === "TAG_NO" || col.key === "TAG_DESC"; return ( <FormField key={col.key} @@ -178,22 +201,51 @@ export function UpdateTagSheet({ return ( <FormItem> <FormLabel>{col.label}</FormLabel> - <Select - disabled={isTagNumberField} - value={field.value ?? ""} - onValueChange={(val) => field.onChange(val)} - > - <SelectTrigger> - <SelectValue placeholder="Select an option" /> - </SelectTrigger> - <SelectContent> - {col.options?.map((opt) => ( - <SelectItem key={opt} value={opt}> - {opt} - </SelectItem> - ))} - </SelectContent> - </Select> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + disabled={isTagNumberField} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? col.options?.find((opt) => opt === field.value) + : "Select an option"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search options..." /> + <CommandEmpty>No option found.</CommandEmpty> + <CommandList> + <CommandGroup> + {col.options?.map((opt) => ( + <CommandItem + key={opt} + value={opt} + onSelect={() => { + field.onChange(opt); + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === opt ? "opacity-100" : "opacity-0" + )} + /> + {opt} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> <FormMessage /> </FormItem> ); @@ -253,4 +305,4 @@ export function UpdateTagSheet({ </SheetContent> </Sheet> ); -} +}
\ No newline at end of file diff --git a/components/form-data/var-list-download-btn.tsx b/components/form-data/var-list-download-btn.tsx index 19bb26f9..bbadf893 100644 --- a/components/form-data/var-list-download-btn.tsx +++ b/components/form-data/var-list-download-btn.tsx @@ -50,11 +50,12 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ // 2. 데이터 행 추가 columnsJSON.forEach((row) => { - const { displayLabel, label } = row; + console.log(row) + const { displayLabel, key } = row; - const labelConvert = label.replaceAll(" ", "_"); + // const labelConvert = label.replaceAll(" ", "_"); - worksheet.addRow([displayLabel, labelConvert]); + worksheet.addRow([displayLabel, key]); }); // 3. 컬럼 너비 자동 조정 @@ -94,17 +95,18 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ return ( <Button - variant="ghost" - className="relative p-2" + variant="outline" + className="relative px-[8px] py-[6px] flex-1" aria-label="Variable List Download" onClick={downloadReportVarList} > <Image src="/icons/var_list_icon.svg" alt="Template Sample Download Icon" - width={20} - height={20} + width={16} + height={16} /> + <div className='text-[12px]'>Variable List Download</div> </Button> ); -}; +};
\ No newline at end of file diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 498668eb..6747e549 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -97,7 +97,13 @@ export function Header() { width={20} height={20} /> - <span className="hidden font-bold lg:inline-block">eVCP</span> + <span className="hidden font-bold lg:inline-block"> + {isPartnerRoute + ? "eVCP Partners" + : pathname?.includes("/evcp") + ? "eVCP 삼성중공업" + : "eVCP"} + </span> </Link> </div> diff --git a/components/login/login-form copy 2.tsx b/components/login/login-form copy 2.tsx new file mode 100644 index 00000000..d5ac01b9 --- /dev/null +++ b/components/login/login-form copy 2.tsx @@ -0,0 +1,470 @@ +'use client'; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon, ArrowRight } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" +import { useTranslation } from '@/i18n/client' +import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp" +import { signIn } from 'next-auth/react'; +import { sendOtpAction } from "@/lib/users/send-otp"; +import { verifyTokenAction } from "@/lib/users/verifyToken"; +import { buttonVariants } from "@/components/ui/button" +import Link from "next/link" +import Image from 'next/image'; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + + const params = useParams() || {}; + const pathname = usePathname() || ''; + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams?.get('token') || null; + const [showCredentialsForm, setShowCredentialsForm] = useState(false); + // 새로운 상태: 업체 등록 안내 표시 여부 + const [showVendorRegistrationInfo, setShowVendorRegistrationInfo] = useState(false); + + const lng = params.lng as string; + const { t, i18n } = useTranslation(lng, 'login'); + + const { toast } = useToast(); + + const handleChangeLanguage = (lang: string) => { + const segments = pathname.split('/'); + segments[1] = lang; + router.push(segments.join('/')); + }; + + const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); + + const [email, setEmail] = useState(''); + const [otpSent, setOtpSent] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [otp, setOtp] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + // 업체 등록 페이지로 이동하는 함수 + const goToVendorRegistration = () => { + router.push(`/${lng}/partners/repository`); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + const result = await sendOtpAction(email, lng); + + if (result.success) { + setOtpSent(true); + toast({ + title: t('otpSentTitle'), + description: t('otpSentMessage'), + }); + } else { + // Handle specific error types + let errorMessage = t('defaultErrorMessage'); + + // 업체 미등록 사용자 에러 처리 + if (result.error === 'userNotFound' || result.error === 'vendorNotRegistered') { + setShowVendorRegistrationInfo(true); + errorMessage = t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.'; + } + + toast({ + title: t('errorTitle'), + description: result.message || errorMessage, + variant: 'destructive', + }); + } + } catch (error) { + // This will catch network errors or other unexpected issues + console.error(error); + toast({ + title: t('errorTitle'), + description: t('networkErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + async function handleOtpSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + + try { + // next-auth의 Credentials Provider로 로그인 시도 + const result = await signIn('credentials', { + email, + code: otp, + redirect: false, // 커스텀 처리 위해 redirect: false + }); + + if (result?.ok) { + // 토스트 메시지 표시 + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + router.push(`/${lng}/partners/dashboard`); + } else { + // 로그인 실패 시 에러 메시지에 업체 등록 관련 정보 포함 + if (result?.error === 'vendorNotRegistered') { + setShowVendorRegistrationInfo(true); + toast({ + title: t('errorTitle'), + description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.', + variant: 'destructive', + }); + } else { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + } + + // 새로운 로그인 처리 함수 추가 + const handleCredentialsLogin = async () => { + if (!username || !password) { + toast({ + title: t('errorTitle'), + description: t('credentialsRequired'), + variant: 'destructive', + }); + return; + } + + setIsLoading(true); + + try { + // next-auth의 다른 credentials provider로 로그인 시도 + const result = await signIn('credentials-password', { + username, + password, + redirect: false, + }); + + if (result?.ok) { + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + router.push(`/${lng}/partners/dashboard`); + } else { + // 로그인 실패 시 업체 등록 관련 정보 표시 여부 결정 + if (result?.error === 'vendorNotRegistered') { + setShowVendorRegistrationInfo(true); + toast({ + title: t('errorTitle'), + description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.', + variant: 'destructive', + }); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidCredentials'), + variant: 'destructive', + }); + } + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + const verifyToken = async () => { + if (!token) return; + setIsLoading(true); + + try { + const data = await verifyTokenAction(token); + + if (data.valid) { + setOtpSent(true); + setEmail(data.email ?? ''); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidToken'), + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + verifyToken(); + }, [token, toast, t]); + + return ( + <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> + {/* Left Content */} + <div className="flex flex-col w-full h-screen lg:p-2"> + {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} + <div className="flex items-center justify-between p-4"> + <div className="flex items-center space-x-2"> + <Ship className="w-4 h-4" /> + <span className="text-md font-bold">eVCP</span> + </div> + + {/* 업체 등록 신청 버튼 - 가시성 향상을 위해 variant 변경 */} + <Link + href={`/${lng}/partners/repository`} + className={cn(buttonVariants({ variant: "outline" }), "border-blue-500 text-blue-600 hover:bg-blue-50")} + > + <InfoIcon className="w-4 h-4 mr-2" /> + {t('registerVendor') || '업체 등록 신청'} + </Link> + </div> + + {/* Content section that occupies remaining space, centered vertically */} + <div className="flex-1 flex items-center justify-center"> + {/* Your form container */} + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + {/* 업체 등록 안내 알림 - 특정 상황에서만 표시 */} + {showVendorRegistrationInfo && ( + <Alert className="border-blue-500 bg-blue-50"> + <InfoIcon className="h-4 w-4 text-blue-600" /> + <AlertTitle className="text-blue-700"> + {t('vendorRegistrationRequired') || '업체 등록이 필요합니다'} + </AlertTitle> + <AlertDescription className="text-blue-600"> + {t('vendorRegistrationMessage') || '로그인하시려면 먼저 업체 등록이 필요합니다. 아래 버튼을 클릭하여 등록을 진행해주세요.'} + </AlertDescription> + <Button + onClick={goToVendorRegistration} + className="mt-2 w-full bg-blue-600 hover:bg-blue-700 text-white" + > + {t('goToVendorRegistration') || '업체 등록 신청하기'} + <ArrowRight className="ml-2 h-4 w-4" /> + </Button> + </Alert> + )} + + <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + + {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} + <p className="text-sm text-muted-foreground mt-2"> + {t('loginDescription') || '등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} + </p> + </div> + + {/* S-chips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} + {!showCredentialsForm && ( + <> + <div className="grid gap-2"> + <Input + id="email" + type="email" + placeholder={t('email')} + required + className="h-10" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </div> + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isLoading} + onClick={handleSubmit} + > + {isLoading ? t('sending') : t('ContinueWithEmail')} + </Button> + + {/* 구분선과 "Or continue with" 섹션 추가 */} + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <span className="w-full border-t"></span> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + {t('orContinueWith')} + </span> + </div> + </div> + + {/* S-chips 로그인 버튼 */} + <Button + type="button" + className="w-full" + onClick={() => setShowCredentialsForm(true)} + > + S-Gips로 로그인하기 + </Button> + + {/* 업체 등록 안내 링크 추가 */} + <Button + type="button" + variant="link" + className="text-blue-600 hover:text-blue-800" + onClick={goToVendorRegistration} + > + {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'} + </Button> + </> + )} + + {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} + {showCredentialsForm && ( + <> + <div className="grid gap-4"> + <Input + id="username" + type="text" + placeholder="S-chips ID" + className="h-10" + value={username} + onChange={(e) => setUsername(e.target.value)} + /> + <Input + id="password" + type="password" + placeholder="비밀번호" + className="h-10" + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + <Button + type="button" + className="w-full" + variant="samsung" + onClick={handleCredentialsLogin} + disabled={isLoading} + > + {isLoading ? "로그인 중..." : "로그인"} + </Button> + + {/* 뒤로 가기 버튼 */} + <Button + type="button" + variant="ghost" + className="w-full text-sm" + onClick={() => setShowCredentialsForm(false)} + > + 이메일로 로그인하기 + </Button> + + {/* 업체 등록 안내 링크 추가 */} + <Button + type="button" + variant="link" + className="text-blue-600 hover:text-blue-800" + onClick={goToVendorRegistration} + > + {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'} + </Button> + </div> + </> + )} + + <div className="text-center text-sm mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} + <a href="#">{t('privacyPolicy')}</a>. + </div> + </div> + </div> + </div> + + {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> + {/* Image 컴포넌트로 대체 */} + <div className="absolute inset-0"> + <Image + src="/images/02.jpg" + alt="Background image" + fill + priority + sizes="(max-width: 1024px) 100vw, 50vw" + className="object-cover" + /> + </div> + <div className="relative z-10 mt-auto"> + <blockquote className="space-y-2"> + <p className="text-sm">“{t("blockquote")}”</p> + {/* <footer className="text-sm">SHI</footer> */} + </blockquote> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/login/login-form copy.tsx b/components/login/login-form copy.tsx new file mode 100644 index 00000000..ef9eba10 --- /dev/null +++ b/components/login/login-form copy.tsx @@ -0,0 +1,468 @@ +'use client'; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" +import { useTranslation } from '@/i18n/client' +import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp" +import { signIn } from 'next-auth/react'; +import { sendOtpAction } from "@/lib/users/send-otp"; +import { verifyTokenAction } from "@/lib/users/verifyToken"; +import { buttonVariants } from "@/components/ui/button" +import Link from "next/link" +import Image from 'next/image'; // 추가: Image 컴포넌트 import + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + + const params = useParams() || {}; + const pathname = usePathname() || ''; + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams?.get('token') || null; + const [showCredentialsForm, setShowCredentialsForm] = useState(false); + + + const lng = params.lng as string; + const { t, i18n } = useTranslation(lng, 'login'); + + const { toast } = useToast(); + + const handleChangeLanguage = (lang: string) => { + const segments = pathname.split('/'); + segments[1] = lang; + router.push(segments.join('/')); + }; + + const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); + + const [email, setEmail] = useState(''); + const [otpSent, setOtpSent] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [otp, setOtp] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const goToVendorRegistration = () => { + router.push(`/${lng}/partners/repository`); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + const result = await sendOtpAction(email, lng); + + if (result.success) { + setOtpSent(true); + toast({ + title: t('otpSentTitle'), + description: t('otpSentMessage'), + }); + } else { + // Handle specific error types + let errorMessage = t('defaultErrorMessage'); + + // You can handle different error types differently + if (result.error === 'userNotFound') { + errorMessage = t('userNotFoundMessage'); + } + + toast({ + title: t('errorTitle'), + description: result.message || errorMessage, + variant: 'destructive', + }); + } + } catch (error) { + // This will catch network errors or other unexpected issues + console.error(error); + toast({ + title: t('errorTitle'), + description: t('networkErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + async function handleOtpSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + + try { + // next-auth의 Credentials Provider로 로그인 시도 + const result = await signIn('credentials', { + email, + code: otp, + redirect: false, // 커스텀 처리 위해 redirect: false + }); + + if (result?.ok) { + // 토스트 메시지 표시 + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + router.push(`/${lng}/partners/dashboard`); + + } else { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + } + + // 새로운 로그인 처리 함수 추가 + const handleCredentialsLogin = async () => { + if (!username || !password) { + toast({ + title: t('errorTitle'), + description: t('credentialsRequired'), + variant: 'destructive', + }); + return; + } + + setIsLoading(true); + + try { + // next-auth의 다른 credentials provider로 로그인 시도 + const result = await signIn('credentials-password', { + username, + password, + redirect: false, + }); + + if (result?.ok) { + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + router.push(`/${lng}/partners/dashboard`); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidCredentials'), + variant: 'destructive', + }); + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + const verifyToken = async () => { + if (!token) return; + setIsLoading(true); + + try { + const data = await verifyTokenAction(token); + + if (data.valid) { + setOtpSent(true); + setEmail(data.email ?? ''); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidToken'), + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + verifyToken(); + }, [token, toast, t]); + + return ( + <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> + {/* Left Content */} + <div className="flex flex-col w-full h-screen lg:p-2"> + {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + {/* <img + src="/images/logo.png" + alt="logo" + className="h-8 w-auto" + /> */} + <Ship className="w-4 h-4" /> + <span className="text-md font-bold">eVCP</span> + </div> + <Link + href="/partners/repository" + className={cn(buttonVariants({ variant: "ghost" }))} + > + <InfoIcon className="w-4 h-4 mr-1" /> + {'업체 등록 신청'} + </Link> + </div> + + {/* Content section that occupies remaining space, centered vertically */} + <div className="flex-1 flex items-center justify-center"> + {/* Your form container */} + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + + {/* Here's your existing login/OTP forms: */} + {/* {!otpSent ? ( */} + + {/* <form onSubmit={handleSubmit} className="p-6 md:p-8"> */} + <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + + {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} + <p className="text-xs text-muted-foreground mt-2"> + {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} + </p> + </div> + + {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} + {!showCredentialsForm && ( + <> + <div className="grid gap-2"> + <Input + id="email" + type="email" + placeholder={t('email')} + required + className="h-10" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </div> + <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> + {isLoading ? t('sending') : t('ContinueWithEmail')} + </Button> + + {/* 구분선과 "Or continue with" 섹션 추가 */} + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <span className="w-full border-t"></span> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + {t('orContinueWith')} + </span> + </div> + </div> + + {/* S-Gips 로그인 버튼 */} + <Button + type="button" + className="w-full" + // variant="" + onClick={() => setShowCredentialsForm(true)} + > + S-Gips로 로그인하기 + </Button> + + {/* 업체 등록 안내 링크 추가 */} + <Button + type="button" + variant="link" + className="text-blue-600 hover:text-blue-800" + onClick={goToVendorRegistration} + > + {'신규 업체이신가요? 여기서 등록하세요'} + </Button> + </> + )} + + {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} + {showCredentialsForm && ( + <> + <div className="grid gap-4"> + <Input + id="username" + type="text" + placeholder="S-Gips ID" + className="h-10" + value={username} + onChange={(e) => setUsername(e.target.value)} + /> + <Input + id="password" + type="password" + placeholder="비밀번호" + className="h-10" + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + <Button + type="button" + className="w-full" + variant="samsung" + onClick={handleCredentialsLogin} + disabled={isLoading} + > + {isLoading ? "로그인 중..." : "로그인"} + </Button> + + {/* 뒤로 가기 버튼 */} + <Button + type="button" + variant="ghost" + className="w-full text-sm" + onClick={() => setShowCredentialsForm(false)} + > + 이메일로 로그인하기 + </Button> + </div> + </> + )} + + <div className="text-center text-sm mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + {/* ) : ( + <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + </div> + <div className="grid gap-2 justify-center"> + <InputOTP + maxLength={6} + value={otp} + onChange={(value) => setOtp(value)} + > + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> + </InputOTP> + </div> + <Button type="submit" className="w-full" disabled={isLoading}> + {isLoading ? t('verifying') : t('verifyOtp')} + </Button> + <div className="mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + )} */} + + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} + <a href="#">{t('privacyPolicy')}</a>. + </div> + </div> + </div> + </div> + + {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> + {/* Image 컴포넌트로 대체 */} + <div className="absolute inset-0"> + <Image + src="/images/02.jpg" + alt="Background image" + fill + priority + sizes="(max-width: 1024px) 100vw, 50vw" + className="object-cover" + /> + </div> + <div className="relative z-10 mt-auto"> + <blockquote className="space-y-2"> + <p className="text-sm">“{t("blockquote")}”</p> + {/* <footer className="text-sm">SHI</footer> */} + </blockquote> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx index fb985592..ef39d122 100644 --- a/components/login/login-form-shi.tsx +++ b/components/login/login-form-shi.tsx @@ -110,8 +110,25 @@ export function LoginFormSHI({ title: t('loginSuccess'), description: t('youAreLoggedIn'), }); - - router.push(`/${lng}/evcp/report`); + + const callbackUrlParam = searchParams?.get('callbackUrl'); + + if (callbackUrlParam) { + try { + // URL 객체로 파싱 + const callbackUrl = new URL(callbackUrlParam); + + // pathname + search만 사용 (호스트 제거) + const relativeUrl = callbackUrl.pathname + callbackUrl.search; + router.push(relativeUrl); + } catch (e) { + // 유효하지 않은 URL이면 그대로 사용 (이미 상대 경로일 수 있음) + router.push(callbackUrlParam); + } + } else { + // callbackUrl이 없으면 기본 대시보드로 리다이렉트 + router.push(`/${lng}/evcp/report`); + } } else { toast({ @@ -186,8 +203,10 @@ export function LoginFormSHI({ <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> {/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - <form onSubmit={handleSubmit} className="p-6 md:p-8"> + {/* {!otpSent ? + ( */} + {/* <form onSubmit={handleSubmit} className="p-6 md:p-8"> */} + <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> <div className="flex flex-col gap-6"> <div className="flex flex-col items-center text-center"> <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> @@ -232,7 +251,10 @@ export function LoginFormSHI({ </div> </div> </form> - ) : ( + {/* ) + + + : ( <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> <div className="flex flex-col gap-6"> <div className="flex flex-col items-center text-center"> @@ -283,7 +305,7 @@ export function LoginFormSHI({ </div> </div> </form> - )} + )} */} <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 92fa6e2c..7236c02e 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" -import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship } from "lucide-react"; +import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" import { useTranslation } from '@/i18n/client' @@ -55,6 +55,10 @@ export function LoginForm({ const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const goToVendorRegistration = () => { + router.push(`/${lng}/partners/repository`); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); @@ -114,7 +118,24 @@ export function LoginForm({ description: t('youAreLoggedIn'), }); - router.push(`/${lng}/partners/dashboard`); + const callbackUrlParam = searchParams?.get('callbackUrl'); + + if (callbackUrlParam) { + try { + // URL 객체로 파싱 + const callbackUrl = new URL(callbackUrlParam); + + // pathname + search만 사용 (호스트 제거) + const relativeUrl = callbackUrl.pathname + callbackUrl.search; + router.push(relativeUrl); + } catch (e) { + // 유효하지 않은 URL이면 그대로 사용 (이미 상대 경로일 수 있음) + router.push(callbackUrlParam); + } + } else { + // callbackUrl이 없으면 기본 대시보드로 리다이렉트 + router.push(`/${lng}/partners/dashboard`); + } } else { toast({ @@ -232,7 +253,8 @@ export function LoginForm({ href="/partners/repository" className={cn(buttonVariants({ variant: "ghost" }))} > - Request Vendor Repository + <InfoIcon className="w-4 h-4 mr-1" /> + {'업체 등록 신청'} </Link> </div> @@ -242,14 +264,21 @@ export function LoginForm({ <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> {/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - <form onSubmit={handleSubmit} className="p-6 md:p-8"> + {/* {!otpSent ? ( */} + + {/* <form onSubmit={handleSubmit} className="p-6 md:p-8"> */} + <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - </div> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + + {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} + <p className="text-xs text-muted-foreground mt-2"> + {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} + </p> + </div> - {/* S-chips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} + {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} {!showCredentialsForm && ( <> <div className="grid gap-2"> @@ -279,15 +308,25 @@ export function LoginForm({ </div> </div> - {/* S-chips 로그인 버튼 */} + {/* S-Gips 로그인 버튼 */} <Button type="button" className="w-full" // variant="" onClick={() => setShowCredentialsForm(true)} > - S-chips로 로그인하기 + S-Gips로 로그인하기 </Button> + + {/* 업체 등록 안내 링크 추가 */} + <Button + type="button" + variant="link" + className="text-blue-600 hover:text-blue-800" + onClick={goToVendorRegistration} + > + {'신규 업체이신가요? 여기서 등록하세요'} + </Button> </> )} @@ -298,7 +337,7 @@ export function LoginForm({ <Input id="username" type="text" - placeholder="S-chips ID" + placeholder="S-Gips ID" className="h-10" value={username} onChange={(e) => setUsername(e.target.value)} @@ -360,7 +399,7 @@ export function LoginForm({ </div> </div> </form> - ) : ( + {/* ) : ( <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> <div className="flex flex-col gap-6"> <div className="flex flex-col items-center text-center"> @@ -411,7 +450,7 @@ export function LoginForm({ </div> </div> </form> - )} + )} */} <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx index effd7bd3..ada64d96 100644 --- a/components/login/partner-auth-form.tsx +++ b/components/login/partner-auth-form.tsx @@ -16,11 +16,22 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { GlobeIcon, ChevronDownIcon, Loader, Ship } from "lucide-react" +import { GlobeIcon, ChevronDownIcon, Loader, Ship, LogIn, InfoIcon, HelpCircle } from "lucide-react" import { languages } from "@/config/language" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { siteConfig } from "@/config/site" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert" import { checkJoinPortal } from "@/lib/vendors/service" import Image from "next/image" @@ -30,11 +41,13 @@ interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> { } export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { const [isLoading, setIsLoading] = React.useState<boolean>(false) + const [showInfoBanner, setShowInfoBanner] = React.useState<boolean>(true) const router = useRouter() const { toast } = useToast() - const params = useParams() - const pathname = usePathname() + const params = useParams() || {}; + const pathname = usePathname() || ''; + const lng = params.lng as string const { t, i18n } = useTranslation(lng, "login") @@ -51,6 +64,11 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { ? t("languages.japanese") : t("languages.english") + // 로그인 페이지로 이동 + const goToLogin = () => { + router.push(`/${lng}/partners`); + } + // --------------------------- // 1) onSubmit -> 서버 액션 호출 // --------------------------- @@ -86,11 +104,26 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { // 가입 가능 → signup 페이지 이동 router.push(`/partners/signup?taxID=${taxID}`) } else { + // 이미 등록된 기업인 경우 - 로그인으로 안내하는 토스트와 함께 추가 액션 제공 toast({ variant: "destructive", title: "가입이 진행 중이거나 완료된 회사", description: `${result.data} 에 연락하여 계정 생성 요청을 하시기 바랍니다.`, }) + + // 로그인 액션 버튼이 있는 알림 표시 + setTimeout(() => { + toast({ + title: "이미 등록된 회사이신가요?", + description: "로그인 페이지로 이동하여 계정에 접속하세요.", + action: ( + <Button variant="outline" onClick={goToLogin} className="bg-blue-50 border-blue-300"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Button> + ), + }) + }, 1000); } } catch (error: any) { console.error(error) @@ -111,43 +144,80 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { <div className="flex flex-col w-full h-screen lg:p-2"> {/* Top bar */} - <div className="flex items-center justify-between"> + <div className="flex items-center justify-between p-4"> <div className="flex items-center space-x-2"> - {/* <img - src="/images/logo.png" - alt="logo" - className="h-8 w-auto" - /> */} <Ship className="w-4 h-4" /> <span className="text-md font-bold">eVCP</span> </div> - {/* Remove 'absolute right-4 top-4 ...', just use buttonVariants */} + {/* 로그인 버튼 가시성 개선 */} <Link - href="/login" + href={`/${lng}/partners`} className={cn( - buttonVariants({ variant: "ghost" }) + buttonVariants({ variant: "outline" }), + "border-blue-500 text-blue-600 hover:bg-blue-50" )} > - Login + <LogIn className="mr-2 h-4 w-4" /> + {t("login") || "로그인"} </Link> </div> <div className="flex-1 flex items-center justify-center"> - <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[400px]"> + {/* 정보 알림 배너 - 업체 등록과 로그인의 관계 설명 */} + {showInfoBanner && ( + <Alert className="bg-blue-50 border-blue-200"> + <InfoIcon className="h-4 w-4 text-blue-600" /> + <AlertTitle className="text-blue-700 mt-1"> + {t("registrationInfoTitle") || "업체 등록 신청 안내"} + </AlertTitle> + <AlertDescription className="text-blue-600"> + {t("registrationInfoDescription") || "이미 등록된 업체의 직원이신가요? 상단의 로그인 버튼을 눌러 로그인하세요. 새로운 업체 등록을 원하시면 아래 양식을 작성해주세요."} + </AlertDescription> + <Button + variant="ghost" + size="sm" + onClick={() => setShowInfoBanner(false)} + className="absolute top-2 right-4 h-6 w-6 p-0" + > + ✕ + </Button> + </Alert> + )} + <div className="flex flex-col space-y-2 text-center"> <h1 className="text-2xl font-semibold tracking-tight"> - {t("heading")} + {t("heading") || "업체 등록 신청"} </h1> - <p className="text-sm text-muted-foreground">{t("subheading")}</p> + <p className="text-sm text-muted-foreground"> + {t("subheading") || "귀사의 사업자 등록 번호를 입력하여 등록을 시작하세요"} + </p> </div> <div className={cn("grid gap-6", className)} {...props}> <form onSubmit={onSubmit}> - <div className="grid gap-2"> - <div className="grid gap-1"> - <label className="sr-only" htmlFor="taxid"> - Business Number / Tax ID - </label> + <div className="grid gap-4"> + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <Label htmlFor="taxid"> + 사업자등록번호 / Tax ID + </Label> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="ghost" size="icon" className="h-6 w-6 p-0"> + <HelpCircle className="h-4 w-4 text-muted-foreground" /> + <span className="sr-only">Help</span> + </Button> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs"> + {t("taxIdTooltip") || "법인/개인사업자 사업자등록번호를 '-' 포함하여 입력해주세요 (예: 123-45-67890)"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> <input id="taxid" name="taxid" @@ -159,12 +229,26 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { disabled={isLoading} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" /> + <p className="text-xs text-muted-foreground"> + {t("taxIdHint") || "사업자 등록 번호는 업체 인증에 사용됩니다"} + </p> </div> <Button type="submit" disabled={isLoading} variant="samsung"> {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />} - {t("joinButton")} + {t("joinButton") || "업체 등록 시작하기"} </Button> + {/* 로그인 안내 링크 추가 */} + <div className="text-center"> + <Button + variant="link" + className="text-blue-600 hover:text-blue-800 text-sm" + onClick={goToLogin} + > + {t("alreadyRegistered") || "이미 등록된 업체이신가요? 로그인하기"} + </Button> + </div> + {/* 언어 선택 Dropdown */} <div className="mx-auto"> <DropdownMenu> diff --git a/components/pq/client-pq-input-wrapper.tsx b/components/pq/client-pq-input-wrapper.tsx index 89f0fa78..42d2420d 100644 --- a/components/pq/client-pq-input-wrapper.tsx +++ b/components/pq/client-pq-input-wrapper.tsx @@ -9,7 +9,7 @@ import { PQGroupData, ProjectPQ } from "@/lib/pq/service" import { useRouter, useSearchParams } from "next/navigation" interface ClientPQWrapperProps { - allPQData: PQGroupData[] + pqData: PQGroupData[] // 변경: allPQData → pqData (현재 선택된 PQ 데이터) projectPQs: ProjectPQ[] vendorId: number rawSearchParams: { @@ -18,7 +18,7 @@ interface ClientPQWrapperProps { } export function ClientPQWrapper({ - allPQData, + pqData, projectPQs, vendorId, rawSearchParams @@ -28,16 +28,12 @@ export function ClientPQWrapper({ // 클라이언트 측에서 projectId 파싱 const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined - + // 현재 프로젝트 정보 찾기 const currentProject = projectId ? projectPQs.find(p => p.projectId === projectId) : null - - // 필요한 경우 여기서 PQ 데이터를 필터링할 수 있음 - // 예: 모든 데이터를 가져왔는데 현재 projectId에 따라 필터링이 필요한 경우 - // const filteredPQData = projectId ? allPQData.filter(...) : allPQData; - + return ( <Shell className="gap-2"> {/* 헤더 - 프로젝트 정보 포함 */} @@ -54,7 +50,7 @@ export function ClientPQWrapper({ PQ에 적절한 응답을 제출하시기 바랍니다. </p> </div> - + {/* 일반/프로젝트 PQ 선택 탭 */} {projectPQs.length > 0 && ( <div className="border-b"> @@ -65,7 +61,11 @@ export function ClientPQWrapper({ </TabsTrigger> {projectPQs.map(project => ( - <TabsTrigger key={project.projectId} value={`project-${project.projectId}`} asChild> + <TabsTrigger + key={project.projectId} + value={`project-${project.projectId}`} + asChild + > <a href={`/partners/pq?projectId=${project.projectId}`}> {project.projectCode} </a> @@ -75,11 +75,11 @@ export function ClientPQWrapper({ </Tabs> </div> )} - + {/* PQ 입력 탭 */} <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> <PQInputTabs - data={allPQData} + data={pqData} vendorId={vendorId} projectId={projectId} projectData={currentProject} diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx index e1bc5510..e636caae 100644 --- a/components/pq/pq-review-detail.tsx +++ b/components/pq/pq-review-detail.tsx @@ -32,6 +32,7 @@ import { import { Card } from "@/components/ui/card" import { formatDate } from "@/lib/utils" import { downloadFileAction } from "@/lib/downloadFile" +import { useSession } from "next-auth/react" // Importando o hook do next-auth // 코멘트 상태를 위한 인터페이스 정의 interface PendingComment { @@ -72,6 +73,10 @@ export default function VendorPQAdminReview({ pqType }: VendorPQAdminReviewProps) { const { toast } = useToast() + const { data: session } = useSession() + const reviewerName = session?.user?.name || "Unknown Reviewer" + const reviewerId = session?.user?.id + // State for dynamically loaded data const [pqData, setPqData] = React.useState<PQGroupData[]>(data) @@ -272,7 +277,9 @@ export default function VendorPQAdminReview({ vendorId: vendor.id, projectId: pqType === 'project' ? projectId : undefined, comment: itemComments, - generalComment: requestComment || undefined + generalComment: requestComment || undefined, + reviewerName, + reviewerId }); if (res.ok) { diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx index e778cf91..08b4de61 100644 --- a/components/pq/pq-review-table.tsx +++ b/components/pq/pq-review-table.tsx @@ -25,6 +25,7 @@ import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/servic import { useToast } from "@/hooks/use-toast" import { formatDate } from "@/lib/utils" import { downloadFileAction } from "@/lib/downloadFile" +import { useSession } from "next-auth/react" interface ReviewLog { id: number @@ -189,6 +190,9 @@ function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewBu const [newComment, setNewComment] = React.useState(""); const [isLoading, setIsLoading] = React.useState(false); const [hasComments, setHasComments] = React.useState(false); + const { data: session } = useSession() + const reviewerName = session?.user?.name || "Unknown Reviewer" + const reviewerId = session?.user?.id // If there's no answerId, item wasn't answered if (!answerId) { @@ -258,7 +262,7 @@ function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewBu const res = await addReviewCommentAction({ answerId, comment: newComment, - reviewerName: "AdminUser", + reviewerName, }); if (res.ok) { diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 6f9ad891..e53e779f 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -39,7 +39,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslation } from "@/i18n/client" -import { createVendor } from "@/lib/vendors/service" +import { createVendor, getVendorTypes } from "@/lib/vendors/service" import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations" import { Select, @@ -81,6 +81,63 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ label, })) +// Sort countries to put Korea first, then alphabetically +const sortedCountryArray = [...countryArray].sort((a, b) => { + // Put Korea (KR) at the top + if (a.code === "KR") return -1; + if (b.code === "KR") return 1; + + // Otherwise sort alphabetically + return a.label.localeCompare(b.label); +}); + +// Add English names for Korean locale +const enhancedCountryArray = sortedCountryArray.map(country => ({ + ...country, + label: locale === "ko" && country.code === "KR" + ? "대한민국 (South Korea)" + : country.label +})); + +// Comprehensive list of country dial codes +const countryDialCodes: { [key: string]: string } = { + AF: "+93", AL: "+355", DZ: "+213", AS: "+1-684", AD: "+376", AO: "+244", + AI: "+1-264", AG: "+1-268", AR: "+54", AM: "+374", AW: "+297", AU: "+61", + AT: "+43", AZ: "+994", BS: "+1-242", BH: "+973", BD: "+880", BB: "+1-246", + BY: "+375", BE: "+32", BZ: "+501", BJ: "+229", BM: "+1-441", BT: "+975", + BO: "+591", BA: "+387", BW: "+267", BR: "+55", BN: "+673", BG: "+359", + BF: "+226", BI: "+257", KH: "+855", CM: "+237", CA: "+1", CV: "+238", + KY: "+1-345", CF: "+236", TD: "+235", CL: "+56", CN: "+86", CO: "+57", + KM: "+269", CG: "+242", CD: "+243", CR: "+506", CI: "+225", HR: "+385", + CU: "+53", CY: "+357", CZ: "+420", DK: "+45", DJ: "+253", DM: "+1-767", + DO: "+1-809", EC: "+593", EG: "+20", SV: "+503", GQ: "+240", ER: "+291", + EE: "+372", ET: "+251", FJ: "+679", FI: "+358", FR: "+33", GA: "+241", + GM: "+220", GE: "+995", DE: "+49", GH: "+233", GR: "+30", GD: "+1-473", + GT: "+502", GN: "+224", GW: "+245", GY: "+592", HT: "+509", HN: "+504", + HK: "+852", HU: "+36", IS: "+354", IN: "+91", ID: "+62", IR: "+98", + IQ: "+964", IE: "+353", IL: "+972", IT: "+39", JM: "+1-876", JP: "+81", + JO: "+962", KZ: "+7", KE: "+254", KI: "+686", KR: "+82", KW: "+965", + KG: "+996", LA: "+856", LV: "+371", LB: "+961", LS: "+266", LR: "+231", + LY: "+218", LI: "+423", LT: "+370", LU: "+352", MK: "+389", MG: "+261", + MW: "+265", MY: "+60", MV: "+960", ML: "+223", MT: "+356", MH: "+692", + MR: "+222", MU: "+230", MX: "+52", FM: "+691", MD: "+373", MC: "+377", + MN: "+976", ME: "+382", MA: "+212", MZ: "+258", MM: "+95", NA: "+264", + NR: "+674", NP: "+977", NL: "+31", NZ: "+64", NI: "+505", NE: "+227", + NG: "+234", NU: "+683", KP: "+850", NO: "+47", OM: "+968", PK: "+92", + PW: "+680", PS: "+970", PA: "+507", PG: "+675", PY: "+595", PE: "+51", + PH: "+63", PL: "+48", PT: "+351", PR: "+1-787", QA: "+974", RO: "+40", + RU: "+7", RW: "+250", KN: "+1-869", LC: "+1-758", VC: "+1-784", WS: "+685", + SM: "+378", ST: "+239", SA: "+966", SN: "+221", RS: "+381", SC: "+248", + SL: "+232", SG: "+65", SK: "+421", SI: "+386", SB: "+677", SO: "+252", + ZA: "+27", SS: "+211", ES: "+34", LK: "+94", SD: "+249", SR: "+597", + SZ: "+268", SE: "+46", CH: "+41", SY: "+963", TW: "+886", TJ: "+992", + TZ: "+255", TH: "+66", TL: "+670", TG: "+228", TK: "+690", TO: "+676", + TT: "+1-868", TN: "+216", TR: "+90", TM: "+993", TV: "+688", UG: "+256", + UA: "+380", AE: "+971", GB: "+44", US: "+1", UY: "+598", UZ: "+998", + VU: "+678", VA: "+39-06", VE: "+58", VN: "+84", YE: "+967", ZM: "+260", + ZW: "+263" +}; + const MAX_FILE_SIZE = 3e9 export function JoinForm() { @@ -92,16 +149,54 @@ export function JoinForm() { const searchParams = useSearchParams() || new URLSearchParams(); const defaultTaxId = searchParams.get("taxID") ?? "" + // Define VendorType interface + interface VendorType { + id: number; + code: string; + nameKo: string; + nameEn: string; + } + + // Vendor Types state with proper typing + const [vendorTypes, setVendorTypes] = React.useState<VendorType[]>([]) + const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true) + // File states const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) const [isSubmitting, setIsSubmitting] = React.useState(false) + // Fetch vendor types on component mount + React.useEffect(() => { + async function loadVendorTypes() { + setIsLoadingVendorTypes(true) + try { + const result = await getVendorTypes() + if (result.data) { + setVendorTypes(result.data) + } + } catch (error) { + console.error("Failed to load vendor types:", error) + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load vendor types", + }) + } finally { + setIsLoadingVendorTypes(false) + } + } + + loadVendorTypes() + }, []) + // React Hook Form const form = useForm<CreateVendorSchema>({ resolver: zodResolver(createVendorSchema), defaultValues: { vendorName: "", + vendorTypeId: undefined, + items: "", taxId: defaultTaxId, address: "", email: "", @@ -126,7 +221,9 @@ export function JoinForm() { mode: "onChange", }) const isFormValid = form.formState.isValid - + console.log("Form errors:", form.formState.errors); + console.log("Form values:", form.getValues()); + console.log("Form valid:", form.formState.isValid); // Field array for contacts @@ -168,6 +265,8 @@ export function JoinForm() { const vendorData = { vendorName: values.vendorName, + vendorTypeId: values.vendorTypeId, + items: values.items, vendorCode: values.vendorCode, website: values.website, taxId: values.taxId, @@ -194,7 +293,7 @@ export function JoinForm() { title: "등록 완료", description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)", }) - router.push("/") + router.push("/ko/partners") } else { toast({ variant: "destructive", @@ -214,6 +313,12 @@ export function JoinForm() { } } + // Get country code for phone number placeholder + const getPhonePlaceholder = (countryCode: string) => { + if (!countryCode || !countryDialCodes[countryCode]) return "전화번호"; + return `${countryDialCodes[countryCode]} 전화번호`; + }; + // Render return ( <div className="container py-6"> @@ -244,79 +349,123 @@ export function JoinForm() { <div className="rounded-md border p-4 space-y-4"> <h4 className="text-md font-semibold">기본 정보</h4> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {/* vendorName is required in the schema → show * */} + {/* Vendor Type - New Field */} <FormField control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 업체명 - </FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Address (optional, no * here) */} - <FormField - control={form.control} - name="address" - render={({ field }) => ( - <FormItem> - <FormLabel>주소</FormLabel> - <FormControl> - <Input {...field} disabled={isSubmitting} /> - </FormControl> - <FormMessage /> - </FormItem> - )} + name="vendorTypeId" + render={({ field }) => { + const selectedType = vendorTypes.find(type => type.id === field.value); + const displayName = lng === "ko" ? + (selectedType?.nameKo || "") : + (selectedType?.nameEn || ""); + + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 업체유형 + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + disabled={isSubmitting || isLoadingVendorTypes} + > + {isLoadingVendorTypes + ? "Loading..." + : displayName || "업체유형 선택"} + <ChevronsUpDown className="ml-2 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="업체유형 검색..." /> + <CommandList> + <CommandEmpty>No vendor type found.</CommandEmpty> + <CommandGroup> + {vendorTypes.map((type) => ( + <CommandItem + key={type.id} + value={lng === "ko" ? type.nameKo : type.nameEn} + onSelect={() => field.onChange(type.id)} + > + <Check + className={cn( + "mr-2", + type.id === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {lng === "ko" ? type.nameKo : type.nameEn} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + ); + }} /> + {/* vendorName */} <FormField control={form.control} - name="phone" + name="vendorName" render={({ field }) => ( <FormItem> - <FormLabel>대표 전화</FormLabel> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 업체명 + </FormLabel> <FormControl> <Input {...field} disabled={isSubmitting} /> </FormControl> + <FormDescription> + {form.watch("country") === "KR" + ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." + : "해외 업체의 경우 영문 회사명을 입력하세요."} + </FormDescription> <FormMessage /> </FormItem> )} /> - {/* email is required → show * */} + {/* Items - New Field */} <FormField control={form.control} - name="email" + name="items" render={({ field }) => ( <FormItem> <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 대표 이메일 + 공급품목 </FormLabel> <FormControl> <Input {...field} disabled={isSubmitting} /> </FormControl> <FormDescription> - 회사 대표 이메일(관리자 로그인에 사용될 수 있음) + 공급 가능한 제품/서비스를 입력하세요 </FormDescription> <FormMessage /> </FormItem> )} /> - {/* website optional */} + {/* Address */} <FormField control={form.control} - name="website" + name="address" render={({ field }) => ( <FormItem> - <FormLabel>웹사이트</FormLabel> + <FormLabel>주소</FormLabel> <FormControl> <Input {...field} disabled={isSubmitting} /> </FormControl> @@ -325,17 +474,18 @@ export function JoinForm() { )} /> + {/* Country - Updated with enhanced list */} <FormField control={form.control} name="country" render={({ field }) => { - const selectedCountry = countryArray.find( + const selectedCountry = enhancedCountryArray.find( (c) => c.code === field.value ) return ( <FormItem> <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Country + 국가 </FormLabel> <Popover> <PopoverTrigger asChild> @@ -351,18 +501,18 @@ export function JoinForm() { > {selectedCountry ? selectedCountry.label - : "Select a country"} + : "국가 선택"} <ChevronsUpDown className="ml-2 opacity-50" /> </Button> </FormControl> </PopoverTrigger> <PopoverContent className="w-full p-0"> <Command> - <CommandInput placeholder="Search country..." /> + <CommandInput placeholder="국가 검색..." /> <CommandList> <CommandEmpty>No country found.</CommandEmpty> <CommandGroup> - {countryArray.map((country) => ( + {enhancedCountryArray.map((country) => ( <CommandItem key={country.code} value={country.label} @@ -391,6 +541,62 @@ export function JoinForm() { ) }} /> + + {/* Phone - Updated with country code hint */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표 전화 + </FormLabel> + <FormControl> + <Input + {...field} + placeholder={getPhonePlaceholder(form.watch("country"))} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Email - Updated with company domain guidance */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표 이메일 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormDescription> + 회사 도메인 이메일을 사용하세요. (naver.com, gmail.com, daum.net 등의 개인 이메일은 지양해주세요) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> </div> </div> @@ -425,7 +631,7 @@ export function JoinForm() { className="bg-muted/10 rounded-md p-4 space-y-4" > <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> - {/* contactName → required */} + {/* contactName - All required now */} <FormField control={form.control} name={`contacts.${index}.contactName`} @@ -442,13 +648,15 @@ export function JoinForm() { )} /> - {/* contactPosition → optional */} + {/* contactPosition - Now required */} <FormField control={form.control} name={`contacts.${index}.contactPosition`} render={({ field }) => ( <FormItem> - <FormLabel>직급 / 부서</FormLabel> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 직급 / 부서 + </FormLabel> <FormControl> <Input {...field} disabled={isSubmitting} /> </FormControl> @@ -457,7 +665,7 @@ export function JoinForm() { )} /> - {/* contactEmail → required */} + {/* contactEmail */} <FormField control={form.control} name={`contacts.${index}.contactEmail`} @@ -474,15 +682,21 @@ export function JoinForm() { )} /> - {/* contactPhone → optional */} + {/* contactPhone - Now required */} <FormField control={form.control} name={`contacts.${index}.contactPhone`} render={({ field }) => ( <FormItem> - <FormLabel>전화번호</FormLabel> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 전화번호 + </FormLabel> <FormControl> - <Input {...field} disabled={isSubmitting} /> + <Input + {...field} + placeholder={getPhonePlaceholder(form.watch("country"))} + disabled={isSubmitting} + /> </FormControl> <FormMessage /> </FormItem> @@ -515,7 +729,7 @@ export function JoinForm() { <div className="rounded-md border p-4 space-y-4"> <h4 className="text-md font-semibold">한국 사업자 정보</h4> - {/* 대표자 등... all optional or whichever you want * for */} + {/* 대표자 등... all now required for Korean companies */} <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <FormField control={form.control} @@ -613,6 +827,9 @@ export function JoinForm() { <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> 첨부 파일 </FormLabel> + <FormDescription> + 사업자등록증, ISO 9001 인증서, 회사 브로셔, 기본 소개자료 등을 첨부해주세요. + </FormDescription> <Dropzone maxSize={MAX_FILE_SIZE} multiple diff --git a/components/vendor-data/sidebar.tsx b/components/vendor-data/sidebar.tsx index b9e14b65..2dff6bc1 100644 --- a/components/vendor-data/sidebar.tsx +++ b/components/vendor-data/sidebar.tsx @@ -29,6 +29,7 @@ interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { selectedForm: string | null onSelectForm: (formName: string) => void isLoadingForms?: boolean + mode: "IM" | "ENG" // 새로 추가: 현재 선택된 모드 } export function Sidebar({ @@ -41,9 +42,11 @@ export function Sidebar({ selectedForm, onSelectForm, isLoadingForms = false, + mode = "IM", // 기본값 설정 }: SidebarProps) { const router = useRouter() - const pathname = usePathname() + const rawPathname = usePathname() + const pathname = rawPathname ?? "" /** * --------------------------- @@ -93,43 +96,69 @@ export function Sidebar({ /** * --------------------------- - * 3) 폼 클릭 핸들러 + * 3) 폼 클릭 핸들러 (mode 추가) * --------------------------- */ const handleFormClick = (form: FormInfo) => { - // 패키지가 선택되어 있을 때만 동작 - if (selectedPackageId === null) return + // 패키지 ID 선택 전략 + let packageId: number; + + if (mode === "ENG") { + // ENG 모드에서는 첫 번째 패키지 ID 또는 현재 URL에서 추출한 ID 사용 + packageId = currentItemId || (packages[0]?.itemId || 0); + } else { + // IM 모드에서는 반드시 선택된 패키지 ID 필요 + if (selectedPackageId === null) return; + packageId = selectedPackageId; + } // 상위 컴포넌트 상태 업데이트 onSelectForm(form.formName) // 해당 폼 페이지로 라우팅 // 예: /vendor-data/form/[packageId]/[formCode] - const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") - - router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}`) + // 모드 정보를 쿼리 파라미터로 추가 + router.push(`/${baseSegments}/form/${packageId}/${form.formCode}?mode=${mode}`) } return ( <div className={cn("pb-12", className)}> <div className="space-y-4 py-4"> - {/* ---------- 패키지(Items) 목록 ---------- */} - <div className="py-1"> - <h2 className="relative px-7 text-lg font-semibold tracking-tight"> - {isCollapsed ? "P" : "Package Lists"} - </h2> - <ScrollArea className="h-[150px] px-1"> - <div className="space-y-1 p-2"> - {packages.map((pkg) => { - // URL 기준으로 active 여부 판단 - const isActive = pkg.itemId === currentItemId - - return ( - <div key={pkg.itemId}> - {isCollapsed ? ( - <Tooltip delayDuration={0}> - <TooltipTrigger asChild> + {/* ---------- 패키지(Items) 목록 - IM 모드에서만 표시 ---------- */} + {mode === "IM" && ( + <> + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "P" : "Package Lists"} + </h2> + <ScrollArea className="h-[150px] px-1"> + <div className="space-y-1 p-2"> + {packages.map((pkg) => { + // URL 기준으로 active 여부 판단 + const isActive = pkg.itemId === currentItemId + + return ( + <div key={pkg.itemId}> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {pkg.itemName} + </TooltipContent> + </Tooltip> + ) : ( <Button variant="ghost" className={cn( @@ -139,40 +168,29 @@ export function Sidebar({ onClick={() => handlePackageClick(pkg.itemId)} > <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} </Button> - </TooltipTrigger> - <TooltipContent side="right"> - {pkg.itemName} - </TooltipContent> - </Tooltip> - ) : ( - <Button - variant="ghost" - className={cn( - "w-full justify-start font-normal", - isActive && "bg-accent text-accent-foreground" )} - onClick={() => handlePackageClick(pkg.itemId)} - > - <Package2 className="mr-2 h-4 w-4" /> - {pkg.itemName} - </Button> - )} - </div> - ) - })} + </div> + ) + })} + </div> + </ScrollArea> </div> - </ScrollArea> - </div> - - <Separator /> + <Separator /> + </> + )} {/* ---------- 폼 목록 ---------- */} <div className="py-1"> <h2 className="relative px-7 text-lg font-semibold tracking-tight"> {isCollapsed ? "F" : "Form Lists"} </h2> - <ScrollArea className="h-[300px] px-1"> + <ScrollArea className={cn( + "px-1", + // IM 모드는 더 작은 높이, ENG 모드는 더 큰 높이 + mode === "IM" ? "h-[300px]" : "h-[450px]" + )}> <div className="space-y-1 p-2"> {isLoadingForms ? ( // 로딩 중 스켈레톤 UI 표시 @@ -190,6 +208,9 @@ export function Sidebar({ // URL 기준으로 active 여부 판단 const isActive = form.formCode === currentFormCode + // IM 모드에서만 패키지 선택 여부에 따라 비활성화 + const isDisabled = mode === "IM" && currentItemId === null; + return isCollapsed ? ( <Tooltip key={form.formCode} delayDuration={0}> <TooltipTrigger asChild> @@ -200,7 +221,7 @@ export function Sidebar({ isActive && "bg-accent text-accent-foreground" )} onClick={() => handleFormClick(form)} - disabled={currentItemId === null} + disabled={isDisabled} > <FormInput className="mr-2 h-4 w-4" /> </Button> @@ -218,7 +239,7 @@ export function Sidebar({ isActive && "bg-accent text-accent-foreground" )} onClick={() => handleFormClick(form)} - disabled={currentItemId === null} + disabled={isDisabled} > <FormInput className="mr-2 h-4 w-4" /> {form.formName} diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index 11aa6f9d..77b36abf 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -6,9 +6,14 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { cn } from "@/lib/utils" import { ProjectSwitcher } from "./project-swicher" import { Sidebar } from "./sidebar" -import { usePathname, useRouter } from "next/navigation" +import { usePathname, useRouter, useSearchParams } from "next/navigation" import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" import { Separator } from "@/components/ui/separator" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" +import { FormInput } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" interface PackageData { itemId: number @@ -25,6 +30,7 @@ interface ProjectData { projectId: number projectCode: string projectName: string + projectType: string contracts: ContractData[] } @@ -58,45 +64,86 @@ export function VendorDataContainer({ children }: VendorDataContainerProps) { const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() const tagIdNumber = getTagIdFromPathname(pathname) - - const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) - // 폼 로드 요청 추적 - const lastRequestIdRef = React.useRef(0) - // 기본 상태 const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) const [selectedContractId, setSelectedContractId] = React.useState( projects[0]?.contracts[0]?.contractId || 0 ) - // URL에서 들어온 tagIdNumber를 우선으로 설정하기 위해 초기에 null로 두고, 뒤에서 useEffect로 세팅 const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) - - const [formList, setFormList] = React.useState<FormInfo[]>([]) const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) const [isLoadingForms, setIsLoadingForms] = React.useState(false) - // 현재 선택된 프로젝트/계약/패키지 const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) ?? currentProject?.contracts[0] - const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false - const currentPackageName = isTagOrFormRoute - ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" - : "None" + // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 + const isShipProject = currentProject?.projectType === "ship" + + // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) + const modeFromUrl = searchParams?.get('mode') + const initialMode = isShipProject + ? "ENG" + : (modeFromUrl === "ENG" || modeFromUrl === "IM") ? modeFromUrl : "IM" + + // 모드 선택 상태 - 프로젝트 타입에 따라 초기값 결정 + const [selectedMode, setSelectedMode] = React.useState<"IM" | "ENG">(initialMode) + + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false + const currentPackageName = isTagOrFormRoute + ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" + : "None" // 폼 목록에서 고유한 폼 이름만 추출 const formNames = React.useMemo(() => { return [...new Set(formList.map((form) => form.formName))] }, [formList]) + // URL에서 현재 폼 코드 추출 + const getCurrentFormCode = (path: string): string | null => { + const segments = path.split("/").filter(Boolean) + const formIndex = segments.indexOf("form") + if (formIndex !== -1 && segments[formIndex + 2]) { + return segments[formIndex + 2] + } + return null + } + + const currentFormCode = React.useMemo(() => { + return pathname ? getCurrentFormCode(pathname) : null + }, [pathname]) + + // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) + React.useEffect(() => { + if (!isShipProject) { + const modeFromUrl = searchParams?.get('mode') + if (modeFromUrl === "ENG" || modeFromUrl === "IM") { + setSelectedMode(modeFromUrl) + } + } + }, [searchParams, isShipProject]) + + // 프로젝트 타입이 변경될 때 모드 업데이트 + React.useEffect(() => { + if (isShipProject) { + setSelectedMode("ENG") + + // URL 모드 파라미터도 업데이트 + const url = new URL(window.location.href); + url.searchParams.set('mode', 'ENG'); + router.replace(url.pathname + url.search); + } + }, [isShipProject, router]) + // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 - // URL에 tagIdNumber가 있으면 그걸 우선으로, 아니면 기존 로직대로 '첫 번째 패키지' 사용 React.useEffect(() => { if (!currentContract) return @@ -121,17 +168,7 @@ export function VendorDataContainer({ } }, [currentProject]) - // // (3) selectedPackageId 바뀔 때 URL도 같이 업데이트 - // React.useEffect(() => { - // if (!selectedPackageId) return - // const basePath = pathname.includes("/partners/") - // ? "/partners/vendor-data/tag/" - // : "/vendor-data/tag/" - - // router.push(`${basePath}${selectedPackageId}`) - // }, [selectedPackageId, router, pathname]) - - // (4) 패키지 ID가 정해질 때마다 폼 로딩 + // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 React.useEffect(() => { const packageId = getTagIdFromPathname(pathname) @@ -139,27 +176,30 @@ export function VendorDataContainer({ setSelectedPackageId(packageId) // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 - loadFormsList(packageId); + loadFormsList(packageId, selectedMode); } else if (currentContract?.packages?.length) { - setSelectedPackageId(currentContract.packages[0].itemId) + const firstPackageId = currentContract.packages[0].itemId; + setSelectedPackageId(firstPackageId); + loadFormsList(firstPackageId, selectedMode); } - }, [pathname, currentContract]) + }, [pathname, currentContract, selectedMode]) - // 폼 로드 함수를 컴포넌트 내부에 정의하고 재사용 - const loadFormsList = async (packageId: number) => { + // 모드에 따른 폼 로드 함수 + const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { if (!packageId) return; setIsLoadingForms(true); try { - const result = await getFormsByContractItemId(packageId); + const result = await getFormsByContractItemId(packageId, mode); setFormList(result.forms || []); } catch (error) { - console.error("폼 로딩 오류:", error); + console.error(`폼 로딩 오류 (${mode} 모드):`, error); setFormList([]); } finally { setIsLoadingForms(false); } }; + // 핸들러들 function handleSelectContract(projId: number, cId: number) { setSelectedProjectId(projId) @@ -177,6 +217,27 @@ export function VendorDataContainer({ } } + // 모드 변경 핸들러 + const handleModeChange = (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; + + setSelectedMode(mode); + + // 현재 URL에서 mode 파라미터 업데이트 (현재 경로 유지) + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + + // 모드가 변경되면 현재 패키지의 폼 다시 로드 + if (selectedPackageId) { + loadFormsList(selectedPackageId, mode); + } else if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + loadFormsList(firstPackageId, mode); + } + }; + return ( <TooltipProvider delayDuration={0}> <ResizablePanelGroup direction="horizontal" className="h-full"> @@ -204,21 +265,123 @@ export function VendorDataContainer({ /> </div> <Separator /> - <Sidebar - isCollapsed={isCollapsed} - packages={currentContract?.packages || []} - selectedPackageId={selectedPackageId} - onSelectPackage={handleSelectPackage} - forms={formList} - selectedForm={ - selectedFormCode - ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null - : null - } - onSelectForm={handleSelectForm} - isLoadingForms={isLoadingForms} - className="hidden lg:block" - /> + + {!isCollapsed ? ( + isShipProject ? ( + // 프로젝트 타입이 ship인 경우: 탭 없이 ENG 모드 사이드바만 바로 표시 + <div className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </div> + ) : ( + // 프로젝트 타입이 ship이 아닌 경우: 기존 탭 UI 표시 + <Tabs + defaultValue={initialMode} + value={selectedMode} + onValueChange={(value) => handleModeChange(value as "IM" | "ENG")} + className="w-full" + > + <TabsList className="w-full"> + <TabsTrigger value="IM" className="flex-1">IM</TabsTrigger> + <TabsTrigger value="ENG" className="flex-1">ENG</TabsTrigger> + </TabsList> + + <TabsContent value="IM" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="IM" + className="hidden lg:block" + /> + </TabsContent> + + <TabsContent value="ENG" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </TabsContent> + </Tabs> + ) + ) : ( + // 접혀있을 때 UI + <> + {!isShipProject && ( + // ship 프로젝트가 아닐 때만 모드 선택 버튼 표시 + <div className="flex justify-center space-x-1 my-2"> + <Button + variant={selectedMode === "IM" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("IM")} + > + IM + </Button> + <Button + variant={selectedMode === "ENG" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("ENG")} + > + ENG + </Button> + </div> + )} + + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode={isShipProject ? "ENG" : selectedMode} + className="hidden lg:block" + /> + </> + )} </ResizablePanel> <ResizableHandle withHandle /> @@ -226,7 +389,11 @@ export function VendorDataContainer({ <ResizablePanel defaultSize={defaultLayout[1]} minSize={40}> <div className="p-4 h-full overflow-auto flex flex-col"> <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-bold">Package: {currentPackageName}</h2> + <h2 className="text-lg font-bold"> + {isShipProject || selectedMode === "ENG" + ? "Engineering Mode" + : `Package: ${currentPackageName}`} + </h2> </div> {children} </div> |
