From 2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 2 Jul 2025 00:45:49 +0000 Subject: (대표님/최겸) 20250702 변경사항 업데이트 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../engineering/(engineering)/report/page.tsx | 154 +- app/[lng]/evcp/(evcp)/report/page.tsx | 4 +- app/[lng]/page.tsx | 158 + .../procurement/(procurement)/report/page.tsx | 154 +- app/[lng]/sales/(sales)/report/page.tsx | 154 +- app/api/richtext/route.ts | 54 + app/api/vendor-responses/update-comment/route.ts | 2 + app/api/vendor-responses/update/route.ts | 1 + components/error-boundary.tsx | 45 + components/layout/SessionManager.tsx | 2 +- components/qna/comment-section.tsx | 166 + components/qna/richTextEditor.tsx | 676 + components/qna/tiptap-editor.tsx | 287 + components/tech-vendors/tech-vendor-container.tsx | 7 +- config/dashboard-table.ts | 96 + config/menuConfig.ts | 7 +- config/partners-dashboard-table.ts | 49 + db/migrations/0175_slippery_morph.sql | 4 + db/migrations/0176_huge_absorbing_man.sql | 4 + db/migrations/0177_fresh_the_captain.sql | 44 + db/migrations/0178_icy_robin_chapel.sql | 158 + db/migrations/0179_robust_ghost_rider.sql | 163 + db/migrations/0180_amusing_post.sql | 163 + db/migrations/0182_grey_banshee.sql | 1053 + db/migrations/meta/0175_snapshot.json | 31096 +++++++++++++++ db/migrations/meta/0176_snapshot.json | 31134 +++++++++++++++ db/migrations/meta/0177_snapshot.json | 31478 +++++++++++++++ db/migrations/meta/0178_snapshot.json | 31854 ++++++++++++++++ db/migrations/meta/0179_snapshot.json | 31891 ++++++++++++++++ db/migrations/meta/0180_snapshot.json | 31685 ++++++++++++++++ db/migrations/meta/0182_snapshot.json | 37843 +++++++++++++++++++ db/migrations/meta/_journal.json | 63 + db/schema/bRfq.ts | 10 + db/schema/evaluationCriteria.ts | 11 +- db/schema/qna.ts | 583 + lib/b-rfq/service.ts | 6 + lib/b-rfq/summary-table/summary-rfq-columns.tsx | 4 +- lib/dashboard/dashboard-client.tsx | 115 + lib/dashboard/dashboard-overview-chart.tsx | 325 + lib/dashboard/dashboard-stats-card.tsx | 88 + lib/dashboard/dashboard-summary-cards.tsx | 64 + lib/dashboard/partners-service.ts | 447 + lib/dashboard/service.ts | 454 + lib/qna/service.ts | 1006 + lib/qna/table/create-qna-dialog.tsx | 203 + lib/qna/table/delete-qna-dialog.tsx | 250 + lib/qna/table/improved-comment-section.tsx | 319 + lib/qna/table/qna-detail.tsx | 455 + lib/qna/table/qna-export-actions.tsx | 261 + lib/qna/table/qna-table-columns.tsx | 325 + lib/qna/table/qna-table-toolbar-actions.tsx | 176 + lib/qna/table/qna-table.tsx | 236 + lib/qna/table/update-qna-sheet.tsx | 206 + lib/qna/table/utils.tsx | 329 + lib/qna/validation.ts | 374 + lib/users/access-control/users-table.tsx | 2 +- middleware.ts | 3 + 57 files changed, 236783 insertions(+), 118 deletions(-) create mode 100644 app/[lng]/page.tsx create mode 100644 app/api/richtext/route.ts create mode 100644 components/error-boundary.tsx create mode 100644 components/qna/comment-section.tsx create mode 100644 components/qna/richTextEditor.tsx create mode 100644 components/qna/tiptap-editor.tsx create mode 100644 config/dashboard-table.ts create mode 100644 config/partners-dashboard-table.ts create mode 100644 db/migrations/0175_slippery_morph.sql create mode 100644 db/migrations/0176_huge_absorbing_man.sql create mode 100644 db/migrations/0177_fresh_the_captain.sql create mode 100644 db/migrations/0178_icy_robin_chapel.sql create mode 100644 db/migrations/0179_robust_ghost_rider.sql create mode 100644 db/migrations/0180_amusing_post.sql create mode 100644 db/migrations/0182_grey_banshee.sql create mode 100644 db/migrations/meta/0175_snapshot.json create mode 100644 db/migrations/meta/0176_snapshot.json create mode 100644 db/migrations/meta/0177_snapshot.json create mode 100644 db/migrations/meta/0178_snapshot.json create mode 100644 db/migrations/meta/0179_snapshot.json create mode 100644 db/migrations/meta/0180_snapshot.json create mode 100644 db/migrations/meta/0182_snapshot.json create mode 100644 db/schema/qna.ts create mode 100644 lib/dashboard/dashboard-client.tsx create mode 100644 lib/dashboard/dashboard-overview-chart.tsx create mode 100644 lib/dashboard/dashboard-stats-card.tsx create mode 100644 lib/dashboard/dashboard-summary-cards.tsx create mode 100644 lib/dashboard/partners-service.ts create mode 100644 lib/dashboard/service.ts create mode 100644 lib/qna/service.ts create mode 100644 lib/qna/table/create-qna-dialog.tsx create mode 100644 lib/qna/table/delete-qna-dialog.tsx create mode 100644 lib/qna/table/improved-comment-section.tsx create mode 100644 lib/qna/table/qna-detail.tsx create mode 100644 lib/qna/table/qna-export-actions.tsx create mode 100644 lib/qna/table/qna-table-columns.tsx create mode 100644 lib/qna/table/qna-table-toolbar-actions.tsx create mode 100644 lib/qna/table/qna-table.tsx create mode 100644 lib/qna/table/update-qna-sheet.tsx create mode 100644 lib/qna/table/utils.tsx create mode 100644 lib/qna/validation.ts diff --git a/app/[lng]/engineering/(engineering)/report/page.tsx b/app/[lng]/engineering/(engineering)/report/page.tsx index 3efaa7c3..eb932e0f 100644 --- a/app/[lng]/engineering/(engineering)/report/page.tsx +++ b/app/[lng]/engineering/(engineering)/report/page.tsx @@ -1,47 +1,129 @@ -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" +// app/procurement/dashboard/page.tsx +import * as React from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Shell } from "@/components/shell"; +import { ErrorBoundary } from "@/components/error-boundary"; +import { getDashboardData } from "@/lib/dashboard/service"; +import { DashboardClient } from "@/lib/dashboard/dashboard-client"; +// 대시보드 데이터 로딩 컴포넌트 +async function DashboardContent() { + try { + const data = await getDashboardData("engineering"); + + const handleRefresh = async () => { + "use server"; + return await getDashboardData("engineering"); + }; -export default async function IndexPage() { - + return ( + + ); + } catch (error) { + console.error("Dashboard data loading error:", error); + throw error; + } +} +// 대시보드 로딩 스켈레톤 +function DashboardSkeleton() { return ( - +
+ {/* 헤더 스켈레톤 */}
-
-

- Dashboard -

-

- 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다. -

+
+ + +
+ +
+ + {/* 요약 카드 스켈레톤 */} +
+ {[...Array(4)].map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ + {/* 차트 스켈레톤 */} +
+ {[...Array(2)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ + {/* 탭 스켈레톤 */} +
+ +
+ {[...Array(6)].map((_, i) => ( +
+ +
+
+ + +
+
+ + + +
+ +
+
+ ))}
+
+ ); +} - }> - {/* */} - - - - } +// 에러 표시 컴포넌트 +function DashboardError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+
+

대시보드를 불러올 수 없습니다

+

+ {error.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+ ); +} + +export default async function DashboardPage() { + return ( + + + }> + + + - ) -} \ No newline at end of file + ); +} diff --git a/app/[lng]/evcp/(evcp)/report/page.tsx b/app/[lng]/evcp/(evcp)/report/page.tsx index eeb6a91d..95566b05 100644 --- a/app/[lng]/evcp/(evcp)/report/page.tsx +++ b/app/[lng]/evcp/(evcp)/report/page.tsx @@ -10,11 +10,11 @@ import { DashboardClient } from "@/lib/dashboard/dashboard-client"; // 대시보드 데이터 로딩 컴포넌트 async function DashboardContent() { try { - const data = await getDashboardData("procurement"); + const data = await getDashboardData("evcp"); const handleRefresh = async () => { "use server"; - return await getDashboardData("procurement"); + return await getDashboardData("evcp"); }; return ( diff --git a/app/[lng]/page.tsx b/app/[lng]/page.tsx new file mode 100644 index 00000000..2ee83857 --- /dev/null +++ b/app/[lng]/page.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ShoppingCart, Users, Settings, ArrowRight, Building2 } from 'lucide-react'; + +export default function LandingPage() { + const portals = [ + { + id: 'sales', + title: '기술영업포탈', + description: '기술 영업 단계에서의 RFQ를 관리할 수 있는 통합 플랫폼', + icon: Users, + color: 'from-emerald-500 to-teal-500', + href: '/sales', + features: ['벤더 관리', '기술 영업 RFQ'] + }, + { + id: 'purchase', + title: '구매포탈', + description: '협력업체에서부터 마지막 발주까지 원스톱 구매 솔루션', + icon: ShoppingCart, + color: 'from-blue-500 to-cyan-500', + href: '/procurement', + features: ['협력업체 관리', '구매관리'] + }, + + { + id: 'design', + title: '설계포탈', + description: '벤더가 플랫폼을 통해 데이터와 문서를 제출할 수 있게 하고 TBE를 처리할 수 있는 플랫폼', + icon: Settings, + color: 'from-purple-500 to-pink-500', + href: '/engineering', + features: ['설계 기준정보관리', 'TBE'] + } + ]; + + + + return ( +
+ {/* Header */} +
+
+
+
+ +

+ enterprise Vendor Co-work Platform +

+
+

+ 통합된 비즈니스 솔루션으로 구매부터 설계까지, +
모든 업무 프로세스를 하나의 플랫폼에서 관리하세요 +

+ + Enterprise Ready + +
+
+ + {/* Main Portal Selection */} +
+
+

+ 포털을 선택하세요 +

+

+ 각 포털은 특화된 기능과 도구를 제공하여 업무 효율성을 극대화합니다 +

+
+ +
+ {portals.map((portal) => { + const Icon = portal.icon; + return ( + + +
+ + +
+ +
+ + {portal.title} + + + {portal.description} + +
+ + +
+

주요 기능

+
+ {portal.features.map((feature, idx) => ( +
+
+ {feature} +
+ ))} +
+
+ + +
+
+ + ); + })} +
+ + {/* Additional Info Section */} +
+
+

+ 모든 포털이 연동됩니다 +

+

+ 구매, 영업, 설계 포털 간의 데이터가 실시간으로 동기화되어 + 효율적인 업무 협업이 가능합니다 +

+
+ 실시간 동기화 + 통합 대시보드 + 권한 관리 + 보안 인증 +
+
+
+
+ + {/* Footer */} +
+
+
+ + enterprise Vendor Co-work Platform +
+

+ © 2025 삼성중공업. All rights reserved. +

+ {/*
+ 이용약관 + 개인정보처리방침 + 고객지원 +
*/} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/report/page.tsx b/app/[lng]/procurement/(procurement)/report/page.tsx index 3efaa7c3..800fbd8b 100644 --- a/app/[lng]/procurement/(procurement)/report/page.tsx +++ b/app/[lng]/procurement/(procurement)/report/page.tsx @@ -1,47 +1,129 @@ -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" +// app/procurement/dashboard/page.tsx +import * as React from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Shell } from "@/components/shell"; +import { ErrorBoundary } from "@/components/error-boundary"; +import { getDashboardData } from "@/lib/dashboard/service"; +import { DashboardClient } from "@/lib/dashboard/dashboard-client"; +// 대시보드 데이터 로딩 컴포넌트 +async function DashboardContent() { + try { + const data = await getDashboardData("procurement"); + + const handleRefresh = async () => { + "use server"; + return await getDashboardData("procurement"); + }; -export default async function IndexPage() { - + return ( + + ); + } catch (error) { + console.error("Dashboard data loading error:", error); + throw error; + } +} +// 대시보드 로딩 스켈레톤 +function DashboardSkeleton() { return ( - +
+ {/* 헤더 스켈레톤 */}
-
-

- Dashboard -

-

- 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다. -

+
+ + +
+ +
+ + {/* 요약 카드 스켈레톤 */} +
+ {[...Array(4)].map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ + {/* 차트 스켈레톤 */} +
+ {[...Array(2)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ + {/* 탭 스켈레톤 */} +
+ +
+ {[...Array(6)].map((_, i) => ( +
+ +
+
+ + +
+
+ + + +
+ +
+
+ ))}
+
+ ); +} - }> - {/* */} - - - - } +// 에러 표시 컴포넌트 +function DashboardError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+
+

대시보드를 불러올 수 없습니다

+

+ {error.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+ ); +} + +export default async function DashboardPage() { + return ( + + + }> + + + - ) -} \ No newline at end of file + ); +} diff --git a/app/[lng]/sales/(sales)/report/page.tsx b/app/[lng]/sales/(sales)/report/page.tsx index 3efaa7c3..33225e33 100644 --- a/app/[lng]/sales/(sales)/report/page.tsx +++ b/app/[lng]/sales/(sales)/report/page.tsx @@ -1,47 +1,129 @@ -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" +// app/procurement/dashboard/page.tsx +import * as React from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Shell } from "@/components/shell"; +import { ErrorBoundary } from "@/components/error-boundary"; +import { getDashboardData } from "@/lib/dashboard/service"; +import { DashboardClient } from "@/lib/dashboard/dashboard-client"; +// 대시보드 데이터 로딩 컴포넌트 +async function DashboardContent() { + try { + const data = await getDashboardData("sales"); + + const handleRefresh = async () => { + "use server"; + return await getDashboardData("sales"); + }; -export default async function IndexPage() { - + return ( + + ); + } catch (error) { + console.error("Dashboard data loading error:", error); + throw error; + } +} +// 대시보드 로딩 스켈레톤 +function DashboardSkeleton() { return ( - +
+ {/* 헤더 스켈레톤 */}
-
-

- Dashboard -

-

- 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다. -

+
+ + +
+ +
+ + {/* 요약 카드 스켈레톤 */} +
+ {[...Array(4)].map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ + {/* 차트 스켈레톤 */} +
+ {[...Array(2)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ + {/* 탭 스켈레톤 */} +
+ +
+ {[...Array(6)].map((_, i) => ( +
+ +
+
+ + +
+
+ + + +
+ +
+
+ ))}
+
+ ); +} - }> - {/* */} - - - - } +// 에러 표시 컴포넌트 +function DashboardError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+
+

대시보드를 불러올 수 없습니다

+

+ {error.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+ ); +} + +export default async function DashboardPage() { + return ( + + + }> + + + - ) -} \ No newline at end of file + ); +} diff --git a/app/api/richtext/route.ts b/app/api/richtext/route.ts new file mode 100644 index 00000000..3d050dc5 --- /dev/null +++ b/app/api/richtext/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get('file') as File; + + console.log(file) + + if (!file) { + return NextResponse.json({ error: '파일이 없습니다.' }, { status: 400 }); + } + + // 파일 확장자 검증 + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json({ error: '지원하지 않는 파일 형식입니다.' }, { status: 400 }); + } + + // 파일 크기 제한 (5MB) + if (file.size > 5 * 1024 * 1024) { + return NextResponse.json({ error: '파일 크기는 5MB 이하여야 합니다.' }, { status: 400 }); + } + + // 업로드 디렉토리 생성 + const uploadDir = join(process.cwd(), 'public', 'uploads', 'richtext'); + await mkdir(uploadDir, { recursive: true }); + + // 고유한 파일명 생성 + const fileExtension = file.name.split('.').pop(); + const fileName = `${randomUUID()}.${fileExtension}`; + const filePath = join(uploadDir, fileName); + + // 파일 저장 + const arrayBuffer = await file.arrayBuffer(); + await writeFile(filePath, new Uint8Array(arrayBuffer)); + + // 공개 URL 반환 + const fileUrl = `/uploads/richtext/${fileName}`; + + return NextResponse.json({ + success: true, + url: fileUrl, + message: '이미지가 성공적으로 업로드되었습니다.' + }); + + } catch (error) { + console.error('파일 업로드 실패:', error); + return NextResponse.json({ error: '파일 업로드에 실패했습니다.' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/vendor-responses/update-comment/route.ts b/app/api/vendor-responses/update-comment/route.ts index 212173d7..f1e4c487 100644 --- a/app/api/vendor-responses/update-comment/route.ts +++ b/app/api/vendor-responses/update-comment/route.ts @@ -5,6 +5,7 @@ import { vendorAttachmentResponses } from "@/db/schema"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { eq } from "drizzle-orm"; export async function POST(request: NextRequest) { try { @@ -34,6 +35,7 @@ export async function POST(request: NextRequest) { responseComment, vendorComment, updatedAt: new Date(), + updatedBy:Number(session?.user.id) }) .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) .returning(); diff --git a/app/api/vendor-responses/update/route.ts b/app/api/vendor-responses/update/route.ts index 8771b062..5ee31d4d 100644 --- a/app/api/vendor-responses/update/route.ts +++ b/app/api/vendor-responses/update/route.ts @@ -94,6 +94,7 @@ export async function POST(request: NextRequest) { vendorComment, respondedAt: respondedAt ? new Date(respondedAt) : null, updatedAt: new Date(), + updatedBy:Number(session?.user.id) }) .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) .returning(); diff --git a/components/error-boundary.tsx b/components/error-boundary.tsx new file mode 100644 index 00000000..41334eb7 --- /dev/null +++ b/components/error-boundary.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; + +interface ErrorBoundaryProps { + children: React.ReactNode; + fallback: React.ComponentType<{ error: Error; reset: () => void }>; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Dashboard error boundary caught an error:", error, errorInfo); + } + + render() { + if (this.state.hasError && this.state.error) { + const Fallback = this.props.fallback; + return ( + this.setState({ hasError: false, error: null })} + /> + ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/components/layout/SessionManager.tsx b/components/layout/SessionManager.tsx index c917c5f3..0aca82fb 100644 --- a/components/layout/SessionManager.tsx +++ b/components/layout/SessionManager.tsx @@ -96,7 +96,7 @@ export function SessionManager({ lng }: SessionManagerProps) { const handleAutoLogout = useCallback(() => { setShowExpiredModal(false) setShowWarning(false) - window.location.href = `/${lng}/evcp?reason=expired` + window.location.href = `/${lng}/${session?.user.domain}?reason=expired` }, [lng]) // 세션 만료 체크 diff --git a/components/qna/comment-section.tsx b/components/qna/comment-section.tsx new file mode 100644 index 00000000..2ea358e2 --- /dev/null +++ b/components/qna/comment-section.tsx @@ -0,0 +1,166 @@ +import * as React from "react"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { format } from "date-fns"; +import { Comment } from "@/lib/qna/types"; +import { Trash2, Pencil, Check, X } from "lucide-react"; + +interface CommentSectionProps { + answerId: string; + comments: Comment[]; + onAddComment: (content: string) => Promise; + onDeleteComment: (commentId: string) => Promise; + onUpdateComment?: (commentId: string, content: string) => Promise; +} + +export function CommentSection({ answerId, comments, onAddComment, onDeleteComment, onUpdateComment }: CommentSectionProps) { + const { data: session } = useSession(); + const [content, setContent] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [editingId, setEditingId] = React.useState(null); + const [editContent, setEditContent] = React.useState(""); + + const handleSubmit = async () => { + if (!content.trim() || !session?.user?.name) return; + setIsSubmitting(true); + try { + await onAddComment(content); + setContent(""); + } finally { + setIsSubmitting(false); + } + }; + + const handleEditStart = (comment: Comment) => { + setEditingId(comment.id); + setEditContent(comment.content); + }; + + const handleEditCancel = () => { + setEditingId(null); + setEditContent(""); + }; + + const handleEditSave = async (commentId: string) => { + if (!editContent.trim() || !onUpdateComment) return; + try { + await onUpdateComment(commentId, editContent); + setEditingId(null); + } catch (error) { + console.error("댓글 수정 실패:", error); + } + }; + + return ( +
+
+

댓글

+ {comments.length > 0 && ( + + {comments.length} + + )} +
+ + {/* 댓글 목록 */} +
+ {comments.map((comment) => ( +
+
+
+ {comment.author} + + {format(new Date(comment.createdAt), "yyyy.MM.dd HH:mm")} + +
+ {editingId === comment.id ? ( +