From ef4c533ebacc2cdc97e518f30e9a9350004fcdfb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 28 Apr 2025 02:13:30 +0000 Subject: ~20250428 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/BidProjectSelector.tsx | 124 +++ components/additional-info/join-form.tsx | 130 ++- .../client-data-table/data-table-toolbar.tsx | 25 - components/client-data-table/data-table.tsx | 2 +- .../data-table/data-table-advanced-toolbar.tsx | 100 ++- .../data-table/data-table-compact-toggle.tsx | 35 + components/data-table/data-table-filter-list.tsx | 2 +- components/data-table/data-table-group-list.tsx | 2 +- components/data-table/data-table-pin-left.tsx | 243 +++++- components/data-table/data-table-pin-right.tsx | 150 +++- components/data-table/data-table-sort-list.tsx | 2 +- components/data-table/data-table-view-options.tsx | 44 +- components/data-table/data-table.tsx | 75 +- components/date-range-picker.tsx | 39 +- components/form-data/add-formTag-dialog.tsx | 957 +++++++++++++++++++++ components/form-data/export-excel-form.tsx | 197 +++++ .../form-data/form-data-report-batch-dialog.tsx | 8 +- components/form-data/form-data-report-dialog.tsx | 153 ++-- .../form-data-report-temp-upload-dialog.tsx | 45 +- .../form-data/form-data-report-temp-upload-tab.tsx | 2 +- .../form-data-report-temp-uploaded-list-tab.tsx | 2 +- components/form-data/form-data-table copy.tsx | 539 ++++++++++++ components/form-data/form-data-table-columns.tsx | 19 +- components/form-data/form-data-table.tsx | 948 +++++++++++--------- components/form-data/import-excel-form.tsx | 323 +++++++ components/form-data/publish-dialog.tsx | 470 ++++++++++ components/form-data/sedp-compare-dialog.tsx | 372 ++++++++ components/form-data/sedp-components.tsx | 173 ++++ components/form-data/sedp-excel-download.tsx | 163 ++++ components/form-data/temp-download-btn.tsx | 11 +- components/form-data/update-form-sheet.tsx | 140 ++- components/form-data/var-list-download-btn.tsx | 18 +- components/layout/Header.tsx | 8 +- components/login/login-form copy 2.tsx | 470 ++++++++++ components/login/login-form copy.tsx | 468 ++++++++++ components/login/login-form-shi.tsx | 34 +- components/login/login-form.tsx | 67 +- components/login/partner-auth-form.tsx | 128 ++- components/pq/client-pq-input-wrapper.tsx | 24 +- components/pq/pq-review-detail.tsx | 9 +- components/pq/pq-review-table.tsx | 6 +- components/signup/join-form.tsx | 323 +++++-- components/vendor-data/sidebar.tsx | 121 +-- components/vendor-data/vendor-data-container.tsx | 265 ++++-- 44 files changed, 6460 insertions(+), 976 deletions(-) create mode 100644 components/BidProjectSelector.tsx create mode 100644 components/data-table/data-table-compact-toggle.tsx create mode 100644 components/form-data/add-formTag-dialog.tsx create mode 100644 components/form-data/export-excel-form.tsx create mode 100644 components/form-data/form-data-table copy.tsx create mode 100644 components/form-data/import-excel-form.tsx create mode 100644 components/form-data/publish-dialog.tsx create mode 100644 components/form-data/sedp-compare-dialog.tsx create mode 100644 components/form-data/sedp-components.tsx create mode 100644 components/form-data/sedp-excel-download.tsx create mode 100644 components/login/login-form copy 2.tsx create mode 100644 components/login/login-form copy.tsx (limited to 'components') 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([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(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 ( + + + + + + + + + 검색 결과가 없습니다 + {isLoading ? ( +
로딩 중...
+ ) : ( + + {filteredProjects.map((project) => ( + handleSelectProject(project)} + > + + {project.projectCode} + - {project.projectName} + + ))} + + )} +
+
+
+
+ ); +} \ 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 = { SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], } +const cashFlowRatingScaleMap: Record = { + 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(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([]) const [creditRatingFile, setCreditRatingFile] = React.useState([]) const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState([]) + const [isDownloading, setIsDownloading] = React.useState(false); // React Hook Form const form = useForm({ - 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 (
- 벤더 정보를 불러오는 중입니다... + 협력업체 정보를 불러오는 중입니다...
) } @@ -543,7 +591,7 @@ export function InfoForm() {
- handleDownloadFile(file.id)}> + handleDownloadFile(file)}> handleDeleteExistingFile(file.id)}> @@ -574,7 +622,7 @@ export function InfoForm() {
- handleDownloadFile(file.id)}> + handleDownloadFile(file)}> handleDeleteExistingFile(file.id)}> @@ -605,7 +653,7 @@ export function InfoForm() {
- handleDownloadFile(file.id)}> + handleDownloadFile(file)}> 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 ( 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({ ...props }: DataTableAdvancedToolbarProps) { - // 전체 엑셀 내보내기 - 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 (
({ -
{/* 오른쪽: 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({ const [grouping, setGrouping] = React.useState([]) const [columnSizing, setColumnSizing] = React.useState({}) const [columnPinning, setColumnPinning] = React.useState({ - 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 = (key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => { + const [storedValue, setStoredValue] = React.useState(() => { + 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 extends React.HTMLAttributes { /** @@ -58,6 +90,29 @@ interface DataTableAdvancedToolbarProps * @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({ @@ -65,10 +120,30 @@ export function DataTableAdvancedToolbar({ filterFields = [], debounceMs = 300, shallow = true, + enableCompactToggle = true, + initialCompact = false, + onCompactChange, + compactStorageKey = "dataTableCompact", children, className, ...props }: DataTableAdvancedToolbarProps) { + // 컴팩트 모드 상태 관리 + const [isCompact, setIsCompact] = useLocalStorage( + compactStorageKey, + initialCompact + ) + + // 컴팩트 모드 변경 시 콜백 호출 + React.useEffect(() => { + onCompactChange?.(isCompact) + }, [isCompact, onCompactChange]) + + // 컴팩트 모드 토글 핸들러 + const handleToggleCompact = React.useCallback(() => { + setIsCompact(prev => !prev) + }, [setIsCompact]) + return (
({ {...props} >
- + {enableCompactToggle && ( + + )} + ({ debounceMs={debounceMs} shallow={shallow} /> - - - + + +
- {children} + {/* 컴팩트 모드 토글 버튼 */} + {children}
) -} +} \ 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 ( + + ) +} \ 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({ }: DataTableFilterListProps) { 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({ aria-controls={`${id}-group-dialog`} >
); -}; +}; \ 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 = ({ ); -}; +}; \ 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 | null>(null); + const [tableData, setTableData] = React.useState(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([]); + 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({ columnsJSON, setRowAction, setReportData, tempCount }), + [columnsJSON, setRowAction, setReportData, tempCount] + ); + + function mapColumnTypeToAdvancedFilterType( + columnType: ColumnType + ): DataTableAdvancedFilterField["type"] { + switch (columnType) { + case "STRING": + return "text"; + case "NUMBER": + return "number"; + case "LIST": + return "select"; + default: + return "text"; + } + } + + const advancedFilterFields = React.useMemo< + DataTableAdvancedFilterField[] + >(() => { + 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) { + 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 ( + <> + + {/* 버튼 그룹 */} +
+ {/* 태그 관리 드롭다운 */} + + + + + + + + Sync Tags + + setAddTagDialogOpen(true)} disabled={isAnyOperationPending}> + + Add Tags + + + + + {/* 리포트 관리 드롭다운 */} + + + + + + setTempUpDialog(true)} disabled={isAnyOperationPending}> + + Upload Template + + + + Batch Document + + + + + {/* IMPORT 버튼 (파일 선택) */} + + + {/* EXPORT 버튼 */} + + + + {/* SEDP 전송 버튼 */} + +
+
+ + {/* Modal dialog for tag update */} + { + 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 */} + + + {/* SEDP Confirmation Dialog */} + setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* Other dialogs */} + {tempUpDialog && ( + + )} + + {reportData.length > 0 && ( + + )} + + {batchDownDialog && ( + + )} + + ); +} \ 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 { row: Row; @@ -36,6 +37,7 @@ export interface DataTableColumnJSON { type: ColumnType; options?: string[]; uom?: string; + uomId?: string; } /** * getColumns 함수에 필요한 props @@ -47,6 +49,7 @@ interface GetColumnsProps { React.SetStateAction | null> >; setReportData: React.Dispatch>; + tempCount: number; } /** @@ -58,6 +61,7 @@ export function getColumns({ columnsJSON, setRowAction, setReportData, + tempCount, }: GetColumnsProps): ColumnDef[] { // (1) 기본 컬럼들 const baseColumns: ColumnDef[] = columnsJSON.map((col) => ({ @@ -73,7 +77,7 @@ export function getColumns({ 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({ { + if(tempCount > 0){ const { original } = row; setReportData([original]); + } else { + toast.error("업로드된 Template File이 없습니다."); + } }} > - Create Vendor Document + Create Document ), - 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 { + 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 | null>(null); - const [tableData, setTableData] = React.useState( - () => dataJSON - ); - const [isPending, setIsPending] = React.useState(false); + const [tableData, setTableData] = React.useState(dataJSON); + + // Update tableData when dataJSON changes + React.useEffect(() => { + setTableData(dataJSON); + }, [dataJSON]); + + // 폴링 상태 관리를 위한 ref + const pollingRef = React.useRef(null); + const [syncId, setSyncId] = React.useState(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(''); + const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState([]); 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({ columnsJSON, setRowAction, setReportData }), - [columnsJSON, setRowAction, setReportData] + () => getColumns({ 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) { - 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(); - 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(); - 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 = {}; - - // 각 열에 대해 처리 - 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) { + 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(); - - // 기존 데이터를 맵에 추가 - 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(); - - 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 ( <> {/* 버튼 그룹 */}
- {/* 태그 불러오기 버튼 */} - - - - - - - - - - + + + setTempUpDialog(true)} disabled={isAnyOperationPending}> + + Upload Template + + + + Batch Document + + + {/* IMPORT 버튼 (파일 선택) */} - - {/* EXPORT 버튼 (새로 추가) */} + {/* EXPORT 버튼 */} - {/* SAVE 버튼 */} + {/* COMPARE WITH SEDP 버튼 */} + + {/* SEDP 전송 버튼 */} +
+ {/* Modal dialog for tag update */} { @@ -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 */} + + + {/* SEDP Confirmation Dialog */} + setSedpConfirmOpen(false)} + onConfirm={handleSEDPSendConfirmed} + formName={formName} + tagCount={tableData.length} + isLoading={isSendingSEDP} + /> + + {/* SEDP Status Dialog */} + setSedpStatusOpen(false)} + status={sedpStatusData.status} + message={sedpStatusData.message} + successCount={sedpStatusData.successCount} + errorCount={sedpStatusData.errorCount} + totalCount={sedpStatusData.totalCount} + /> + + {/* SEDP Compare Dialog */} + setSedpCompareOpen(false)} + tableData={tableData} + columnsJSON={columnsJSON} + projectCode={projectCode} + formCode={formCode} + fetchTagDataFromSEDP={fetchTagDataFromSEDP} + /> + + {/* Other dialogs */} {tempUpDialog && ( ); -} +} \ 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 { + 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(); + 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(); + 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 = {}; + + // 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(); + + // 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 = ({ + open, + onOpenChange, + packageId, + formCode, + fileBlob, +}) => { + // Get current user session from next-auth + const { data: session } = useSession(); + + // State for form data + const [documents, setDocuments] = useState([]); + const [stages, setStages] = useState([]); + const [latestRevision, setLatestRevision] = useState(""); + + // State for document search + const [openDocumentCombobox, setOpenDocumentCombobox] = useState(false); + const [documentSearchValue, setDocumentSearchValue] = useState(""); + + // Selected values + const [selectedDocId, setSelectedDocId] = useState(""); + const [selectedDocumentDisplay, setSelectedDocumentDisplay] = useState(""); + const [selectedStage, setSelectedStage] = useState(""); + const [revisionInput, setRevisionInput] = useState(""); + const [uploaderName, setUploaderName] = useState(""); + const [comment, setComment] = useState(""); + const [customFileName, setCustomFileName] = useState(`${formCode}_document.docx`); + + // Loading states + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(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 ( + + + + Publish Document + + Select document, stage, and revision to publish the vendor document. + + + +
+
+ {/* Document Selection with Search */} +
+ +
+ + + + + + + + No document found. + + {filteredDocuments.map((doc) => ( + { + setSelectedDocId(String(doc.id)); + setSelectedDocumentDisplay(`${doc.docNumber} - ${doc.title}`); + setOpenDocumentCombobox(false); + }} + className="flex items-center" + > + + {/* Add text-overflow handling for document items */} + {doc.docNumber} - {doc.title} + + ))} + + + + +
+
+ + {/* Stage Selection */} +
+ +
+ +
+
+ + {/* Revision Input */} +
+ +
+ setRevisionInput(e.target.value)} + placeholder="Enter revision" + disabled={isLoading || !selectedStage} + /> + {latestRevision && ( +

+ Latest revision: {latestRevision} +

+ )} +
+
+ +
+ +
+ setCustomFileName(e.target.value)} + placeholder="Custom file name" + /> +
+
+ +
+ +
+ 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 && ( +

+ Using your account name from login +

+ )} +
+
+ +
+ +
+