diff options
5 files changed, 598 insertions, 25 deletions
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/[id]/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/[id]/page.tsx new file mode 100644 index 00000000..21bb05ca --- /dev/null +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/[id]/page.tsx @@ -0,0 +1,126 @@ +// app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/[id]/page.tsx - 기술영업 해양 HULL RFQ 견적 응답 페이지
+import { Metadata } from "next"
+import { notFound } from "next/navigation"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { BackButton } from "@/components/ui/back-button"
+import { getTechSalesVendorQuotation } from "@/lib/techsales-rfq/service"
+import { formatDate } from "@/lib/utils"
+import { TechSalesQuotationTabs } from "@/lib/techsales-rfq/vendor-response/detail/quotation-tabs"
+
+interface TechSalesVendorQuotationDetailPageProps {
+ params: Promise<{
+ id: string
+ lng: string
+ }>
+ searchParams: Promise<{
+ tab?: string
+ }>
+}
+
+export async function generateMetadata(): Promise<Metadata> {
+ return {
+ title: "기술영업 해양 HULL RFQ 견적서 상세",
+ description: "기술영업 해양 HULL RFQ 견적서 상세 정보 및 응답",
+ }
+}
+
+export default async function TechSalesVendorQuotationDetailPage({
+ params,
+ searchParams,
+}: TechSalesVendorQuotationDetailPageProps) {
+ const { id } = await params
+ const { tab } = await searchParams
+ const quotationId = parseInt(id)
+
+ if (isNaN(quotationId)) {
+ notFound()
+ }
+
+ // 견적서 데이터 조회
+ const { data: quotation, error } = await getTechSalesVendorQuotation(quotationId)
+
+ if (error || !quotation) {
+ notFound()
+ }
+
+ const rfq = quotation.rfq
+ const vendor = quotation.vendor
+
+ // 상태별 배지 색상
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <Shell variant="sidebar" className="gap-4">
+ {/* 헤더 */}
+ <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <h1 className="text-2xl font-bold">해양 HULL 견적서 상세</h1>
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>RFQ: {rfq?.rfqCode || "미할당"}</span>
+ <span>•</span>
+ <span>벤더: {vendor?.vendorName}</span>
+ <span>•</span>
+ <span>마감일: {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}</span>
+ </div>
+ </div>
+
+ {/* 우측 상단 버튼 */}
+ <div className="flex items-center justify-end">
+ <BackButton segmentsToRemove={1}>
+ 목록으로 돌아가기
+ </BackButton>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 탭 컨텐츠 */}
+ <div className="flex-1 min-h-0">
+ <TechSalesQuotationTabs
+ quotation={quotation}
+ defaultTab={tab || "project"}
+ />
+ </div>
+ </Shell>
+ )
+}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx index 40be6773..1c830535 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx @@ -1,17 +1,176 @@ +import * as React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { LogIn } from "lucide-react"; import { Shell } from "@/components/shell"; +import { + TECH_SALES_QUOTATION_STATUSES, + TECH_SALES_QUOTATION_STATUS_CONFIG +} from "@/db/schema"; + +import { getQuotationStatusCounts } from "@/lib/techsales-rfq/service"; +import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; + +export const metadata: Metadata = { + title: "기술영업 해양HULL 견적서 관리", + description: "기술영업 해양HULL RFQ 견적서를 관리합니다.", +}; + +export default async function VendorQuotationsHullPage() { + // 세션 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return ( + <Shell> + <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4"> + <div className="text-center"> + <h2 className="text-2xl font-bold tracking-tight">로그인이 필요합니다</h2> + <p className="text-muted-foreground"> + 견적서를 확인하려면 로그인해주세요. + </p> + </div> + <Button asChild> + <Link href="/api/auth/signin"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인 + </Link> + </Button> + </div> + </Shell> + ); + } + + // 벤더 ID 확인 (사용자의 회사 ID가 벤더 ID) + const vendorId = session.user.techCompanyId; + if (!vendorId) { + return ( + <Shell> + <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4"> + <div className="text-center"> + <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 정보가 없습니다</h2> + <p className="text-muted-foreground"> + 기술영업 벤더 정보가 없습니다. 관리자에게 문의하세요. + </p> + </div> + </div> + </Shell> + ); + } + + // 견적서 상태별 개수 조회 + const statusCountsPromise = getQuotationStatusCounts(vendorId.toString(), "HULL"); -export default function TechSalesRfqShipPage() { return ( - <Shell className="gap-6"> - <div> - <h1 className="text-2xl font-bold">기술영업 - 해양 Hull/Top RFQ</h1> - <p className="text-muted-foreground"> - 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다. - </p> - <p className="text-muted-foreground"> - 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다. - </p> + <Shell variant="fullscreen" className="h-full"> + {/* 고정 헤더 영역 */} + <div className="flex-shrink-0"> + <div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> + <div> + <h1 className="text-3xl font-bold tracking-tight">기술영업 해양HULL 견적서</h1> + <p className="text-muted-foreground"> + 할당받은 해양HULL RFQ에 대한 견적서를 작성하고 관리합니다. + </p> + </div> + </div> + + {/* 상태별 개수 카드 */} + <div className="flex-shrink-0"> + <React.Suspense + fallback={ + <div className="w-full overflow-x-auto"> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> + {Array.from({ length: 5 }).map((_, i) => ( + <Card key={i} className="min-w-[160px]"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">-</div> + </CardContent> + </Card> + ))} + </div> + </div> + } + > + <StatusCards statusCountsPromise={statusCountsPromise} /> + </React.Suspense> + </div> + + {/* 견적서 테이블 */} + <div className="flex-1 min-h-0 overflow-hidden"> + <div className="h-full overflow-auto"> + <VendorQuotationsTable vendorId={vendorId.toString()} rfqType="HULL" /> + </div> + </div> </div> </Shell> ); +} + +// 상태별 개수 카드 컴포넌트 +async function StatusCards({ + statusCountsPromise, +}: { + statusCountsPromise: Promise<{ + data: { status: string; count: number }[] | null; + error: string | null; + }>; +}) { + const { data: statusCounts, error } = await statusCountsPromise; + + if (error || !statusCounts) { + return ( + <div className="w-full overflow-x-auto"> + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit"> + <Card className="min-w-[160px]"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium truncate">오류</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">-</div> + <p className="text-xs text-muted-foreground truncate"> + 데이터를 불러올 수 없습니다 + </p> + </CardContent> + </Card> + </div> + </div> + ); + } + + // 중앙화된 상태 설정 사용 + const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ + key: statusValue, + ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue] + })); + + console.log(statusCounts, "statusCounts") + + return ( + <div className="w-full overflow-x-auto"> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> + {statusEntries.map((status) => ( + <Card key={status.key} className="min-w-[160px]"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle> + </CardHeader> + <CardContent> + <div className={`text-2xl font-bold ${status.color}`}> + {statusCounts.find(item => item.status === status.key)?.count || 0} + </div> + <p className="text-xs text-muted-foreground truncate"> + {status.description} + </p> + </CardContent> + </Card> + ))} + </div> + </div> + ); }
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/[id]/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/[id]/page.tsx new file mode 100644 index 00000000..df40558a --- /dev/null +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/[id]/page.tsx @@ -0,0 +1,126 @@ +// app/[lng]/partners/(partners)/techsales/rfq-offshore-top/[id]/page.tsx - 기술영업 해양 TOP RFQ 견적 응답 페이지
+import { Metadata } from "next"
+import { notFound } from "next/navigation"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { BackButton } from "@/components/ui/back-button"
+import { getTechSalesVendorQuotation } from "@/lib/techsales-rfq/service"
+import { formatDate } from "@/lib/utils"
+import { TechSalesQuotationTabs } from "@/lib/techsales-rfq/vendor-response/detail/quotation-tabs"
+
+interface TechSalesVendorQuotationDetailPageProps {
+ params: Promise<{
+ id: string
+ lng: string
+ }>
+ searchParams: Promise<{
+ tab?: string
+ }>
+}
+
+export async function generateMetadata(): Promise<Metadata> {
+ return {
+ title: "기술영업 해양 TOP RFQ 견적서 상세",
+ description: "기술영업 해양 TOP RFQ 견적서 상세 정보 및 응답",
+ }
+}
+
+export default async function TechSalesVendorQuotationDetailPage({
+ params,
+ searchParams,
+}: TechSalesVendorQuotationDetailPageProps) {
+ const { id } = await params
+ const { tab } = await searchParams
+ const quotationId = parseInt(id)
+
+ if (isNaN(quotationId)) {
+ notFound()
+ }
+
+ // 견적서 데이터 조회
+ const { data: quotation, error } = await getTechSalesVendorQuotation(quotationId)
+
+ if (error || !quotation) {
+ notFound()
+ }
+
+ const rfq = quotation.rfq
+ const vendor = quotation.vendor
+
+ // 상태별 배지 색상
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <Shell variant="sidebar" className="gap-4">
+ {/* 헤더 */}
+ <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <h1 className="text-2xl font-bold">해양 TOP 견적서 상세</h1>
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>RFQ: {rfq?.rfqCode || "미할당"}</span>
+ <span>•</span>
+ <span>벤더: {vendor?.vendorName}</span>
+ <span>•</span>
+ <span>마감일: {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}</span>
+ </div>
+ </div>
+
+ {/* 우측 상단 버튼 */}
+ <div className="flex items-center justify-end">
+ <BackButton segmentsToRemove={1}>
+ 목록으로 돌아가기
+ </BackButton>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 탭 컨텐츠 */}
+ <div className="flex-1 min-h-0">
+ <TechSalesQuotationTabs
+ quotation={quotation}
+ defaultTab={tab || "project"}
+ />
+ </div>
+ </Shell>
+ )
+}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx index 40be6773..b9c957f0 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx @@ -1,17 +1,176 @@ +import * as React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { LogIn } from "lucide-react"; import { Shell } from "@/components/shell"; +import { + TECH_SALES_QUOTATION_STATUSES, + TECH_SALES_QUOTATION_STATUS_CONFIG +} from "@/db/schema"; + +import { getQuotationStatusCounts } from "@/lib/techsales-rfq/service"; +import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; + +export const metadata: Metadata = { + title: "기술영업 해양TOP 견적서 관리", + description: "기술영업 해양TOP RFQ 견적서를 관리합니다.", +}; + +export default async function VendorQuotationsTopPage() { + // 세션 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return ( + <Shell> + <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4"> + <div className="text-center"> + <h2 className="text-2xl font-bold tracking-tight">로그인이 필요합니다</h2> + <p className="text-muted-foreground"> + 견적서를 확인하려면 로그인해주세요. + </p> + </div> + <Button asChild> + <Link href="/api/auth/signin"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인 + </Link> + </Button> + </div> + </Shell> + ); + } + + // 벤더 ID 확인 (사용자의 회사 ID가 벤더 ID) + const vendorId = session.user.techCompanyId; + if (!vendorId) { + return ( + <Shell> + <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4"> + <div className="text-center"> + <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 정보가 없습니다</h2> + <p className="text-muted-foreground"> + 기술영업 벤더 정보가 없습니다. 관리자에게 문의하세요. + </p> + </div> + </div> + </Shell> + ); + } + + // 견적서 상태별 개수 조회 + const statusCountsPromise = getQuotationStatusCounts(vendorId.toString(), "TOP"); -export default function TechSalesRfqShipPage() { return ( - <Shell className="gap-6"> - <div> - <h1 className="text-2xl font-bold">기술영업 - 해양 Hull/Top RFQ</h1> - <p className="text-muted-foreground"> - 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다. - </p> - <p className="text-muted-foreground"> - 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다. - </p> + <Shell variant="fullscreen" className="h-full"> + {/* 고정 헤더 영역 */} + <div className="flex-shrink-0"> + <div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> + <div> + <h1 className="text-3xl font-bold tracking-tight">기술영업 해양TOP 견적서</h1> + <p className="text-muted-foreground"> + 할당받은 해양TOP RFQ에 대한 견적서를 작성하고 관리합니다. + </p> + </div> + </div> + + {/* 상태별 개수 카드 */} + <div className="flex-shrink-0"> + <React.Suspense + fallback={ + <div className="w-full overflow-x-auto"> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> + {Array.from({ length: 5 }).map((_, i) => ( + <Card key={i} className="min-w-[160px]"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">-</div> + </CardContent> + </Card> + ))} + </div> + </div> + } + > + <StatusCards statusCountsPromise={statusCountsPromise} /> + </React.Suspense> + </div> + + {/* 견적서 테이블 */} + <div className="flex-1 min-h-0 overflow-hidden"> + <div className="h-full overflow-auto"> + <VendorQuotationsTable vendorId={vendorId.toString()} rfqType="TOP" /> + </div> + </div> </div> </Shell> ); +} + +// 상태별 개수 카드 컴포넌트 +async function StatusCards({ + statusCountsPromise, +}: { + statusCountsPromise: Promise<{ + data: { status: string; count: number }[] | null; + error: string | null; + }>; +}) { + const { data: statusCounts, error } = await statusCountsPromise; + + if (error || !statusCounts) { + return ( + <div className="w-full overflow-x-auto"> + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit"> + <Card className="min-w-[160px]"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium truncate">오류</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">-</div> + <p className="text-xs text-muted-foreground truncate"> + 데이터를 불러올 수 없습니다 + </p> + </CardContent> + </Card> + </div> + </div> + ); + } + + // 중앙화된 상태 설정 사용 + const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ + key: statusValue, + ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue] + })); + + console.log(statusCounts, "statusCounts") + + return ( + <div className="w-full overflow-x-auto"> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit"> + {statusEntries.map((status) => ( + <Card key={status.key} className="min-w-[160px]"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle> + </CardHeader> + <CardContent> + <div className={`text-2xl font-bold ${status.color}`}> + {statusCounts.find(item => item.status === status.key)?.count || 0} + </div> + <p className="text-xs text-muted-foreground truncate"> + {status.description} + </p> + </CardContent> + </Card> + ))} + </div> + </div> + ); }
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx index 12ff9a81..07797c9b 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx @@ -24,6 +24,9 @@ export const metadata: Metadata = { export default async function VendorQuotationsPage() { // 세션 확인 const session = await getServerSession(authOptions); + console.log(session, "session") + console.log(session?.user, "session?.user") + console.log(session?.user.techCompanyId, "session?.user.techCompanyId") if (!session?.user) { return ( @@ -47,15 +50,15 @@ export default async function VendorQuotationsPage() { } // 벤더 ID 확인 (사용자의 회사 ID가 벤더 ID) - const vendorId = session.user.companyId; + const vendorId = session.user.techCompanyId; if (!vendorId) { return ( <Shell> <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4"> <div className="text-center"> - <h2 className="text-2xl font-bold tracking-tight">회사 정보가 없습니다</h2> + <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 정보가 없습니다</h2> <p className="text-muted-foreground"> - 견적서를 확인하려면 회사 정보가 필요합니다. + 기술영업 벤더 정보가 없습니다. 관리자에게 문의하세요. </p> </div> </div> @@ -64,7 +67,7 @@ export default async function VendorQuotationsPage() { } // 견적서 상태별 개수 조회 - const statusCountsPromise = getQuotationStatusCounts(vendorId.toString()); + const statusCountsPromise = getQuotationStatusCounts(vendorId.toString(), "SHIP"); return ( <Shell variant="fullscreen" className="h-full"> @@ -106,7 +109,7 @@ export default async function VendorQuotationsPage() { {/* 견적서 테이블 */} <div className="flex-1 min-h-0 overflow-hidden"> <div className="h-full overflow-auto"> - <VendorQuotationsTable vendorId={vendorId.toString()} /> + <VendorQuotationsTable vendorId={vendorId.toString()} rfqType="SHIP" /> </div> </div> </div> |
