summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/BidProjectSelector.tsx124
-rw-r--r--components/additional-info/join-form.tsx130
-rw-r--r--components/client-data-table/data-table-toolbar.tsx25
-rw-r--r--components/client-data-table/data-table.tsx2
-rw-r--r--components/data-table/data-table-advanced-toolbar.tsx100
-rw-r--r--components/data-table/data-table-compact-toggle.tsx35
-rw-r--r--components/data-table/data-table-filter-list.tsx2
-rw-r--r--components/data-table/data-table-group-list.tsx2
-rw-r--r--components/data-table/data-table-pin-left.tsx243
-rw-r--r--components/data-table/data-table-pin-right.tsx150
-rw-r--r--components/data-table/data-table-sort-list.tsx2
-rw-r--r--components/data-table/data-table-view-options.tsx44
-rw-r--r--components/data-table/data-table.tsx75
-rw-r--r--components/date-range-picker.tsx39
-rw-r--r--components/form-data/add-formTag-dialog.tsx957
-rw-r--r--components/form-data/export-excel-form.tsx197
-rw-r--r--components/form-data/form-data-report-batch-dialog.tsx8
-rw-r--r--components/form-data/form-data-report-dialog.tsx153
-rw-r--r--components/form-data/form-data-report-temp-upload-dialog.tsx45
-rw-r--r--components/form-data/form-data-report-temp-upload-tab.tsx2
-rw-r--r--components/form-data/form-data-report-temp-uploaded-list-tab.tsx2
-rw-r--r--components/form-data/form-data-table copy.tsx539
-rw-r--r--components/form-data/form-data-table-columns.tsx19
-rw-r--r--components/form-data/form-data-table.tsx948
-rw-r--r--components/form-data/import-excel-form.tsx323
-rw-r--r--components/form-data/publish-dialog.tsx470
-rw-r--r--components/form-data/sedp-compare-dialog.tsx372
-rw-r--r--components/form-data/sedp-components.tsx173
-rw-r--r--components/form-data/sedp-excel-download.tsx163
-rw-r--r--components/form-data/temp-download-btn.tsx11
-rw-r--r--components/form-data/update-form-sheet.tsx140
-rw-r--r--components/form-data/var-list-download-btn.tsx18
-rw-r--r--components/layout/Header.tsx8
-rw-r--r--components/login/login-form copy 2.tsx470
-rw-r--r--components/login/login-form copy.tsx468
-rw-r--r--components/login/login-form-shi.tsx34
-rw-r--r--components/login/login-form.tsx67
-rw-r--r--components/login/partner-auth-form.tsx128
-rw-r--r--components/pq/client-pq-input-wrapper.tsx24
-rw-r--r--components/pq/pq-review-detail.tsx9
-rw-r--r--components/pq/pq-review-table.tsx6
-rw-r--r--components/signup/join-form.tsx323
-rw-r--r--components/vendor-data/sidebar.tsx121
-rw-r--r--components/vendor-data/vendor-data-container.tsx265
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">&ldquo;{t("blockquote")}&rdquo;</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">&ldquo;{t("blockquote")}&rdquo;</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>