From b54f6f03150dd78d86db62201b6386bf14b72394 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 15 Oct 2025 12:52:11 +0000 Subject: (대표님) 커버, 데이터룸, 파일매니저, 담당자할당 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/cover/repository.ts | 44 ++ lib/cover/service.ts | 123 ++++++ lib/cover/table/cover-template-dialog.tsx | 455 +++++++++++++++++++++ lib/cover/table/projects-table-columns.tsx | 187 +++++++++ lib/cover/table/projects-table-toolbar-actions.tsx | 50 +++ lib/cover/table/projects-table.tsx | 114 ++++++ lib/cover/validation.ts | 36 ++ lib/owner-companies/owner-company-form.tsx | 99 +++++ lib/owner-companies/owner-company-list.tsx | 85 ++++ lib/owner-companies/owner-company-user-form.tsx | 125 ++++++ lib/owner-companies/owner-company-user-list.tsx | 93 +++++ lib/owner-companies/service.ts | 77 ++++ .../detail-table/pcr-detail-toolbar-action.tsx | 5 +- lib/pcr/table/pcr-table-toolbar-actions.tsx | 4 +- lib/po/service.ts | 2 +- lib/procurement-items/service.ts | 18 +- lib/procurement-items/validations.ts | 6 +- lib/rfq-last/service.ts | 1 + lib/rfq-last/table/rfq-assign-pic-dialog.tsx | 36 +- lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 28 +- lib/rfq-last/vendor/rfq-vendor-table.tsx | 32 +- lib/techsales-rfq/service.ts | 4 +- lib/vendor-document-list/plant/upload/columns.tsx | 198 ++++----- lib/vendor-document-list/plant/upload/table.tsx | 37 +- .../ship-all/enhanced-documents-table.tsx | 20 +- 25 files changed, 1709 insertions(+), 170 deletions(-) create mode 100644 lib/cover/repository.ts create mode 100644 lib/cover/service.ts create mode 100644 lib/cover/table/cover-template-dialog.tsx create mode 100644 lib/cover/table/projects-table-columns.tsx create mode 100644 lib/cover/table/projects-table-toolbar-actions.tsx create mode 100644 lib/cover/table/projects-table.tsx create mode 100644 lib/cover/validation.ts create mode 100644 lib/owner-companies/owner-company-form.tsx create mode 100644 lib/owner-companies/owner-company-list.tsx create mode 100644 lib/owner-companies/owner-company-user-form.tsx create mode 100644 lib/owner-companies/owner-company-user-list.tsx create mode 100644 lib/owner-companies/service.ts (limited to 'lib') diff --git a/lib/cover/repository.ts b/lib/cover/repository.ts new file mode 100644 index 00000000..62b70778 --- /dev/null +++ b/lib/cover/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { projects } from "@/db/schema"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectProjectLists( + tx: PgTransaction, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(projects) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } +/** 총 개수 count */ +export async function countProjectLists( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(projects).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/cover/service.ts b/lib/cover/service.ts new file mode 100644 index 00000000..91ea3458 --- /dev/null +++ b/lib/cover/service.ts @@ -0,0 +1,123 @@ +"use server"; + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { countProjectLists, selectProjectLists } from "./repository"; +import { projects, projectCoverTemplates, generatedCoverPages } from "@/db/schema"; +import { GetProjectListsSchema } from "./validation"; + +export async function getProjectListsForCover(input: GetProjectListsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; + + const advancedWhere = filterColumns({ + table: projects, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(projects.name, s), + ilike(projects.code, s), + ilike(projects.type, s), + ) + } + + const finalWhere = and( + eq(projects.type, "plant"), + advancedWhere, + globalWhere + ) + + const where = finalWhere + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(projects[item.id]) : asc(projects[item.id]) + ) + : [asc(projects.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const projectData = await selectProjectLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + // 프로젝트 ID 목록 추출 + const projectIds = projectData.map(p => p.id); + + // 활성 템플릿 정보 조회 + const templates = projectIds.length > 0 + ? await tx + .select() + .from(projectCoverTemplates) + .where( + and( + inArray(projectCoverTemplates.projectId, projectIds), + eq(projectCoverTemplates.isActive, true) + ) + ) + : []; + + // 템플릿 맵 생성 + const templateMap = new Map( + templates.map(t => [t.projectId, t]) + ); + + // 생성된 커버 페이지 조회 (각 템플릿의 최신 것만) + const templateIds = templates.map(t => t.id); + const generatedCovers = templateIds.length > 0 + ? await tx + .select() + .from(generatedCoverPages) + .where(inArray(generatedCoverPages.templateId, templateIds)) + .orderBy(desc(generatedCoverPages.generatedAt)) + : []; + + // 각 템플릿별 최신 생성 커버 맵 생성 + const latestCoverMap = new Map(); + for (const cover of generatedCovers) { + if (!latestCoverMap.has(cover.templateId)) { + latestCoverMap.set(cover.templateId, cover); + } + } + + // 프로젝트에 템플릿 및 생성된 커버 정보 병합 + const data = projectData.map(project => { + const template = templateMap.get(project.id); + const latestCover = template ? latestCoverMap.get(template.id) : null; + + return { + ...project, + coverTemplatePath: template?.filePath || null, + templateVariables: template?.variables || null, + template: template || null, + generatedCover: latestCover || null, + generatedCoverPath: latestCover?.filePath || null, + }; + }); + + const total = await countProjectLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("❌ getProjectListsForCover 오류:", err); + return { data: [], pageCount: 0 }; + } +} \ No newline at end of file diff --git a/lib/cover/table/cover-template-dialog.tsx b/lib/cover/table/cover-template-dialog.tsx new file mode 100644 index 00000000..f5ac3fae --- /dev/null +++ b/lib/cover/table/cover-template-dialog.tsx @@ -0,0 +1,455 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Upload, Save, Download, Copy, Check, Loader2 } from "lucide-react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { Project } from "@/db/schema" +import { toast } from "sonner" +import { quickDownload } from "@/lib/file-download" +import { BasicContractTemplateViewer } from "@/lib/basic-contract/template/basic-contract-template-viewer" +import { useRouter, usePathname } from "next/navigation" + +interface CoverTemplateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + project: Project | null +} + +export function CoverTemplateDialog({ open, onOpenChange, project }: CoverTemplateDialogProps) { + const [instance, setInstance] = React.useState(null) + const [filePath, setFilePath] = React.useState("") + const [uploadedFile, setUploadedFile] = React.useState(null) + const [isSaving, setIsSaving] = React.useState(false) + const [isUploading, setIsUploading] = React.useState(false) + const [copiedVar, setCopiedVar] = React.useState(null) + const router = useRouter() + + // 필수 템플릿 변수 + const templateVariables = [ + { key: "docNumber", value: "docNumber", label: "문서 번호" }, + { key: "projectNumber", value: "projectNumber", label: "프로젝트 번호" }, + { key: "projectName", value: "projectName", label: "프로젝트명" } + ] + + // instance 상태 모니터링 + React.useEffect(() => { + console.log("🔍 Instance 상태:", instance ? "있음" : "없음"); + }, [instance]); + + // 다이얼로그가 열릴 때마다 상태 초기화 및 템플릿 로드 + React.useEffect(() => { + if (open) { + // instance는 초기화하지 않음 - 뷰어가 알아서 설정함 + setUploadedFile(null) + setIsSaving(false) + setIsUploading(false) + setCopiedVar(null) + + // 프로젝트에 저장된 템플릿이 있으면 로드 + if (project?.coverTemplatePath) { + setFilePath(project.coverTemplatePath) + } else { + setFilePath("") + } + } else { + // 다이얼로그가 닫힐 때만 완전히 초기화 + setFilePath("") + setInstance(null) + setUploadedFile(null) + } + }, [open, project]) + + // 파일 업로드 핸들러 + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + if (!file.name.endsWith('.docx')) { + toast.error("DOCX 파일만 업로드 가능합니다") + return + } + + setIsUploading(true) + setUploadedFile(file) + + const formData = new FormData() + formData.append("file", file) + formData.append("projectId", String(project?.id)) + + try { + const response = await fetch("/api/projects/cover-template/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || "업로드 실패") + } + + const data = await response.json() + setFilePath(data.filePath) + router.refresh() + toast.success("템플릿 파일이 업로드되었습니다") + } catch (error) { + console.error("파일 업로드 오류:", error) + toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + } + } + + // 복사 함수 - 더 강력한 버전 + const copyToClipboard = async (text: string, key: string) => { + let copySuccess = false; + + // 방법 1: 최신 Clipboard API (가장 확실함) + try { + await navigator.clipboard.writeText(text); + copySuccess = true; + setCopiedVar(key); + setTimeout(() => setCopiedVar(null), 2000); + toast.success(`복사됨: ${text}`, { + description: "문서에 붙여넣으세요 (Ctrl+V)" + }); + return; + } catch (err) { + console.error("Clipboard API 실패:", err); + } + + // 방법 2: 이벤트 기반 복사 (사용자 상호작용 컨텍스트 유지) + try { + const listener = (e: ClipboardEvent) => { + e.clipboardData?.setData('text/plain', text); + e.preventDefault(); + copySuccess = true; + }; + + document.addEventListener('copy', listener); + const result = document.execCommand('copy'); + document.removeEventListener('copy', listener); + + if (result && copySuccess) { + setCopiedVar(key); + setTimeout(() => setCopiedVar(null), 2000); + toast.success(`복사됨: ${text}`, { + description: "문서에 붙여넣으세요 (Ctrl+V)" + }); + return; + } + } catch (err) { + console.error("이벤트 기반 복사 실패:", err); + } + + // 방법 3: textarea 방식 (강화 버전) + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // 스타일 설정으로 화면에 보이지 않게 + textArea.style.position = "fixed"; + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.width = "2em"; + textArea.style.height = "2em"; + textArea.style.padding = "0"; + textArea.style.border = "none"; + textArea.style.outline = "none"; + textArea.style.boxShadow = "none"; + textArea.style.background = "transparent"; + textArea.style.opacity = "0"; + + document.body.appendChild(textArea); + + // iOS 대응 + if (navigator.userAgent.match(/ipad|ipod|iphone/i)) { + textArea.contentEditable = "true"; + textArea.readOnly = false; + const range = document.createRange(); + range.selectNodeContents(textArea); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + textArea.setSelectionRange(0, 999999); + } else { + textArea.select(); + textArea.setSelectionRange(0, 99999); + } + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + setCopiedVar(key); + setTimeout(() => setCopiedVar(null), 2000); + toast.success(`복사됨: ${text}`, { + description: "문서에 붙여넣으세요 (Ctrl+V)" + }); + copySuccess = true; + return; + } + } catch (err) { + console.error("textarea 복사 실패:", err); + } + + // 모든 방법 실패 + if (!copySuccess) { + toast.error("자동 복사 실패", { + description: `수동으로 복사하세요: ${text}`, + duration: 5000, + }); + } + }; + + // 템플릿 저장 + const handleSaveTemplate = async () => { + console.log("💾 저장 시도 - instance:", instance); + console.log("💾 저장 시도 - project:", project); + + if (!instance) { + toast.error("뷰어가 아직 준비되지 않았습니다", { + description: "문서가 완전히 로드될 때까지 기다려주세요" + }) + return + } + + if (!project) { + toast.error("프로젝트 정보가 없습니다") + return + } + + setIsSaving(true) + + try { + const { documentViewer } = instance.Core + const doc = documentViewer.getDocument() + + if (!doc) { + throw new Error("문서가 로드되지 않았습니다") + } + + console.log("📄 문서 export 시작..."); + + // DOCX로 export + const data = await doc.getFileData({ + downloadType: 'office', + includeAnnotations: true + }) + + console.log("✅ 문서 export 완료, 크기:", data.byteLength); + + const blob = new Blob([data], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }) + + // FormData 생성 + const formData = new FormData() + formData.append("file", blob, `${project.code}_cover_template.docx`) + formData.append("projectId", String(project.id)) + formData.append("templateName", `${project.name} 커버 템플릿`) + + console.log("📤 서버 전송 시작..."); + + const response = await fetch("/api/projects/cover-template/save", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || "저장 실패") + } + + const result = await response.json() + + console.log("✅ 서버 저장 완료:", result); + router.refresh() + toast.success("커버 페이지가 생성되었습니다") + + // 저장된 파일 경로 업데이트 + if (result.filePath) { + setFilePath(result.filePath) + } + + onOpenChange(false) + } catch (error) { + console.error("❌ 템플릿 저장 오류:", error) + toast.error(error instanceof Error ? error.message : "템플릿 저장 중 오류가 발생했습니다") + } finally { + setIsSaving(false) + } + } + + const handleDownloadTemplate = () => { + if (!filePath || !project) return + quickDownload(filePath, `${project.code}_cover_template.docx`) + } + + return ( + + + + + 커버 페이지 템플릿 관리 - {project?.name} ({project?.code}) + + + +
+
+
+ +
+ + {filePath && ( + + )} +
+ {isUploading && ( +

업로드 중...

+ )} +
+ +
+ +
+ 복사 버튼을 클릭하여 변수를 복사한 후 문서에 붙여넣으세요 +
+ +
+ {templateVariables.map(({ key, value, label }) => ( +
+
+
{label}
+
+ + +
+
+
+ ))} +
+ +
+
💡 사용 방법
+ 1. 복사 버튼을 클릭하여 변수를 복사
+ 2. 문서에서 원하는 위치에 Ctrl+V로 붙여넣기
+ 3. 문서 생성 시 변수는 실제 값으로 자동 치환됩니다
+
+
📌 커스텀 변수
+ 필요한 경우 {`{{customField}}`} 형식으로 직접 입력 가능 +
+
+ +
+ {/* 상태 표시 */} +
+
+
+ 파일: {filePath ? '준비됨' : '없음'} +
+ {filePath && +
+
+ 뷰어: {instance ? '준비됨' : '로딩 중...'} +
+ } +
+ + +
+
+ +
+ {filePath ? ( +
+ +
+ ) : ( +
+ DOCX 파일을 업로드하세요 +
+ )} +
+
+ +
+ ) +} \ No newline at end of file diff --git a/lib/cover/table/projects-table-columns.tsx b/lib/cover/table/projects-table-columns.tsx new file mode 100644 index 00000000..9ed36436 --- /dev/null +++ b/lib/cover/table/projects-table-columns.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Eye, FileText, FilePlus } from "lucide-react" +import { Checkbox } from "@/components/ui/checkbox"; + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { quickDownload } from "@/lib/file-download" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Project } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> + onDetailClick: (project: Project) => void + onTemplateManage: (project: Project) => void +} + +export function getColumns({ setRowAction, onDetailClick, onTemplateManage }: GetColumnsProps): ColumnDef[] { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "code", + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: "Project Code", + }, + }, + { + accessorKey: "name", + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: "Project Name", + }, + }, + { + id: "coverTemplate", + enableResizing: true, + header: "커버 템플릿", + cell: ({ row }) => { + const project = row.original + const hasTemplate = !!project.coverTemplatePath + + return ( +
+ {hasTemplate ? ( + <> + + + + ) : ( + + )} +
+ ) + }, + }, + { + id: "generatedCover", + enableResizing: true, + header: "생성된 커버", + cell: ({ row }) => { + const project = row.original + const hasGenerated = !!project.generatedCoverPath + const generatedAt = project.generatedCover?.generatedAt + + return ( +
+ {hasGenerated ? ( + <> + + {generatedAt && ( + + {formatDate(new Date(generatedAt), "KR")} + + )} + + ) : ( + - + )} +
+ ) + }, + }, + { + accessorKey: "OWN_NM", + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: "Owner Name", + }, + }, + { + accessorKey: "createdAt", + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: "Created At", + }, + cell: ({ cell }) => { + const dateVal = cell.getValue() as Date + return formatDate(dateVal, "KR") + }, + }, + { + accessorKey: "updatedAt", + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: "Updated At", + }, + cell: ({ cell }) => { + const dateVal = cell.getValue() as Date + return formatDate(dateVal, "KR") + }, + }, + ] +} \ No newline at end of file diff --git a/lib/cover/table/projects-table-toolbar-actions.tsx b/lib/cover/table/projects-table-toolbar-actions.tsx new file mode 100644 index 00000000..5d2d1fc6 --- /dev/null +++ b/lib/cover/table/projects-table-toolbar-actions.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, FileText } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Project } from "@/db/schema" +import { CoverTemplateDialog } from "./cover-template-dialog" + +interface ItemsTableToolbarActionsProps { + table: Table +} + +export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + const handleTemplateClick = () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length !== 1) { + toast.error("프로젝트를 하나만 선택해주세요") + return + } + setSelectedProject(selectedRows[0].original) + setTemplateDialogOpen(true) + } + + return ( +
+ + + +
+ ) +} \ No newline at end of file diff --git a/lib/cover/table/projects-table.tsx b/lib/cover/table/projects-table.tsx new file mode 100644 index 00000000..944013ef --- /dev/null +++ b/lib/cover/table/projects-table.tsx @@ -0,0 +1,114 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getColumns } from "./projects-table-columns" +import { getProjectListsForCover } from "../service" +import { Project } from "@/db/schema" +import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions" +import { CoverTemplateDialog } from "./cover-template-dialog" + +interface ItemsTableProps { +promises: Promise< +[ + Awaited>, +] +> +} + +export function ProjectsTableForCover({ promises }: ItemsTableProps) { + const [{ data, pageCount }] = React.use(promises) + + console.log(data, 'data') + + const [rowAction, setRowAction] = + React.useState | null>(null) + + // 템플릿 다이얼로그 상태 + const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + const handleTemplateManage = React.useCallback((project: Project) => { + setSelectedProject(project) + setTemplateDialogOpen(true) + }, []) + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + onDetailClick: () => {}, + onTemplateManage: handleTemplateManage + }), + [setRowAction, handleTemplateManage] + ) + + const filterFields: DataTableFilterField[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "code", + label: "Project Code", + type: "text", + }, + { + id: "name", + label: "Project Name", + type: "text", + }, + { + id: "createdAt", + label: "Created At", + type: "date", + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + + + ) +} \ No newline at end of file diff --git a/lib/cover/validation.ts b/lib/cover/validation.ts new file mode 100644 index 00000000..ed1cc9a1 --- /dev/null +++ b/lib/cover/validation.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Project } from "@/db/schema"; + +export const searchParamsProjectsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + code: parseAsString.withDefault(""), + name: parseAsString.withDefault(""), + type: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + + +export type GetProjectListsSchema = Awaited> diff --git a/lib/owner-companies/owner-company-form.tsx b/lib/owner-companies/owner-company-form.tsx new file mode 100644 index 00000000..a385eccc --- /dev/null +++ b/lib/owner-companies/owner-company-form.tsx @@ -0,0 +1,99 @@ +// app/(admin)/owner-companies/_components/owner-company-form.tsx +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { createOwnerCompany, updateOwnerCompany } from "./service"; + +const formSchema = z.object({ + name: z.string().min(1, "회사명을 입력해주세요"), +}); + +type FormValues = z.infer; + +interface OwnerCompanyFormProps { + initialData?: { + id: number; + name: string; + }; +} + +export function OwnerCompanyForm({ initialData }: OwnerCompanyFormProps) { + const router = useRouter(); + const isEdit = !!initialData; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: initialData?.name || "", + }, + }); + + async function onSubmit(values: FormValues) { + try { + const result = isEdit + ? await updateOwnerCompany(initialData.id, values) + : await createOwnerCompany(values); + + if (result.success) { + toast.success( + isEdit ? "회사 정보가 수정되었습니다" : "회사가 등록되었습니다" + ); + router.push("/evcp/data-room/owner-companies"); + router.refresh(); + } + } catch (error) { + toast.error("오류가 발생했습니다"); + } + } + + return ( +
+ + ( + + 회사명 * + + + + + + )} + /> + +
+ + +
+ + + ); +} \ No newline at end of file diff --git a/lib/owner-companies/owner-company-list.tsx b/lib/owner-companies/owner-company-list.tsx new file mode 100644 index 00000000..b78b193b --- /dev/null +++ b/lib/owner-companies/owner-company-list.tsx @@ -0,0 +1,85 @@ +// app/(admin)/owner-companies/_components/owner-company-list.tsx +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import Link from "next/link"; +import { Building2, Users } from "lucide-react"; + +interface OwnerCompany { + id: number; + name: string; + createdAt: Date; +} + +interface OwnerCompanyListProps { + companies: OwnerCompany[]; +} + +export function OwnerCompanyList({ companies }: OwnerCompanyListProps) { + if (companies.length === 0) { + return ( +
+ +

등록된 회사가 없습니다

+

+ 첫 번째 발주처 회사를 등록해보세요. +

+ +
+ ); + } + + return ( + + + + 회사명 + 등록일 + 작업 + + + + {companies.map((company) => ( + + +
+ + {company.name} +
+
+ + {new Date(company.createdAt).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + })} + + +
+ + +
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/lib/owner-companies/owner-company-user-form.tsx b/lib/owner-companies/owner-company-user-form.tsx new file mode 100644 index 00000000..52253607 --- /dev/null +++ b/lib/owner-companies/owner-company-user-form.tsx @@ -0,0 +1,125 @@ +// app/(admin)/owner-companies/_components/owner-company-user-form.tsx +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { createOwnerCompanyUser } from "./service"; + +const formSchema = z.object({ + name: z.string().min(1, "이름을 입력해주세요"), + email: z.string().email("올바른 이메일을 입력해주세요"), + phone: z.string().optional(), +}); + +type FormValues = z.infer; + +interface OwnerCompanyUserFormProps { + companyId: number; +} + +export function OwnerCompanyUserForm({ companyId }: OwnerCompanyUserFormProps) { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + email: "", + phone: "", + }, + }); + + async function onSubmit(values: FormValues) { + try { + const result = await createOwnerCompanyUser(companyId, values); + + if (result.success) { + toast.success("사용자가 등록되었습니다"); + router.push(`/evcp/data-room/owner-companies/${companyId}/users`); + router.refresh(); + } else { + toast.error(result.error || "오류가 발생했습니다"); + } + } catch (error) { + toast.error("오류가 발생했습니다"); + } + } + + return ( +
+ + ( + + 이름 * + + + + + + )} + /> + + ( + + 이메일 * + + + + + + )} + /> + + ( + + 전화번호 + + + + + + )} + /> + +
+ + +
+ + + ); +} \ No newline at end of file diff --git a/lib/owner-companies/owner-company-user-list.tsx b/lib/owner-companies/owner-company-user-list.tsx new file mode 100644 index 00000000..1f0963fe --- /dev/null +++ b/lib/owner-companies/owner-company-user-list.tsx @@ -0,0 +1,93 @@ +// app/(admin)/owner-companies/_components/owner-company-user-list.tsx +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { UserPlus } from "lucide-react"; +import Link from "next/link"; + +interface User { + id: number; + name: string; + email: string; + phone: string | null; + isActive: boolean; + createdAt: Date; + employeeNumber: string | null; +} + +interface OwnerCompanyUserListProps { + users: User[]; + companyId: number; +} + +export function OwnerCompanyUserList({ + users, + companyId, +}: OwnerCompanyUserListProps) { + if (users.length === 0) { + return ( +
+ +

등록된 사용자가 없습니다

+

+ 첫 번째 사용자를 추가해보세요. +

+ +
+ ); + } + + return ( + + + + 이름 + 이메일 + 전화번호 + 사번 + 상태 + 등록일 + + + + {users.map((user) => ( + + {user.name} + {user.email} + {user.phone || "-"} + {user.employeeNumber || "-"} + + {user.isActive ? ( + + 활성 + + ) : ( + 비활성 + )} + + + {new Date(user.createdAt).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + })} + + + ))} + +
+ ); +} \ No newline at end of file diff --git a/lib/owner-companies/service.ts b/lib/owner-companies/service.ts new file mode 100644 index 00000000..3692abd4 --- /dev/null +++ b/lib/owner-companies/service.ts @@ -0,0 +1,77 @@ +// lib/owner-companies/service.ts +"use server"; + +import db from "@/db/db"; +import { ownerCompanies, users } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; + +export async function createOwnerCompany(data: { name: string }) { + const [company] = await db + .insert(ownerCompanies) + .values({ + name: data.name, + }) + .returning(); + + revalidatePath("/owner-companies"); + return { success: true, data: company }; +} + +export async function updateOwnerCompany( + id: number, + data: { name: string } +) { + const [company] = await db + .update(ownerCompanies) + .set({ + name: data.name, + }) + .where(eq(ownerCompanies.id, id)) + .returning(); + + revalidatePath("/owner-companies"); + revalidatePath(`/owner-companies/${id}`); + return { success: true, data: company }; +} + +export async function createOwnerCompanyUser( + companyId: number, + data: { + name: string; + email: string; + phone?: string; + employeeNumber?: string; + } +) { + // 이메일 중복 체크 + const existing = await db + .select() + .from(users) + .where(eq(users.email, data.email)) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: "이미 사용 중인 이메일입니다." }; + } + + const [user] = await db + .insert(users) + .values({ + ...data, + ownerCompanyId: companyId, + domain: "owner", // 발주처 도메인 + isActive: true, + }) + .returning(); + + revalidatePath(`/owner-companies/${companyId}/users`); + return { success: true, data: user }; +} + +export async function getOwnerCompanyUsers(companyId: number) { + return await db + .select() + .from(users) + .where(eq(users.ownerCompanyId, companyId)); +} \ No newline at end of file diff --git a/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx b/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx index 92829055..8e012e57 100644 --- a/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx +++ b/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx @@ -52,7 +52,7 @@ export function PcrDetailToolbarAction({ {/* PCR_PR 생성 버튼 - Partners 페이지에서는 표시하지 않음 */} - {!isPartnersPage && ( + {/* {!isPartnersPage && ( <>

선택한 ITB에 구매 담당자를 지정합니다

- {selectedRfqData.itbCount !== selectedRfqData.totalCount && ( + {selectedRfqData.assignableItbCount !== selectedRfqData.itbCount && (

- 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다 + 전체 ITB {selectedRfqData.itbCount}건 중 {selectedRfqData.assignableItbCount}건만 지정 가능합니다

)}
@@ -103,7 +113,7 @@ export function RfqTableToolbarActions({ {selectedRfqData.totalCount !== selectedRfqData.itbCount && ( - ITB {selectedRfqData.itbCount}건 + ITB {selectedRfqData.itbCount}건 (지정가능 {selectedRfqData.assignableItbCount}건) )} @@ -139,8 +149,8 @@ export function RfqTableToolbarActions({ diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index dc5564e2..428160d5 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -753,8 +753,10 @@ export function RfqVendorTable({ filterFn: createFilterFn("text"), cell: ({ row }) => { - const status = row.original.tbeStatus; + const status = row.original.tbeStatus?.trim(); + const rfqCode = row.original.rfqCode?.trim(); + // 생성중/준비중은 대기 표시(비클릭) if (!status || status === "준비중") { return ( @@ -772,8 +774,28 @@ export function RfqVendorTable({ "취소": { variant: "destructive", icon: }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; + const isClickable = !!rfqCode; + return ( - + { + if (!isClickable) return; + e.stopPropagation(); + e.preventDefault(); + router.push(`/evcp/tbe-last?search=${encodeURIComponent(rfqCode!)}`); + // window.open( + // `/evcp/tbe-last?search=${encodeURIComponent(rfqCode!)}`, + // "_blank", + // "noopener,noreferrer" + // ); + // 새 창으로 이동 + }} + title={isClickable ? `TBE로 이동: ${rfqCode}` : undefined} + > {statusConfig.icon} {status} @@ -802,19 +824,19 @@ export function RfqVendorTable({ "Acceptable": { variant: "success", icon: , - text: "적합", + text: "Acceptable", color: "bg-green-50 text-green-700 border-green-200" }, "Acceptable with Comment": { variant: "warning", icon: , - text: "조건부 적합", + text: "Acceptable with Comment", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, "Not Acceptable": { variant: "destructive", icon: , - text: "부적합", + text: "Not Acceptable", color: "bg-red-50 text-red-700 border-red-200" }, }[result]; diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index deb2981a..b4fb28df 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -744,9 +744,7 @@ export async function sendTechSalesRfqToVendors(input: { // 이메일 전송 await sendEmail({ to: vendorEmailsString, - subject: isResend - ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}` - : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`, + subject: '견적 요청', template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿 context: emailContext, cc: sender.email, // 발신자를 CC에 추가 diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx index 01fc61df..9c2fe228 100644 --- a/lib/vendor-document-list/plant/upload/columns.tsx +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -17,16 +17,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { - Ellipsis, - Upload, - Eye, - RefreshCw, - CheckCircle2, - XCircle, +import { + Ellipsis, + Upload, + Eye, + RefreshCw, + CheckCircle2, + XCircle, AlertCircle, Clock, - Download + Download } from "lucide-react" interface GetColumnsProps { @@ -109,7 +109,7 @@ export function getColumns({ const stageName = row.getValue("stageName") as string const stageStatus = row.original.stageStatus const stageOrder = row.original.stageOrder - + return (
@@ -119,12 +119,12 @@ export function getColumns({ {stageName}
{stageStatus && ( - @@ -145,9 +145,9 @@ export function getColumns({ const planDate = row.getValue("stagePlanDate") as Date | null const isOverdue = row.original.isOverdue const daysUntilDue = row.original.daysUntilDue - + if (!planDate) return - - + return (
@@ -187,7 +187,7 @@ export function getColumns({ const reviewStatus = row.original.latestReviewStatus const revisionNumber = row.original.latestRevisionNumber const revisionCode = row.original.latestRevisionCode - + if (!status) { return ( @@ -196,20 +196,20 @@ export function getColumns({ ) } - + return (
- {reviewStatus || status} - {revisionCode !== null &&( + {revisionCode !== null && (
{revisionCode}
@@ -229,7 +229,7 @@ export function getColumns({ const syncStatus = row.getValue("latestSyncStatus") as string | null const syncProgress = row.original.syncProgress const requiresSync = row.original.requiresSync - + if (!syncStatus || syncStatus === "pending") { if (requiresSync) { return ( @@ -241,15 +241,15 @@ export function getColumns({ } return - } - + return (
- @@ -274,9 +274,9 @@ export function getColumns({ cell: ({ row }) => { const totalFiles = row.getValue("totalFiles") as number const syncedFiles = row.original.syncedFilesCount - + if (!totalFiles) return 0 - + return (
{syncedFiles !== null && syncedFiles !== undefined ? ( @@ -297,7 +297,7 @@ export function getColumns({ // cell: ({ row }) => { // const vendorName = row.getValue("vendorName") as string // const vendorCode = row.original.vendorCode - + // return ( //
//
{vendorName}
@@ -309,82 +309,88 @@ export function getColumns({ // }, // size: 150, // }, - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const requiresSubmission = row.original.requiresSubmission - const requiresSync = row.original.requiresSync - const latestSubmissionId = row.original.latestSubmissionId - - return ( - - - + + + {requiresSubmission && ( + setRowAction({ row, type: "upload" })} + className="gap-2" + > + + Upload Documents + + )} + + {latestSubmissionId && ( + <> + setRowAction({ row, type: "view" })} + className="gap-2" > - - - - - {requiresSubmission && ( + + View Submission + + + {requiresSync && ( setRowAction({ row, type: "upload" })} + onSelect={() => setRowAction({ row, type: "sync" })} className="gap-2" > - - Upload Documents + + Retry Sync )} - - {latestSubmissionId && ( - <> - setRowAction({ row, type: "view" })} - className="gap-2" - > - - View Submission - - - {requiresSync && ( - setRowAction({ row, type: "sync" })} - className="gap-2" - > - - Retry Sync - - )} - - )} - - - {/* ✅ 커버 페이지 다운로드 */} - setRowAction({ row, type: "downloadCover" })} - className="gap-2" - > - - Download Cover Page - + + )} + {/* ✅ 커버 페이지 다운로드 - projectCode가 있을 때만 표시 */} + {projectCode && ( + <> - setRowAction({ row, type: "history" })} + onSelect={() => setRowAction({ row, type: "downloadCover" })} className="gap-2" > - - View History + + Download Cover Page - - - ) - }, - size: 40, - } + + )} + + + + setRowAction({ row, type: "history" })} + className="gap-2" + > + + View History + + + + ) + }, + size: 40, +} ] } \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx index 84b04092..2247fc57 100644 --- a/lib/vendor-document-list/plant/upload/table.tsx +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -21,6 +21,7 @@ import { SingleUploadDialog } from "./components/single-upload-dialog" import { HistoryDialog } from "./components/history-dialog" import { ViewSubmissionDialog } from "./components/view-submission-dialog" import { toast } from "sonner" +import { quickDownload } from "@/lib/file-download" interface StageSubmissionsTableProps { promises: Promise<[ @@ -167,23 +168,43 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm const { type, row } = rowAction; if (type === "downloadCover") { - // 2) 서버에서 생성 후 다운로드 (예: API 호출) + const projectCode = row.original.projectCode; + const project = projects.find(p => p.code === projectCode); + + if (!project) { + toast.error("프로젝트 정보를 찾을 수 없습니다."); + setRowAction(null); + return; + } + (async () => { try { - const res = await fetch(`/api/stages/${row.original.stageId}/cover`, { method: "POST" }); - if (!res.ok) throw new Error("failed"); - const { fileUrl } = await res.json(); // 서버 응답: { fileUrl: string } - window.open(fileUrl, "_blank", "noopener,noreferrer"); + const res = await fetch(`/api/projects/${project.id}/cover`, { + method: "GET" + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || "커버 페이지를 가져올 수 없습니다"); + } + + const { fileUrl, fileName } = await res.json(); + + // quickDownload 사용 + quickDownload(fileUrl, fileName || `${projectCode}_cover.docx`); + + toast.success("커버 페이지 다운로드를 시작했습니다."); + } catch (e) { - toast.error("커버 페이지 생성에 실패했습니다."); + toast.error(e instanceof Error ? e.message : "커버 페이지 다운로드에 실패했습니다."); console.error(e); } finally { setRowAction(null); } })(); } - }, [rowAction, setRowAction]); - + }, [rowAction, setRowAction, projects]); + return ( <> diff --git a/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx b/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx index 255aa56c..b8b7542a 100644 --- a/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx +++ b/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx @@ -167,16 +167,16 @@ export function SimplifiedDocumentsTable({ label: "Second Actual Date", type: "date", }, - { - id: "issuedDate", - label: "Issue Date", - type: "date", - }, - { - id: "createdAt", - label: "Created Date", - type: "date", - }, + // { + // id: "issuedDate", + // label: "Issue Date", + // type: "date", + // }, + // { + // id: "createdAt", + // label: "Created Date", + // type: "date", + // }, { id: "updatedAt", label: "Updated Date", -- cgit v1.2.3