summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx143
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx56
-rw-r--r--app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx249
-rw-r--r--app/[lng]/partners/(partners)/po/[id]/page.tsx55
-rw-r--r--app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PO_INFORMATION/route.ts10
-rw-r--r--components/contract/contract-info-card.tsx104
-rw-r--r--components/contract/contract-items-card.tsx76
-rw-r--r--db/schema/contract.ts214
-rw-r--r--lib/po/vendor-table/service.ts573
-rw-r--r--lib/po/vendor-table/shi-vendor-po-columns.tsx59
-rw-r--r--lib/po/vendor-table/types.ts7
-rw-r--r--lib/po/vendor-table/vendor-po-actions.tsx273
-rw-r--r--lib/po/vendor-table/vendor-po-columns.tsx158
-rw-r--r--lib/po/vendor-table/vendor-po-items-dialog.tsx13
-rw-r--r--lib/po/vendor-table/vendor-po-note-dialog.tsx162
-rw-r--r--lib/po/vendor-table/vendor-po-table.tsx20
-rw-r--r--lib/po/vendor-table/vendor-po-toolbar-actions.tsx61
-rw-r--r--lib/soap/ecc/mapper/po-mapper.ts22
19 files changed, 1839 insertions, 418 deletions
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx
index c3d429d1..c9e6b0e3 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx
@@ -36,7 +36,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 프로젝트 리스트 from S-EDP
+ 프로젝트 리스트
</h2>
<InformationButton pagePath="evcp/projects" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx
new file mode 100644
index 00000000..28a85e50
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { ChevronLeft } from "lucide-react"
+import Link from "next/link"
+import { toast } from "sonner"
+import { saveSHIComment } from "@/lib/po/vendor-table/service"
+import { useRouter } from "next/navigation"
+import { ContractInfoCard } from "@/components/contract/contract-info-card"
+import { ContractItemsCard } from "@/components/contract/contract-items-card"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { AlertCircle } from "lucide-react"
+
+interface ContractDetailClientProps {
+ contract: any
+ lng: string
+}
+
+export function ContractDetailClient({ contract, lng }: ContractDetailClientProps) {
+ const router = useRouter()
+ const [shiComment, setSHIComment] = React.useState(contract.shiComment || "")
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const handleSaveComment = async () => {
+ try {
+ setIsLoading(true)
+ const result = await saveSHIComment(contract.id, shiComment)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ } else {
+ toast.error("의견 저장에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("의견 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <>
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <Link href={`/${lng}/evcp/po`}>
+ <Button variant="ghost" size="icon">
+ <ChevronLeft className="h-5 w-5" />
+ </Button>
+ </Link>
+ <div className="flex items-center gap-3">
+ <h1 className="text-2xl font-bold tracking-tight">계약 상세</h1>
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">계약번호:</span>
+ <span className="text-sm font-medium">{contract.contractNo}</span>
+ <Badge variant="outline" className="ml-2">
+ {contract.status}
+ </Badge>
+ </div>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="default"
+ onClick={handleSaveComment}
+ disabled={isLoading}
+ >
+ 의견저장
+ </Button>
+ </div>
+ </div>
+
+ {/* 계약 거절 사유 표시 (있는 경우) */}
+ {contract.rejectionReason && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ <strong>계약 거절 사유:</strong> {contract.rejectionReason}
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 계약 조건 */}
+ <ContractInfoCard contract={contract} />
+
+ {/* 코멘트 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">코멘트</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <label htmlFor="vendor-comment" className="text-sm font-medium text-muted-foreground">
+ Vendor Comment
+ </label>
+ <div className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md bg-muted/50">
+ {contract.vendorComment || "코멘트가 없습니다."}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <label htmlFor="shi-comment" className="text-sm font-medium">
+ SHI Comment
+ </label>
+ <textarea
+ id="shi-comment"
+ className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring"
+ placeholder="SHI 코멘트를 입력하세요..."
+ value={shiComment}
+ onChange={(e) => setSHIComment(e.target.value)}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 계약문서 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">계약문서</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {contract.contractContent ? (
+ <div className="prose prose-sm max-w-none">
+ <pre className="whitespace-pre-wrap text-sm bg-muted/50 p-4 rounded-md">
+ {contract.contractContent}
+ </pre>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">계약문서 내용이 없습니다.</p>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 계약 품목 */}
+ <ContractItemsCard items={contract.items || []} currency={contract.currency} />
+ </>
+ )
+}
+
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx
new file mode 100644
index 00000000..226d960d
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { redirect } from "next/navigation"
+import { getContractDetail } from "@/lib/po/vendor-table/service"
+import { Shell } from "@/components/shell"
+import { ContractDetailClient } from "./contract-detail-client"
+
+interface ContractDetailPageProps {
+ params: Promise<{
+ id: string
+ lng: string
+ }>
+}
+
+export default async function EVCPContractDetailPage(props: ContractDetailPageProps) {
+ const params = await props.params
+ const contractId = parseInt(params.id, 10)
+
+ // 유효하지 않은 ID 체크
+ if (isNaN(contractId) || contractId <= 0) {
+ return (
+ <Shell className="gap-4">
+ <div className="flex h-full items-center justify-center p-6">
+ <p className="text-muted-foreground">유효하지 않은 계약 ID입니다.</p>
+ </div>
+ </Shell>
+ )
+ }
+
+ // 세션 체크 (EVCP 사용자만 접근 가능)
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ redirect("/")
+ }
+
+ // 계약 상세 정보 조회
+ const result = await getContractDetail(contractId)
+
+ if (!result.success || !result.data) {
+ return (
+ <Shell className="gap-4">
+ <div className="flex h-full items-center justify-center p-6">
+ <p className="text-muted-foreground">{result.error || "계약 정보를 찾을 수 없습니다."}</p>
+ </div>
+ </Shell>
+ )
+ }
+
+ return (
+ <Shell className="gap-4">
+ <ContractDetailClient contract={result.data} lng={params.lng} />
+ </Shell>
+ )
+}
+
diff --git a/app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx b/app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx
new file mode 100644
index 00000000..a6f7a729
--- /dev/null
+++ b/app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx
@@ -0,0 +1,249 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { ChevronLeft } from "lucide-react"
+import Link from "next/link"
+import { toast } from "sonner"
+import { saveVendorComment, acceptContract, rejectContract } from "@/lib/po/vendor-table/service"
+import { useRouter } from "next/navigation"
+import { ContractInfoCard } from "@/components/contract/contract-info-card"
+import { ContractItemsCard } from "@/components/contract/contract-items-card"
+
+interface ContractDetailClientProps {
+ contract: any
+ lng: string
+}
+
+export function ContractDetailClient({ contract, lng }: ContractDetailClientProps) {
+ const router = useRouter()
+ const [vendorComment, setVendorComment] = React.useState(contract.vendorComment || "")
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isRejectDialogOpen, setIsRejectDialogOpen] = React.useState(false)
+ const [rejectReason, setRejectReason] = React.useState("")
+
+ const handleSaveComment = async () => {
+ try {
+ setIsLoading(true)
+ const result = await saveVendorComment(contract.id, vendorComment)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ } else {
+ toast.error("의견 저장에 실패했습니다.")
+ }
+ } catch {
+ toast.error("의견 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleApprove = async () => {
+ try {
+ setIsLoading(true)
+ const result = await acceptContract(contract.id)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ } else {
+ toast.error("계약 승인에 실패했습니다.")
+ }
+ } catch {
+ toast.error("계약 승인 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleRejectClick = () => {
+ setIsRejectDialogOpen(true)
+ }
+
+ const handleRejectConfirm = async () => {
+ if (!rejectReason.trim()) {
+ toast.error("계약 반려 사유를 입력해주세요.")
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ const result = await rejectContract(contract.id, rejectReason)
+ if (result.success) {
+ toast.success(result.message)
+ setIsRejectDialogOpen(false)
+ setRejectReason("")
+ router.refresh()
+ } else {
+ toast.error("계약 거절에 실패했습니다.")
+ }
+ } catch {
+ toast.error("계약 거절 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleRejectCancel = () => {
+ setIsRejectDialogOpen(false)
+ setRejectReason("")
+ }
+
+ return (
+ <>
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <Link href={`/${lng}/partners/po`}>
+ <Button variant="ghost" size="icon">
+ <ChevronLeft className="h-5 w-5" />
+ </Button>
+ </Link>
+ <div className="flex items-center gap-3">
+ <h1 className="text-2xl font-bold tracking-tight">계약 상세</h1>
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">계약번호:</span>
+ <span className="text-sm font-medium">{contract.contractNo}</span>
+ <Badge variant="outline" className="ml-2">
+ {contract.status}
+ </Badge>
+ </div>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={handleSaveComment}
+ disabled={isLoading}
+ >
+ 의견저장
+ </Button>
+ <Button
+ variant="default"
+ onClick={handleApprove}
+ disabled={isLoading}
+ >
+ 계약승인
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleRejectClick}
+ disabled={isLoading}
+ >
+ 계약반려
+ </Button>
+ </div>
+ </div>
+
+ {/* 계약 조건 */}
+ <ContractInfoCard contract={contract} />
+
+ {/* 코멘트 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">코멘트</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <label htmlFor="vendor-comment" className="text-sm font-medium">
+ Vendor Comment
+ </label>
+ <textarea
+ id="vendor-comment"
+ className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring"
+ placeholder="벤더 코멘트를 입력하세요..."
+ value={vendorComment}
+ onChange={(e) => setVendorComment(e.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <label htmlFor="shi-comment" className="text-sm font-medium text-muted-foreground">
+ SHI Comment
+ </label>
+ <div className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md bg-muted/50">
+ {contract.shiComment || "코멘트가 없습니다."}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 계약문서 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">계약문서</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {contract.contractContent ? (
+ <div className="prose prose-sm max-w-none">
+ <pre className="whitespace-pre-wrap text-sm bg-muted/50 p-4 rounded-md">
+ {contract.contractContent}
+ </pre>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">계약문서 내용이 없습니다.</p>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 계약 품목 */}
+ <ContractItemsCard items={contract.items || []} currency={contract.currency} />
+
+ {/* 계약 반려 다이얼로그 */}
+ <Dialog open={isRejectDialogOpen} onOpenChange={setIsRejectDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>계약 반려</DialogTitle>
+ <DialogDescription>
+ 계약을 반려하는 사유를 입력해주세요. 이 정보는 SHI 담당자에게 전달됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-4 py-4">
+ <div className="space-y-2">
+ <label htmlFor="reject-reason" className="text-sm font-medium">
+ 반려 사유 <span className="text-destructive">*</span>
+ </label>
+ <Textarea
+ id="reject-reason"
+ placeholder="계약 반려 사유를 입력해주세요..."
+ value={rejectReason}
+ onChange={(e) => setRejectReason(e.target.value)}
+ rows={5}
+ className="resize-none"
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={handleRejectCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleRejectConfirm}
+ disabled={isLoading || !rejectReason.trim()}
+ >
+ {isLoading ? "처리 중..." : "반려 확정"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+}
+
diff --git a/app/[lng]/partners/(partners)/po/[id]/page.tsx b/app/[lng]/partners/(partners)/po/[id]/page.tsx
new file mode 100644
index 00000000..8df2c90e
--- /dev/null
+++ b/app/[lng]/partners/(partners)/po/[id]/page.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { redirect } from "next/navigation"
+import { getVendorContractDetail } from "@/lib/po/vendor-table/service"
+import { Shell } from "@/components/shell"
+import { ContractDetailClient } from "./contract-detail-client"
+
+interface ContractDetailPageProps {
+ params: Promise<{
+ id: string
+ lng: string
+ }>
+}
+
+export default async function ContractDetailPage(props: ContractDetailPageProps) {
+ const params = await props.params
+ const contractId = parseInt(params.id, 10)
+
+ // 유효하지 않은 ID 체크
+ if (isNaN(contractId) || contractId <= 0) {
+ return (
+ <Shell className="gap-4">
+ <div className="flex h-full items-center justify-center p-6">
+ <p className="text-muted-foreground">유효하지 않은 계약 ID입니다.</p>
+ </div>
+ </Shell>
+ )
+ }
+
+ // 세션에서 벤더 정보 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ redirect("/")
+ }
+
+ // 계약 상세 정보 조회
+ const result = await getVendorContractDetail(contractId, session.user.companyId)
+
+ if (!result.success || !result.data) {
+ return (
+ <Shell className="gap-4">
+ <div className="flex h-full items-center justify-center p-6">
+ <p className="text-muted-foreground">{result.error || "계약 정보를 찾을 수 없습니다."}</p>
+ </div>
+ </Shell>
+ )
+ }
+
+ return (
+ <Shell className="gap-4">
+ <ContractDetailClient contract={result.data} lng={params.lng} />
+ </Shell>
+ )
+}
diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PO_INFORMATION/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PO_INFORMATION/route.ts
index eea16e84..c6275c68 100644
--- a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PO_INFORMATION/route.ts
+++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PO_INFORMATION/route.ts
@@ -117,9 +117,15 @@ export async function POST(request: NextRequest) {
await saveToDatabase(processedData);
// 6) PO 데이터를 비즈니스 테이블(contracts, contract_items)로 매핑
- // ProcessedPOData를 POMapperProcessedData 형식으로 변환
+ // ProcessedPOData를 POMapperProcessedData 형식으로 변환 (notes 포함)
const mappingData: POMapperProcessedData[] = processedData.map(poData => ({
- header: poData.header,
+ header: {
+ ...poData.header,
+ notes: poData.notes.map(note => ({
+ ZNOTE_SER: note.ZNOTE_SER || '',
+ ZNOTE_TXT: note.ZNOTE_TXT || ''
+ }))
+ },
details: poData.details
}));
diff --git a/components/contract/contract-info-card.tsx b/components/contract/contract-info-card.tsx
new file mode 100644
index 00000000..8b9c5103
--- /dev/null
+++ b/components/contract/contract-info-card.tsx
@@ -0,0 +1,104 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { formatCurrency, formatDate } from "@/lib/utils"
+import { FileText, DollarSign } from "lucide-react"
+
+interface ContractInfoCardProps {
+ contract: {
+ contractName?: string | null
+ createdAt?: Date | string | null
+ startDate?: string | null
+ endDate?: string | null
+ contractDate?: Date | string | null
+ purchaseGroup?: string | null
+ totalAmount?: number | string | null
+ currency?: string | null
+ paymentTerms?: string | null
+ deliveryTerms?: string | null
+ }
+}
+
+export function ContractInfoCard({ contract }: ContractInfoCardProps) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">계약조건</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">계약명</p>
+ <p className="text-sm">{contract.contractName || "-"}</p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">계약요청일</p>
+ <p className="text-sm">
+ {formatDate(contract.createdAt)}
+ </p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">계약기간</p>
+ <p className="text-sm">
+ {contract.startDate && contract.endDate
+ ? `${formatDate(contract.startDate)} ~ ${formatDate(contract.endDate)}`
+ : "-"}
+ </p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">계약체결일</p>
+ <p className="text-sm">
+ {formatDate(contract.contractDate)}
+ </p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">SHI 계약담당자</p>
+ <p className="text-sm">{contract.purchaseGroup || "-"}</p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">연계입찰/견적번호</p>
+ <p className="text-sm">-</p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">계약유효기간</p>
+ <p className="text-sm">
+ {contract.startDate && contract.endDate
+ ? `${formatDate(contract.startDate)} ~ ${formatDate(contract.endDate)}`
+ : "-"}
+ </p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">계약금액</p>
+ <p className="text-sm font-semibold">
+ {contract.totalAmount
+ ? formatCurrency(
+ parseFloat(contract.totalAmount.toString()),
+ contract.currency || "KRW"
+ )
+ : "-"}
+ </p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">지불조건</p>
+ <p className="text-sm">{contract.paymentTerms || "-"}</p>
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-medium text-muted-foreground">인도조건</p>
+ <p className="text-sm">{contract.deliveryTerms || "-"}</p>
+ </div>
+ </div>
+
+ <div className="mt-6 flex gap-2">
+ <Button variant="outline" size="sm">
+ <FileText className="h-4 w-4 mr-2" />
+ 계약문서
+ </Button>
+ <Button variant="outline" size="sm">
+ <DollarSign className="h-4 w-4 mr-2" />
+ 단가상세
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/components/contract/contract-items-card.tsx b/components/contract/contract-items-card.tsx
new file mode 100644
index 00000000..0d43f979
--- /dev/null
+++ b/components/contract/contract-items-card.tsx
@@ -0,0 +1,76 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { formatCurrency } from "@/lib/utils"
+
+interface ContractItem {
+ materialNo?: string
+ itemDescription?: string
+ specification?: string
+ quantity?: number
+ quantityUnit?: string
+ unitPrice?: number | string
+ contractAmount?: number | string
+}
+
+interface ContractItemsCardProps {
+ items: ContractItem[]
+ currency?: string
+}
+
+export function ContractItemsCard({ items, currency = "KRW" }: ContractItemsCardProps) {
+ if (!items || items.length === 0) {
+ return null
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">계약 품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead className="border-b bg-muted/50">
+ <tr>
+ <th className="px-4 py-3 text-left font-medium">자재번호</th>
+ <th className="px-4 py-3 text-left font-medium">품목/자재내역</th>
+ <th className="px-4 py-3 text-left font-medium">규격</th>
+ <th className="px-4 py-3 text-right font-medium">수량</th>
+ <th className="px-4 py-3 text-right font-medium">단가</th>
+ <th className="px-4 py-3 text-right font-medium">금액</th>
+ </tr>
+ </thead>
+ <tbody>
+ {items.map((item, idx) => (
+ <tr key={idx} className="border-b last:border-0">
+ <td className="px-4 py-3">{item.materialNo || "-"}</td>
+ <td className="px-4 py-3">{item.itemDescription || "-"}</td>
+ <td className="px-4 py-3">{item.specification || "-"}</td>
+ <td className="px-4 py-3 text-right">
+ {item.quantity} {item.quantityUnit || ""}
+ </td>
+ <td className="px-4 py-3 text-right font-mono">
+ {item.unitPrice
+ ? formatCurrency(
+ parseFloat(item.unitPrice.toString()),
+ currency
+ )
+ : "-"}
+ </td>
+ <td className="px-4 py-3 text-right font-mono font-medium">
+ {item.contractAmount
+ ? formatCurrency(
+ parseFloat(item.contractAmount.toString()),
+ currency
+ )
+ : "-"}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/db/schema/contract.ts b/db/schema/contract.ts
index 34f49c96..e2a070ec 100644
--- a/db/schema/contract.ts
+++ b/db/schema/contract.ts
@@ -15,94 +15,136 @@ import { vendorContacts, vendors } from "./vendors"
import { eq, sql } from "drizzle-orm";
import { items } from "./items";
-// ============ contracts (계약/PO 정보) ============
-export const contracts = pgTable("contracts", {
- // 주 키
- id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
-
- // 프로젝트와 협력업체 참조
- // .notNull() 제외(0912 구매 프로젝트 id 없는 계약 case 존재-최겸)
- projectId: integer("project_id")
- .references(() => projects.id, { onDelete: "cascade" }),
+/**
+ * PO 상태 << 정의서에 적어놓고 또 다르게 쓰고 있음. 신뢰가 안감
+ * ENG : KO : 의미
+ * 1. Contract Transfer : 계약번호생성 : 이 상태는 안 쓰이는 것인가?
+ * 2. Request to Review Conditions : 조건검토요청 : 조건검토를 할 수 있는 기능의 요구사항이 없음. 무시
+ * 3. Confirm to Review Conditions : 조건검토완료 : 조건검토를 할 수 있는 기능의 요구사항이 없음. 무시
+ * 4. Contract Accept Request : 계약승인요청 : PO가 들어오면 계약승인 요청으로 처리
+ * 5. Complete the Contract : 계약체결(승인) : 벤더가 PO 승인하면 이 상태로 변경
+ * 6. Reject to Accept Contract : 계약승인거절 : 벤더가 PO 거절하면 이 상태로 변경
+ * 7. Contract Delete : 계약폐기 : 계약폐기를 할 수 있는 기능의 요구사항이 없음. 중공업측에서 변경하거나 무시
+ * 8. PCR Request : PCR요청 : PCR 요청 시 이 상태로 변경
+ * 9. VO Request : VO요청 : VO 요청 기능 요구사항 없음. 요구사항이 정의되지 않은 상태임. 무시. (요구사항 정의되면 나중에 VO 요청 시 이 상태로 변경)
+ * 10. PCR Accept : PCR승인 : PCR 요청 승인 시 이 상태로 변경
+ * 11. PCR Reject : PCR거절 : PCR 요청 거절 시 이 상태로 변경
+ */
+
+// 계약 상태 enum (영어 키를 타입 안전하게 사용, i18n으로 다국어 지원)
+export enum ContractStatus {
+ CONTRACT_TRANSFER = "Contract Transfer",
+ REQUEST_TO_REVIEW_CONDITIONS = "Request to Review Conditions",
+ CONFIRM_TO_REVIEW_CONDITIONS = "Confirm to Review Conditions",
+ CONTRACT_ACCEPT_REQUEST = "Contract Accept Request",
+ COMPLETE_THE_CONTRACT = "Complete the Contract",
+ REJECT_TO_ACCEPT_CONTRACT = "Reject to Accept Contract",
+ CONTRACT_DELETE = "Contract Delete",
+ PCR_REQUEST = "PCR Request",
+ VO_REQUEST = "VO Request",
+ PCR_ACCEPT = "PCR Accept",
+ PCR_REJECT = "PCR Reject"
+}
- vendorId: integer("vendor_id")
- .notNull()
- .references(() => vendors.id, { onDelete: "cascade" }),
-
- // 계약/PO 번호(유니크)
- contractNo: varchar("contract_no", { length: 100 }).notNull().unique(), // EBELN
- contractName: varchar("contract_name", { length: 255 }).notNull(), // ZTITLE
-
- // 계약/PO 상태나 기간
- status: varchar("status", { length: 50 }).notNull().default("ACTIVE"), // ?
- startDate: date("start_date"), // 발주일(혹은 유효 시작일)
- endDate: date("end_date"), // 계약 종료일/유효 기간 등
-
- // --- SAP ECC 인터페이스 매핑 필드들 ---
- // 기본 PO 정보
- paymentTerms: text("payment_terms"), // 지급 조건 (ZTERM - 지급조건코드)
- deliveryTerms: text("delivery_terms"), // 납품 조건 (INCO1 - 인도조건코드)
- deliveryDate: date("delivery_date"), // 납품 기한 (ZPO_DLV_DT - PO납기일자, 개별 품목별)
- shippmentPlace: varchar("shippment_place", { length: 255 }), // 선적지 (ZSHIPMT_PLC_CD - 선적지코드)
- deliveryLocation: varchar("delivery_location", { length: 255 }), // 하역지 (ZUNLD_PLC_CD - 하역지코드)
-
- // SAP ECC 추가 필드들
- poVersion: integer("po_version"), // PO 버전 (ZPO_VER - 발주버전)
- purchaseDocType: varchar("purchase_doc_type", { length: 10 }), // 구매문서유형 (BSART)
- purchaseOrg: varchar("purchase_org", { length: 10 }), // 구매조직 (EKORG - 구매조직코드)
- purchaseGroup: varchar("purchase_group", { length: 10 }), // 구매그룹 (EKGRP - 구매그룹코드)
- exchangeRate: numeric("exchange_rate", { precision: 9, scale: 5 }), // 환율 (WKURS)
- poConfirmStatus: varchar("po_confirm_status", { length: 10 }), // PO확인상태 (ZPO_CNFM_STAT)
-
- // 계약/보증 관련
- contractGuaranteeCode: varchar("contract_guarantee_code", { length: 2 }), // 계약보증코드 (ZCNRT_GRNT_CD)
- defectGuaranteeCode: varchar("defect_guarantee_code", { length: 2 }), // 하자보증코드 (ZDFCT_GRNT_CD)
- guaranteePeriodCode: varchar("guarantee_period_code", { length: 2 }), // 보증기간코드 (ZGRNT_PRD_CD)
- advancePaymentYn: varchar("advance_payment_yn", { length: 1 }), // 선급금여부 (ZPAMT_YN)
-
- // 금액 관련 (KRW 변환)
- budgetAmount: numeric("budget_amount", { precision: 17, scale: 2 }), // 예산금액 (ZBGT_AMT)
- budgetCurrency: varchar("budget_currency", { length: 5 }), // 예산통화 (ZBGT_CURR)
- totalAmountKrw: numeric("total_amount_krw", { precision: 17, scale: 2 }), // 발주금액KRW (ZPO_AMT_KRW)
-
- // 전자계약/승인 관련
- electronicContractYn: varchar("electronic_contract_yn", { length: 1 }), // 전자계약필요여부 (ZELC_CNRT_ND_YN)
- electronicApprovalDate: date("electronic_approval_date"), // 전자승인일자 (ZELC_AGR_DT)
- electronicApprovalTime: varchar("electronic_approval_time", { length: 6 }), // 전자승인시간 (ZELC_AGR_TM)
- ownerApprovalYn: varchar("owner_approval_yn", { length: 1 }), // 선주승인필요여부 (ZOWN_AGR_IND_YN)
-
- // 기타
- plannedInOutFlag: varchar("planned_in_out_flag", { length: 1 }), // 계획내외구분 (ZPLN_INO_GB)
- settlementStandard: varchar("settlement_standard", { length: 1 }), // 정산기준 (ZECAL_BSE)
- weightSettlementFlag: varchar("weight_settlement_flag", { length: 1 }), // 중량정산구분 (ZWGT_ECAL_GB)
-
- // 연동제 관련
- priceIndexYn: varchar("price_index_yn", { length: 1 }), // 납품대금연동제대상여부 (ZDLV_PRICE_T)
- writtenContractNo: varchar("written_contract_no", { length: 20 }), // 서면계약번호 (ZWEBELN)
- contractVersion: integer("contract_version"), // 서면계약차수 (ZVER_NO)
-
- // 가격/금액 관련
- currency: varchar("currency", { length: 10 }).default("KRW"), // 통화 (KRW, USD 등) // ZPO_CURR
- totalAmount: numeric("total_amount", { precision: 12, scale: 2 }), // 총 계약 금액(아이템 합산 등) // ZPO_AMT
- discount: numeric("discount", { precision: 12, scale: 2 }), // 전체 할인 // 인터페이스에 없음 (개별 품목별로는 있음)
- tax: numeric("tax", { precision: 12, scale: 2 }), // 전체 세금 // 인터페이스에 없음 (개별 품목별로는 있음)
- shippingFee: numeric("shipping_fee", { precision: 12, scale: 2 }), // 배송비 // 인터페이스에 없음 (개별 품목별로는 있음)
- netTotal: numeric("net_total", { precision: 12, scale: 2 }), // (합계) - (할인) + (세금) + (배송비) // 인터페이스에 없음 (개별 품목별로는 있음)
-
- // 부분 납품/부분 결제 가능 여부
- partialShippingAllowed: boolean("partial_shipping_allowed").default(false),
- partialPaymentAllowed: boolean("partial_payment_allowed").default(false),
-
- // 추가 메모/비고
- remarks: text("remarks"), // 발주노트 1, 2가 있는데 메모용인것으로 추정
-
- // 버전 관리 (PO 재발행 등)
- version: integer("version").default(1),
+// ============ contracts (계약/PO 정보) ============
+export const contracts = pgTable('contracts', {
+ // 주 키
+ id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
+
+ // 프로젝트와 협력업체 참조
+ // .notNull() 제외(0912 구매 프로젝트 id 없는 계약 case 존재-최겸)
+ projectId: integer('project_id').references(() => projects.id, {
+ onDelete: 'cascade',
+ }),
+
+ vendorId: integer('vendor_id')
+ .notNull()
+ .references(() => vendors.id, { onDelete: 'cascade' }),
+
+ // 계약/PO 번호(유니크)
+ contractNo: varchar('contract_no', { length: 100 }).notNull().unique(), // EBELN
+ contractName: varchar('contract_name', { length: 255 }).notNull(), // ZTITLE
+
+ // 계약/PO 상태나 기간
+ status: varchar('status', { length: 50 })
+ .notNull()
+ .default(ContractStatus.CONTRACT_ACCEPT_REQUEST), // default value: 'Contract Accept Request'
+ startDate: date('start_date'), // 발주일(혹은 유효 시작일)
+ endDate: date('end_date'), // 계약 종료일/유효 기간 등
+
+ // 거절사유
+ rejectionReason: varchar('rejection_reason', { length: 1000 }),
+
+ // --- SAP ECC 인터페이스 매핑 필드들 ---
+ // 기본 PO 정보
+ paymentTerms: text('payment_terms'), // 지급 조건 (ZTERM - 지급조건코드)
+ deliveryTerms: text('delivery_terms'), // 납품 조건 (INCO1 - 인도조건코드)
+ deliveryDate: date('delivery_date'), // 납품 기한 (ZPO_DLV_DT - PO납기일자, 개별 품목별)
+ shippmentPlace: varchar('shippment_place', { length: 255 }), // 선적지 (ZSHIPMT_PLC_CD - 선적지코드)
+ deliveryLocation: varchar('delivery_location', { length: 255 }), // 하역지 (ZUNLD_PLC_CD - 하역지코드)
+
+ // SAP ECC 추가 필드들
+ poVersion: integer('po_version'), // PO 버전 (ZPO_VER - 발주버전)
+ purchaseDocType: varchar('purchase_doc_type', { length: 10 }), // 구매문서유형 (BSART)
+ purchaseOrg: varchar('purchase_org', { length: 10 }), // 구매조직 (EKORG - 구매조직코드)
+ purchaseGroup: varchar('purchase_group', { length: 10 }), // 구매그룹 (EKGRP - 구매그룹코드)
+ exchangeRate: numeric('exchange_rate', { precision: 9, scale: 5 }), // 환율 (WKURS)
+ poConfirmStatus: varchar('po_confirm_status', { length: 10 }), // PO확인상태 (ZPO_CNFM_STAT)
+
+ // 계약/보증 관련
+ contractGuaranteeCode: varchar('contract_guarantee_code', { length: 2 }), // 계약보증코드 (ZCNRT_GRNT_CD)
+ defectGuaranteeCode: varchar('defect_guarantee_code', { length: 2 }), // 하자보증코드 (ZDFCT_GRNT_CD)
+ guaranteePeriodCode: varchar('guarantee_period_code', { length: 2 }), // 보증기간코드 (ZGRNT_PRD_CD)
+ advancePaymentYn: varchar('advance_payment_yn', { length: 1 }), // 선급금여부 (ZPAMT_YN)
+
+ // 금액 관련 (KRW 변환)
+ budgetAmount: numeric('budget_amount', { precision: 17, scale: 2 }), // 예산금액 (ZBGT_AMT)
+ budgetCurrency: varchar('budget_currency', { length: 5 }), // 예산통화 (ZBGT_CURR)
+ totalAmountKrw: numeric('total_amount_krw', { precision: 17, scale: 2 }), // 발주금액KRW (ZPO_AMT_KRW)
+
+ // 전자계약/승인 관련
+ electronicContractYn: varchar('electronic_contract_yn', { length: 1 }), // 전자계약필요여부 (ZELC_CNRT_ND_YN)
+ electronicApprovalDate: date('electronic_approval_date'), // 전자승인일자 (ZELC_AGR_DT)
+ electronicApprovalTime: varchar('electronic_approval_time', { length: 6 }), // 전자승인시간 (ZELC_AGR_TM)
+ ownerApprovalYn: varchar('owner_approval_yn', { length: 1 }), // 선주승인필요여부 (ZOWN_AGR_IND_YN)
+
+ // 기타
+ plannedInOutFlag: varchar('planned_in_out_flag', { length: 1 }), // 계획내외구분 (ZPLN_INO_GB)
+ settlementStandard: varchar('settlement_standard', { length: 1 }), // 정산기준 (ZECAL_BSE)
+ weightSettlementFlag: varchar('weight_settlement_flag', { length: 1 }), // 중량정산구분 (ZWGT_ECAL_GB)
+
+ // 연동제 관련
+ priceIndexYn: varchar('price_index_yn', { length: 1 }), // 납품대금연동제대상여부 (ZDLV_PRICE_T)
+ writtenContractNo: varchar('written_contract_no', { length: 20 }), // 서면계약번호 (ZWEBELN)
+ contractVersion: integer('contract_version'), // 서면계약차수 (ZVER_NO)
+
+ // 가격/금액 관련
+ currency: varchar('currency', { length: 10 }).default('KRW'), // 통화 (KRW, USD 등) // ZPO_CURR
+ totalAmount: numeric('total_amount', { precision: 12, scale: 2 }), // 총 계약 금액(아이템 합산 등) // ZPO_AMT
+ discount: numeric('discount', { precision: 12, scale: 2 }), // 전체 할인 // 인터페이스에 없음 (개별 품목별로는 있음)
+ tax: numeric('tax', { precision: 12, scale: 2 }), // 전체 세금 // 인터페이스에 없음 (개별 품목별로는 있음)
+ shippingFee: numeric('shipping_fee', { precision: 12, scale: 2 }), // 배송비 // 인터페이스에 없음 (개별 품목별로는 있음)
+ netTotal: numeric('net_total', { precision: 12, scale: 2 }), // (합계) - (할인) + (세금) + (배송비) // 인터페이스에 없음 (개별 품목별로는 있음)
+
+ // 부분 납품/부분 결제 가능 여부
+ partialShippingAllowed: boolean('partial_shipping_allowed').default(false),
+ partialPaymentAllowed: boolean('partial_payment_allowed').default(false),
+
+ // 추가 메모/비고
+ remarks: text('remarks'),
+ vendorComment: text('vendor_comment'),
+ shiComment: text('shi_comment'),
+
+ // PO 계약서 내용
+ contractContent: text('contract_content'),
+
+ // 버전 관리 (PO 재발행 등)
+ version: integer('version').default(1),
- // 생성/수정 시각
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-})
+ // 생성/수정 시각
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+});
// 타입 추론
export type Contract = typeof contracts.$inferSelect
diff --git a/lib/po/vendor-table/service.ts b/lib/po/vendor-table/service.ts
index 195144a2..224dd2f1 100644
--- a/lib/po/vendor-table/service.ts
+++ b/lib/po/vendor-table/service.ts
@@ -1,14 +1,14 @@
"use server";
import { GetVendorPOSchema } from "./validations";
-import { getVendorPOsPage } from "./mock-data";
import { VendorPO, VendorPOItem } from "./types";
import db from "@/db/db";
-import { contracts, contractItems } from "@/db/schema/contract";
+import { contracts, contractItems, ContractStatus } from "@/db/schema/contract";
import { projects } from "@/db/schema/projects";
import { vendors } from "@/db/schema/vendors";
import { items } from "@/db/schema/items";
-import { eq, and, or, ilike, count, desc, asc } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { eq, and, or, ilike, count, desc, asc, SQL } from "drizzle-orm";
/**
* 벤더 PO 목록 조회
@@ -20,17 +20,16 @@ export async function getVendorPOs(input: GetVendorPOSchema) {
const offset = (input.page - 1) * input.perPage;
// 검색 조건 구성
- let whereConditions = [];
+ const whereConditions: SQL<unknown>[] = [];
if (input.search) {
const searchTerm = `%${input.search}%`;
- whereConditions.push(
- or(
- ilike(contracts.contractNo, searchTerm),
- ilike(contracts.contractName, searchTerm),
- ilike(projects.name, searchTerm),
- ilike(vendors.vendorName, searchTerm)
- )
+ const searchCondition = or(
+ ilike(contracts.contractNo, searchTerm),
+ ilike(contracts.contractName, searchTerm),
+ ilike(projects.name, searchTerm),
+ ilike(vendors.vendorName, searchTerm)
);
+ if (searchCondition) whereConditions.push(searchCondition);
}
// 벤더 필터링 (partners 페이지에서 사용)
@@ -44,13 +43,15 @@ export async function getVendorPOs(input: GetVendorPOSchema) {
if (filter.id && filter.value) {
switch (filter.id) {
case "contractStatus":
- whereConditions.push(ilike(contracts.status, `%${filter.value}%`));
+ const statusCondition = ilike(contracts.status, `%${filter.value}%`);
+ if (statusCondition) whereConditions.push(statusCondition);
break;
case "contractType":
- whereConditions.push(ilike(contracts.purchaseDocType, `%${filter.value}%`));
+ const typeCondition = ilike(contracts.purchaseDocType, `%${filter.value}%`);
+ if (typeCondition) whereConditions.push(typeCondition);
break;
case "currency":
- whereConditions.push(eq(contracts.currency, filter.value));
+ whereConditions.push(eq(contracts.currency, filter.value as string));
break;
// 추가 필터 조건들...
}
@@ -117,7 +118,11 @@ export async function getVendorPOs(input: GetVendorPOSchema) {
priceIndexYn: contracts.priceIndexYn,
writtenContractNo: contracts.writtenContractNo,
contractVersion: contracts.contractVersion,
-
+
+ // 계약서 내용 및 노트
+ contractContent: contracts.contractContent,
+ remarks: contracts.remarks,
+
createdAt: contracts.createdAt,
updatedAt: contracts.updatedAt,
@@ -145,34 +150,146 @@ export async function getVendorPOs(input: GetVendorPOSchema) {
// VendorPO 타입으로 변환
const data: VendorPO[] = rawData.map(row => ({
+ id: row.id,
+ contractNo: row.contractNo || '',
+ revision: 'Rev.01', // mock 데이터용 기본값
+ itemNo: 'ITM-AUTO', // mock 데이터용 기본값
+ contractStatus: row.status || '',
+ contractType: row.purchaseDocType || '',
+ details: '상세보기', // mock 데이터용 기본값
+ projectName: row.projectName || '',
+ contractName: row.contractName || '',
+ contractPeriod: row.startDate && row.endDate
+ ? `${row.startDate} ~ ${row.endDate}`
+ : '',
+ contractQuantity: '1 LOT', // 기본값 (실제로는 contract_items에서 계산 필요)
+ currency: row.currency || 'KRW',
+ paymentTerms: row.paymentTerms || '',
+ tax: '10%', // 기본값 (실제로는 contract_items에서 계산 필요)
+ exchangeRate: row.exchangeRate?.toString() || '',
+ deliveryTerms: row.deliveryTerms || '',
+ purchaseManager: '', // 사용자 테이블 조인 필요
+ poReceiveDate: row.createdAt?.toISOString().split('T')[0] || '',
+ contractDate: row.startDate || '',
+ lcNo: undefined,
+ priceIndexTarget: row.priceIndexYn === 'Y',
+ linkedContractNo: undefined,
+ lastModifiedDate: row.updatedAt?.toISOString().split('T')[0] || '',
+ lastModifiedBy: '', // 사용자 테이블 조인 필요
+
+ // SAP ECC 추가 필드들
+ poVersion: row.poVersion || undefined,
+ purchaseDocType: row.purchaseDocType || undefined,
+ purchaseOrg: row.purchaseOrg || undefined,
+ purchaseGroup: row.purchaseGroup || undefined,
+ poConfirmStatus: row.poConfirmStatus || undefined,
+ contractGuaranteeCode: row.contractGuaranteeCode || undefined,
+ defectGuaranteeCode: row.defectGuaranteeCode || undefined,
+ guaranteePeriodCode: row.guaranteePeriodCode || undefined,
+ advancePaymentYn: row.advancePaymentYn || undefined,
+ budgetAmount: row.budgetAmount ? Number(row.budgetAmount) : undefined,
+ budgetCurrency: row.budgetCurrency || undefined,
+ totalAmount: row.totalAmount ? Number(row.totalAmount) : undefined,
+ totalAmountKrw: row.totalAmountKrw ? Number(row.totalAmountKrw) : undefined,
+ electronicContractYn: row.electronicContractYn || undefined,
+ electronicApprovalDate: row.electronicApprovalDate || undefined,
+ electronicApprovalTime: row.electronicApprovalTime || undefined,
+ ownerApprovalYn: row.ownerApprovalYn || undefined,
+ plannedInOutFlag: row.plannedInOutFlag || undefined,
+ settlementStandard: row.settlementStandard || undefined,
+ weightSettlementFlag: row.weightSettlementFlag || undefined,
+ priceIndexYn: row.priceIndexYn || undefined,
+ writtenContractNo: row.writtenContractNo || undefined,
+ contractVersion: row.contractVersion || undefined,
+ }));
+
+ return {
+ data,
+ pageCount
+ };
+ } catch (err) {
+ console.error("Error in getVendorPOs:", err);
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/**
+ * 벤더 PO 상세 정보 조회
+ */
+export async function getVendorPOById(id: number): Promise<VendorPO | null> {
+ try {
+ const [row] = await db
+ .select({
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ startDate: contracts.startDate,
+ endDate: contracts.endDate,
+ currency: contracts.currency,
+ totalAmount: contracts.totalAmount,
+ totalAmountKrw: contracts.totalAmountKrw,
+ paymentTerms: contracts.paymentTerms,
+ deliveryTerms: contracts.deliveryTerms,
+ exchangeRate: contracts.exchangeRate,
+ poVersion: contracts.poVersion,
+ purchaseDocType: contracts.purchaseDocType,
+ purchaseOrg: contracts.purchaseOrg,
+ purchaseGroup: contracts.purchaseGroup,
+ poConfirmStatus: contracts.poConfirmStatus,
+ contractGuaranteeCode: contracts.contractGuaranteeCode,
+ defectGuaranteeCode: contracts.defectGuaranteeCode,
+ guaranteePeriodCode: contracts.guaranteePeriodCode,
+ advancePaymentYn: contracts.advancePaymentYn,
+ budgetAmount: contracts.budgetAmount,
+ budgetCurrency: contracts.budgetCurrency,
+ electronicContractYn: contracts.electronicContractYn,
+ electronicApprovalDate: contracts.electronicApprovalDate,
+ electronicApprovalTime: contracts.electronicApprovalTime,
+ ownerApprovalYn: contracts.ownerApprovalYn,
+ plannedInOutFlag: contracts.plannedInOutFlag,
+ settlementStandard: contracts.settlementStandard,
+ weightSettlementFlag: contracts.weightSettlementFlag,
+ priceIndexYn: contracts.priceIndexYn,
+ writtenContractNo: contracts.writtenContractNo,
+ contractVersion: contracts.contractVersion,
+ createdAt: contracts.createdAt,
+ updatedAt: contracts.updatedAt,
+ projectName: projects.name,
+ })
+ .from(contracts)
+ .leftJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contracts.id, id))
+ .limit(1);
+
+ if (!row) return null;
+
+ // VendorPO 타입으로 변환
+ const po: VendorPO = {
id: row.id,
contractNo: row.contractNo || '',
- revision: 'Rev.01', // mock 데이터용 기본값
- itemNo: 'ITM-AUTO', // mock 데이터용 기본값
+ revision: 'Rev.01',
+ itemNo: 'ITM-AUTO',
contractStatus: row.status || '',
contractType: row.purchaseDocType || '',
- details: '상세보기', // mock 데이터용 기본값
+ details: '상세보기',
projectName: row.projectName || '',
contractName: row.contractName || '',
- contractPeriod: row.startDate && row.endDate
- ? `${row.startDate} ~ ${row.endDate}`
- : '',
- contractQuantity: '1 LOT', // 기본값 (실제로는 contract_items에서 계산 필요)
+ contractPeriod: row.startDate && row.endDate ? `${row.startDate} ~ ${row.endDate}` : '',
+ contractQuantity: '1 LOT',
currency: row.currency || 'KRW',
paymentTerms: row.paymentTerms || '',
- tax: '10%', // 기본값 (실제로는 contract_items에서 계산 필요)
+ tax: '10%',
exchangeRate: row.exchangeRate?.toString() || '',
deliveryTerms: row.deliveryTerms || '',
- purchaseManager: '', // 사용자 테이블 조인 필요
+ purchaseManager: '',
poReceiveDate: row.createdAt?.toISOString().split('T')[0] || '',
contractDate: row.startDate || '',
lcNo: undefined,
priceIndexTarget: row.priceIndexYn === 'Y',
linkedContractNo: undefined,
lastModifiedDate: row.updatedAt?.toISOString().split('T')[0] || '',
- lastModifiedBy: '', // 사용자 테이블 조인 필요
-
- // SAP ECC 추가 필드들
+ lastModifiedBy: '',
poVersion: row.poVersion || undefined,
purchaseDocType: row.purchaseDocType || undefined,
purchaseOrg: row.purchaseOrg || undefined,
@@ -196,90 +313,9 @@ export async function getVendorPOs(input: GetVendorPOSchema) {
priceIndexYn: row.priceIndexYn || undefined,
writtenContractNo: row.writtenContractNo || undefined,
contractVersion: row.contractVersion || undefined,
- }));
-
- return {
- data,
- pageCount
- };
-
- // 목업 데이터 사용 (개발/테스트용)
- // const result = getVendorPOsPage(
- // input.page,
- // input.perPage,
- // input.search,
- // input.filters
- // );
-
- // 실제 데이터베이스 연동시에는 아래와 같은 구조로 구현
- // const offset = (input.page - 1) * input.perPage;
- //
- // // 검색 조건 구성
- // let whereConditions = [];
- // if (input.search) {
- // const searchTerm = `%${input.search}%`;
- // whereConditions.push(
- // or(
- // ilike(vendorPOTable.contractNo, searchTerm),
- // ilike(vendorPOTable.contractName, searchTerm),
- // ilike(vendorPOTable.projectName, searchTerm)
- // )
- // );
- // }
- //
- // // 필터 조건 추가
- // if (input.contractStatus) {
- // whereConditions.push(eq(vendorPOTable.contractStatus, input.contractStatus));
- // }
- //
- // const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- //
- // // 정렬 조건
- // const orderBy = input.sort.length > 0
- // ? input.sort.map((item) =>
- // item.desc
- // ? desc(vendorPOTable[item.id])
- // : asc(vendorPOTable[item.id])
- // )
- // : [desc(vendorPOTable.lastModifiedDate)];
- //
- // // 데이터 조회
- // const data = await db
- // .select()
- // .from(vendorPOTable)
- // .where(finalWhere)
- // .orderBy(...orderBy)
- // .offset(offset)
- // .limit(input.perPage);
- //
- // // 총 개수 조회
- // const [{ count }] = await db
- // .select({ count: count() })
- // .from(vendorPOTable)
- // .where(finalWhere);
- //
- // const pageCount = Math.ceil(count / input.perPage);
-
- return {
- data: result.data,
- pageCount: result.pageCount
};
- } catch (err) {
- console.error("Error in getVendorPOs:", err);
- return { data: [], pageCount: 0 };
- }
-}
-/**
- * 벤더 PO 상세 정보 조회
- */
-export async function getVendorPOById(id: number): Promise<VendorPO | null> {
- try {
- // 목업 데이터에서 조회
- const result = getVendorPOsPage(1, 100); // 모든 데이터 가져오기
- const po = result.data.find(item => item.id === id);
-
- return po || null;
+ return po;
} catch (err) {
console.error("Error in getVendorPOById:", err);
return null;
@@ -292,8 +328,7 @@ export async function getVendorPOById(id: number): Promise<VendorPO | null> {
*/
export async function handleVendorPOAction(
poId: number,
- action: string,
- data?: any
+ action: string
): Promise<{ success: boolean; message: string }> {
try {
// 목업에서는 성공 응답만 반환
@@ -415,3 +450,319 @@ export async function getVendorPOItemsByContractNo(contractNo: string): Promise<
throw err;
}
}
+
+/**
+ * PCR 생성 요청: PCR 생성 요청 후, 상태 변경
+ */
+export async function createPcrRequest(contractId: number) {
+ try {
+ // TODO PCR 생성 요청 로직 구현
+
+ // PCR 생성 요청 상태로 변경
+ await db.update(contracts).set({ status: ContractStatus.PCR_REQUEST }).where(eq(contracts.id, contractId));
+
+ // 캐시 무효화하여 변경사항 즉시 반영
+ revalidatePath("/partners/po");
+ } catch (err) {
+ console.error("Error in createPcrRequest:", err);
+ throw err;
+ }
+
+ return {
+ success: true,
+ message: "PCR 생성 요청이 성공적으로 완료되었습니다."
+ };
+}
+
+/**
+ * 계약 승인 처리: 상태만 변경
+ */
+export async function acceptContract(contractId: number) {
+ try {
+ await db.update(contracts).set({ status: ContractStatus.COMPLETE_THE_CONTRACT }).where(eq(contracts.id, contractId));
+
+ // 캐시 무효화하여 변경사항 즉시 반영
+ revalidatePath("/partners/po");
+ } catch (err) {
+ console.error("Error in acceptContract:", err);
+ throw err;
+ }
+
+ return {
+ success: true,
+ message: "계약이 성공적으로 승인되었습니다."
+ };
+}
+/**
+ * 계약 승인 취소 처리: 상태만 변경
+ */
+export async function cancelAcceptContract(contractId: number) {
+ try {
+
+ // 계약 승인 상태에서만 취소 가능
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ });
+ if (!contract) {
+ throw new Error("계약을 찾을 수 없습니다.");
+ }
+ if (contract.status !== ContractStatus.COMPLETE_THE_CONTRACT) {
+ throw new Error("계약 승인 상태가 아닙니다.");
+ }
+
+ // 취소 처리
+ await db.update(contracts).set({ status: ContractStatus.CONTRACT_ACCEPT_REQUEST }).where(eq(contracts.id, contractId));
+
+ // 캐시 무효화하여 변경사항 즉시 반영
+ revalidatePath("/partners/po");
+ } catch (err) {
+ console.error("Error in cancelAcceptContract:", err);
+ throw err;
+ }
+
+ return {
+ success: true,
+ message: "계약이 성공적으로 승인 취소되었습니다."
+ };
+}
+
+/**
+ * 계약 거절 처리: 거절 사유를 입력받고, 상태 변경
+ */
+export async function rejectContract(contractId: number, rejectionReason: string) {
+ try {
+ await db.update(contracts).set({ status: ContractStatus.REJECT_TO_ACCEPT_CONTRACT, rejectionReason }).where(eq(contracts.id, contractId));
+
+ // 캐시 무효화하여 변경사항 즉시 반영
+ revalidatePath("/partners/po");
+ } catch (err) {
+ console.error("Error in rejectContract:", err);
+ throw err;
+ }
+
+ return {
+ success: true,
+ message: "계약이 성공적으로 거절되었습니다."
+ };
+}
+
+/**
+ * 벤더 코멘트 저장
+ */
+export async function saveVendorComment(contractId: number, vendorComment: string) {
+ try {
+ await db.update(contracts).set({
+ vendorComment,
+ updatedAt: new Date()
+ }).where(eq(contracts.id, contractId));
+
+ // 캐시 무효화하여 변경사항 즉시 반영
+ revalidatePath("/partners/po");
+ } catch (err) {
+ console.error("Error in saveVendorComment:", err);
+ throw err;
+ }
+
+ return {
+ success: true,
+ message: "의견이 성공적으로 저장되었습니다."
+ };
+}
+
+/**
+ * SHI 코멘트 저장 (EVCP용)
+ */
+export async function saveSHIComment(contractId: number, shiComment: string) {
+ try {
+ await db.update(contracts).set({
+ shiComment,
+ updatedAt: new Date()
+ }).where(eq(contracts.id, contractId));
+
+ // 캐시 무효화하여 변경사항 즉시 반영
+ revalidatePath("/evcp/po");
+ revalidatePath(`/evcp/po/${contractId}`);
+ } catch (err) {
+ console.error("Error in saveSHIComment:", err);
+ throw err;
+ }
+
+ return {
+ success: true,
+ message: "SHI 의견이 성공적으로 저장되었습니다."
+ };
+}
+
+/**
+ * 특정 계약의 상세 정보 조회 (EVCP/SHI용)
+ */
+export async function getContractDetail(contractId: number) {
+ try {
+ // contractId 유효성 검사
+ if (!contractId || isNaN(contractId)) {
+ return { success: false, error: "유효하지 않은 계약 ID입니다." };
+ }
+
+ // 계약 기본 정보 조회
+ const [contractData] = await db
+ .select({
+ // contracts 테이블 필드들
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ startDate: contracts.startDate,
+ endDate: contracts.endDate,
+ contractDate: contracts.createdAt,
+ currency: contracts.currency,
+ totalAmount: contracts.totalAmount,
+ totalAmountKrw: contracts.totalAmountKrw,
+ paymentTerms: contracts.paymentTerms,
+ deliveryTerms: contracts.deliveryTerms,
+ exchangeRate: contracts.exchangeRate,
+ rejectionReason: contracts.rejectionReason,
+
+ // SAP ECC 추가 필드들
+ poVersion: contracts.poVersion,
+ purchaseDocType: contracts.purchaseDocType,
+ purchaseOrg: contracts.purchaseOrg,
+ purchaseGroup: contracts.purchaseGroup,
+ poConfirmStatus: contracts.poConfirmStatus,
+
+ // 계약/보증 관련
+ contractGuaranteeCode: contracts.contractGuaranteeCode,
+ defectGuaranteeCode: contracts.defectGuaranteeCode,
+ guaranteePeriodCode: contracts.guaranteePeriodCode,
+ advancePaymentYn: contracts.advancePaymentYn,
+
+ // 계약서 내용 및 노트
+ contractContent: contracts.contractContent,
+ remarks: contracts.remarks,
+ vendorComment: contracts.vendorComment,
+ shiComment: contracts.shiComment,
+
+ createdAt: contracts.createdAt,
+ updatedAt: contracts.updatedAt,
+
+ // 조인된 테이블 필드들
+ projectId: projects.id,
+ projectName: projects.name,
+ projectCode: projects.code,
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(contracts)
+ .leftJoin(projects, eq(contracts.projectId, projects.id))
+ .leftJoin(vendors, eq(contracts.vendorId, vendors.id))
+ .where(eq(contracts.id, contractId))
+ .limit(1);
+
+ if (!contractData) {
+ return { success: false, error: "계약 정보를 찾을 수 없습니다." };
+ }
+
+ // 계약 품목 조회
+ const items = await getVendorPOItems(contractId);
+
+ return {
+ success: true,
+ data: {
+ ...contractData,
+ items,
+ },
+ };
+ } catch (error) {
+ console.error("Error fetching contract detail:", error);
+ return { success: false, error: "계약 상세 정보 조회 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 특정 계약의 상세 정보 조회 (벤더용 계약 상세 페이지)
+ */
+export async function getVendorContractDetail(contractId: number, vendorId: number) {
+ try {
+ // contractId 유효성 검사
+ if (!contractId || isNaN(contractId)) {
+ return { success: false, error: "유효하지 않은 계약 ID입니다." };
+ }
+
+ // 계약 기본 정보 조회 (벤더 필터링 포함)
+ const [contractData] = await db
+ .select({
+ // contracts 테이블 필드들
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ startDate: contracts.startDate,
+ endDate: contracts.endDate,
+ contractDate: contracts.createdAt,
+ currency: contracts.currency,
+ totalAmount: contracts.totalAmount,
+ totalAmountKrw: contracts.totalAmountKrw,
+ paymentTerms: contracts.paymentTerms,
+ deliveryTerms: contracts.deliveryTerms,
+ exchangeRate: contracts.exchangeRate,
+
+ // SAP ECC 추가 필드들
+ poVersion: contracts.poVersion,
+ purchaseDocType: contracts.purchaseDocType,
+ purchaseOrg: contracts.purchaseOrg,
+ purchaseGroup: contracts.purchaseGroup,
+ poConfirmStatus: contracts.poConfirmStatus,
+
+ // 계약/보증 관련
+ contractGuaranteeCode: contracts.contractGuaranteeCode,
+ defectGuaranteeCode: contracts.defectGuaranteeCode,
+ guaranteePeriodCode: contracts.guaranteePeriodCode,
+ advancePaymentYn: contracts.advancePaymentYn,
+
+ // 계약서 내용 및 노트
+ contractContent: contracts.contractContent,
+ remarks: contracts.remarks,
+ vendorComment: contracts.vendorComment,
+ shiComment: contracts.shiComment,
+
+ createdAt: contracts.createdAt,
+ updatedAt: contracts.updatedAt,
+
+ // 조인된 테이블 필드들
+ projectId: projects.id,
+ projectName: projects.name,
+ projectCode: projects.code,
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(contracts)
+ .leftJoin(projects, eq(contracts.projectId, projects.id))
+ .leftJoin(vendors, eq(contracts.vendorId, vendors.id))
+ .where(
+ and(
+ eq(contracts.id, contractId),
+ eq(contracts.vendorId, vendorId) // 벤더 권한 체크
+ )
+ )
+ .limit(1);
+
+ if (!contractData) {
+ return { success: false, error: "계약 정보를 찾을 수 없거나 접근 권한이 없습니다." };
+ }
+
+ // 계약 품목 조회
+ const items = await getVendorPOItems(contractId);
+
+ return {
+ success: true,
+ data: {
+ ...contractData,
+ items,
+ },
+ };
+ } catch (error) {
+ console.error("Error fetching contract detail:", error);
+ return { success: false, error: "계약 상세 정보 조회 중 오류가 발생했습니다." };
+ }
+} \ No newline at end of file
diff --git a/lib/po/vendor-table/shi-vendor-po-columns.tsx b/lib/po/vendor-table/shi-vendor-po-columns.tsx
index 041e0c05..356b3dab 100644
--- a/lib/po/vendor-table/shi-vendor-po-columns.tsx
+++ b/lib/po/vendor-table/shi-vendor-po-columns.tsx
@@ -6,7 +6,9 @@ import {
SendIcon,
FileTextIcon,
MoreHorizontalIcon,
+ EyeIcon,
} from "lucide-react"
+import { useRouter, useParams } from "next/navigation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -404,8 +406,34 @@ export function getShiVendorColumns({
enableHiding: false,
header: () => <div className="text-center">Actions</div>,
cell: ({ row }) => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const router = useRouter()
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const params = useParams()
+ const lng = params.lng as string
+
return (
<div className="flex items-center justify-center gap-2">
+ {/* 계약 상세보기 버튼 */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-2"
+ onClick={() => router.push(`/${lng}/evcp/po/${row.original.id}`)}
+ >
+ <EyeIcon className="h-3.5 w-3.5 mr-1" aria-hidden="true" />
+ 상세
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ 계약 상세 정보 보기
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
{/* 서명 요청 버튼 */}
<TooltipProvider>
<Tooltip>
@@ -425,38 +453,11 @@ export function getShiVendorColumns({
</TooltipContent>
</Tooltip>
</TooltipProvider>
-
- {/* 드롭다운 메뉴 (추가 액션)
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">Open menu</span>
- <MoreHorizontalIcon className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuLabel>액션</DropdownMenuLabel>
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "view-items" })}
- >
- <FileTextIcon className="mr-2 h-4 w-4" />
- 상세품목 보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "signature-request" })}
- className="text-blue-600"
- >
- <SendIcon className="mr-2 h-4 w-4" />
- 서명 요청
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu> */}
</div>
)
},
- size: 160,
- minSize: 160,
+ size: 200,
+ minSize: 200,
},
]
} \ No newline at end of file
diff --git a/lib/po/vendor-table/types.ts b/lib/po/vendor-table/types.ts
index 97572ffc..f8bc3ea2 100644
--- a/lib/po/vendor-table/types.ts
+++ b/lib/po/vendor-table/types.ts
@@ -65,7 +65,11 @@ export interface VendorPO {
priceIndexYn?: string // 납품대금연동제대상여부 (ZDLV_PRICE_T)
writtenContractNo?: string // 서면계약번호 (ZWEBELN)
contractVersion?: number // 서면계약차수 (ZVER_NO)
-
+
+ // 계약서 내용 및 노트
+ contractContent?: string // 계약서 내용 (ZMM_NOTE에서 추출)
+ remarks?: string // 비고 (ECC에서 추가 정보)
+
// 상세품목 정보 (다이얼로그에서 표시)
items?: VendorPOItem[]
}
@@ -98,6 +102,7 @@ export interface VendorPOItem {
vatType: string // VAT구분
steelSpec?: string // 철의장 SPEC
prManager: string // P/R 담당자
+ remark?: string // 비고 (contract_items.remark)
}
// 파싱된 벤더 PO 타입 (JSON 필드들이 파싱된 상태)
diff --git a/lib/po/vendor-table/vendor-po-actions.tsx b/lib/po/vendor-table/vendor-po-actions.tsx
new file mode 100644
index 00000000..329d91fd
--- /dev/null
+++ b/lib/po/vendor-table/vendor-po-actions.tsx
@@ -0,0 +1,273 @@
+"use client"
+
+import * as React from "react"
+import {
+ FileTextIcon,
+ MoreHorizontalIcon,
+ EyeIcon,
+ PrinterIcon,
+ FileXIcon,
+ PlusIcon,
+ EditIcon
+} from "lucide-react"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { toast } from "sonner"
+import { VendorPO, VendorPOActionType } from "./types"
+import { createPcrRequest, acceptContract, rejectContract, cancelAcceptContract } from "./service"
+import { ContractStatus } from "@/db/schema/contract"
+
+interface VendorPOActionsProps {
+ row: { original: VendorPO }
+ setRowAction: React.Dispatch<React.SetStateAction<{ row: { original: VendorPO }, type: VendorPOActionType } | null>>
+}
+
+export function VendorPOActions({ row, setRowAction }: VendorPOActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
+ const [rejectionReason, setRejectionReason] = React.useState("")
+
+ // 계약 상태에 따른 버튼 활성화 조건
+ const contractStatus = row.original.contractStatus
+ const canCreatePcr = contractStatus === ContractStatus.CONTRACT_ACCEPT_REQUEST
+ const canApprove = contractStatus === ContractStatus.CONTRACT_ACCEPT_REQUEST
+ const canCancelApprove = contractStatus === ContractStatus.COMPLETE_THE_CONTRACT
+ const canReject = contractStatus === ContractStatus.CONTRACT_ACCEPT_REQUEST
+
+ // PCR 생성 핸들러
+ const handlePcrCreate = async () => {
+ if (isLoading) return
+
+ try {
+ setIsLoading(true)
+ const result = await createPcrRequest(row.original.id)
+
+ if (result.success) {
+ toast.success(result.message)
+ // 필요한 경우 테이블 리프레시 로직 추가
+ }
+ } catch (error) {
+ console.error("PCR 생성 실패:", error)
+ toast.error("PCR 생성에 실패했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 승인 핸들러
+ const handleApprove = async () => {
+ if (isLoading) return
+
+ try {
+ setIsLoading(true)
+ const result = await acceptContract(row.original.id)
+
+ if (result.success) {
+ toast.success(result.message)
+ // 필요한 경우 테이블 리프레시 로직 추가
+ }
+ } catch (error) {
+ console.error("계약 승인 실패:", error)
+ toast.error("계약 승인에 실패했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 승인 취소 핸들러
+ const handleCancelApprove = async () => {
+ if (isLoading) return
+
+ try {
+ setIsLoading(true)
+ const result = await cancelAcceptContract(row.original.id)
+
+ if (result.success) {
+ toast.success(result.message)
+ // 필요한 경우 테이블 리프레시 로직 추가
+ }
+ } catch (error) {
+ console.error("승인 취소 실패:", error)
+ toast.error("승인 취소에 실패했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 계약 거절 다이얼로그 열기
+ const handleRejectClick = () => {
+ if (isLoading || !canReject) return
+ setRejectDialogOpen(true)
+ }
+
+ // 계약 거절 확인
+ const handleRejectConfirm = async () => {
+ if (!rejectionReason.trim()) {
+ toast.error("거절 사유를 입력해주세요.")
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ setRejectDialogOpen(false)
+
+ const result = await rejectContract(row.original.id, rejectionReason.trim())
+
+ if (result.success) {
+ toast.success(result.message)
+ setRejectionReason("") // 입력값 초기화
+ }
+ } catch (error) {
+ console.error("계약 거절 실패:", error)
+ toast.error("계약 거절에 실패했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 계약 거절 취소
+ const handleRejectCancel = () => {
+ setRejectDialogOpen(false)
+ setRejectionReason("")
+ }
+
+ return (
+ <div className="flex justify-center">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0" disabled={isLoading}>
+ <span className="sr-only">Open menu</span>
+ <MoreHorizontalIcon className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuLabel>액션</DropdownMenuLabel>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "view-items" })}
+ >
+ <FileTextIcon className="mr-2 h-4 w-4" />
+ 상세품목 보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={handlePcrCreate}
+ disabled={isLoading || !canCreatePcr}
+ >
+ <PlusIcon className="mr-2 h-4 w-4" />
+ PCR생성
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={handleApprove}
+ disabled={isLoading || !canApprove}
+ >
+ 승인
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={handleCancelApprove}
+ disabled={isLoading || !canCancelApprove}
+ >
+ 승인취소
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={handleRejectClick}
+ className="text-red-600"
+ disabled={isLoading || !canReject}
+ >
+ <FileXIcon className="mr-2 h-4 w-4" />
+ 계약거절
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "print-contract" })}
+ disabled={isLoading}
+ >
+ <PrinterIcon className="mr-2 h-4 w-4" />
+ 계약서출력
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "contract-detail" })}
+ disabled={isLoading}
+ >
+ <EyeIcon className="mr-2 h-4 w-4" />
+ 계약상세
+ </DropdownMenuItem>
+
+
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "price-index" })}
+ disabled={isLoading}
+ >
+ 연동표입력
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 계약 거절 다이얼로그 */}
+ <Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>계약 거절</DialogTitle>
+ <DialogDescription>
+ 계약을 거절하는 사유를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid gap-2">
+ <Label htmlFor="rejection-reason">거절 사유</Label>
+ <Textarea
+ id="rejection-reason"
+ placeholder="거절 사유를 상세히 입력해주세요..."
+ value={rejectionReason}
+ onChange={(e) => setRejectionReason(e.target.value)}
+ rows={4}
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleRejectCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ variant="destructive"
+ onClick={handleRejectConfirm}
+ disabled={isLoading || !rejectionReason.trim()}
+ >
+ {isLoading ? "처리 중..." : "거절하기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+}
diff --git a/lib/po/vendor-table/vendor-po-columns.tsx b/lib/po/vendor-table/vendor-po-columns.tsx
index 0910eaf8..c954b872 100644
--- a/lib/po/vendor-table/vendor-po-columns.tsx
+++ b/lib/po/vendor-table/vendor-po-columns.tsx
@@ -2,43 +2,16 @@
import * as React from "react"
import { type ColumnDef } from "@tanstack/react-table"
-import {
- FileTextIcon,
- MoreHorizontalIcon,
- EyeIcon,
- PrinterIcon,
- FileXIcon,
- PlusIcon,
- EditIcon
-} from "lucide-react"
+import { FileTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { VendorPO, VendorPOActionType } from "./types"
-
-// 벤더 PO용 행 액션 타입
-type VendorPORowAction = {
- row: { original: VendorPO }
- type: VendorPOActionType
-}
+import { VendorPOActions } from "./vendor-po-actions"
interface GetVendorColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<VendorPORowAction | null>>
+ setRowAction: React.Dispatch<React.SetStateAction<{ row: { original: VendorPO }; type: VendorPOActionType } | null>>
selectedRows?: number[]
onRowSelect?: (id: number, selected: boolean) => void
}
@@ -333,18 +306,19 @@ export function getVendorColumns({ setRowAction, selectedRows = [], onRowSelect
size: 120,
},
+ // 데이터 및 룰이 없어 구현불가한 컬럼 주석 처리 --> 안내됨
// L/C No.
- {
- accessorKey: "lcNo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="L/C No." />
- ),
- cell: ({ row }) => {
- const lcNo = row.getValue("lcNo") as string
- return <div className="text-sm">{lcNo || '-'}</div>
- },
- size: 120,
- },
+ // {
+ // accessorKey: "lcNo",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="L/C No." />
+ // ),
+ // cell: ({ row }) => {
+ // const lcNo = row.getValue("lcNo") as string
+ // return <div className="text-sm">{lcNo || '-'}</div>
+ // },
+ // size: 120,
+ // },
// 납품대금 연동제 대상
{
@@ -403,107 +377,7 @@ export function getVendorColumns({ setRowAction, selectedRows = [], onRowSelect
id: "actions",
enableHiding: false,
header: () => <div className="text-center">액션</div>,
- cell: function Cell({ row }) {
- return (
- <div className="flex gap-1">
- {/* 상세품목 버튼 */}
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2"
- onClick={() => setRowAction({ row, type: "view-items" })}
- >
- <FileTextIcon className="h-3.5 w-3.5" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- 상세품목 보기
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
-
- {/* 드롭다운 메뉴
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">Open menu</span>
- <MoreHorizontalIcon className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuLabel>액션</DropdownMenuLabel>
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "view-items" })}
- >
- <FileTextIcon className="mr-2 h-4 w-4" />
- 상세품목 보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "pcr-create" })}
- >
- <PlusIcon className="mr-2 h-4 w-4" />
- PCR생성
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "approve" })}
- >
- 승인
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "cancel-approve" })}
- >
- 승인취소
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "reject-contract" })}
- className="text-red-600"
- >
- <FileXIcon className="mr-2 h-4 w-4" />
- 계약거절
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "print-contract" })}
- >
- <PrinterIcon className="mr-2 h-4 w-4" />
- 계약서출력
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "contract-detail" })}
- >
- <EyeIcon className="mr-2 h-4 w-4" />
- 계약상세
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "po-note" })}
- >
- <EditIcon className="mr-2 h-4 w-4" />
- PO Note
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "price-index" })}
- >
- 연동표입력
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu> */}
- </div>
- );
- },
+ cell: ({ row }) => <VendorPOActions row={row} setRowAction={setRowAction} />,
size: 120,
minSize: 100,
},
diff --git a/lib/po/vendor-table/vendor-po-items-dialog.tsx b/lib/po/vendor-table/vendor-po-items-dialog.tsx
index d3b33371..647950c4 100644
--- a/lib/po/vendor-table/vendor-po-items-dialog.tsx
+++ b/lib/po/vendor-table/vendor-po-items-dialog.tsx
@@ -103,12 +103,12 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia
<TableHead className="min-w-[100px] whitespace-nowrap">단가기준</TableHead>
<TableHead className="min-w-[100px] whitespace-nowrap">자재번호</TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap">품목/자재내역</TableHead>
- <TableHead className="min-w-[200px] whitespace-nowrap">자재내역사양</TableHead>
+ {/* <TableHead className="min-w-[200px] whitespace-nowrap">자재내역사양</TableHead>
<TableHead className="min-w-[120px] whitespace-nowrap">설계자재번호</TableHead>
<TableHead className="min-w-[100px] whitespace-nowrap">Fitting No.</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">Cert.</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">재질</TableHead>
- <TableHead className="min-w-[150px] whitespace-nowrap">규격</TableHead>
+ <TableHead className="min-w-[150px] whitespace-nowrap">규격</TableHead> */}
<TableHead className="min-w-[80px] text-right whitespace-nowrap">수량</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">수량단위</TableHead>
<TableHead className="min-w-[80px] text-right whitespace-nowrap">중량</TableHead>
@@ -121,7 +121,7 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia
<TableHead className="min-w-[100px] text-right whitespace-nowrap">조정금액</TableHead>
<TableHead className="min-w-[100px] whitespace-nowrap">납기일자</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">VAT구분</TableHead>
- <TableHead className="min-w-[120px] whitespace-nowrap">철의장 SPEC</TableHead>
+ {/* <TableHead className="min-w-[120px] whitespace-nowrap">철의장 SPEC</TableHead> */}
<TableHead className="min-w-[100px] whitespace-nowrap">P/R 담당자</TableHead>
</TableRow>
</TableHeader>
@@ -139,7 +139,8 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia
{item.itemDescription || '-'}
</div>
</TableCell>
- <TableCell className="max-w-[200px]">
+ {/* 자재내역사양~규격 까지 받은 정보 없음 */}
+ {/* <TableCell className="max-w-[200px]">
<div className="truncate" title={item.materialSpec || ''}>
{item.materialSpec || '-'}
</div>
@@ -148,7 +149,7 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia
<TableCell>{item.fittingNo || '-'}</TableCell>
<TableCell>{item.cert || '-'}</TableCell>
<TableCell>{item.material || '-'}</TableCell>
- <TableCell>{item.specification || '-'}</TableCell>
+ <TableCell>{item.specification || '-'}</TableCell> */}
<TableCell className="text-right font-mono">
{item.quantity?.toLocaleString() || '-'}
</TableCell>
@@ -173,7 +174,7 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia
</TableCell>
<TableCell>{item.deliveryDate || '-'}</TableCell>
<TableCell>{item.vatType || '-'}</TableCell>
- <TableCell>{item.steelSpec || '-'}</TableCell>
+ {/* <TableCell>{item.steelSpec || '-'}</TableCell> */}
<TableCell>{item.prManager || '-'}</TableCell>
</TableRow>
))}
diff --git a/lib/po/vendor-table/vendor-po-note-dialog.tsx b/lib/po/vendor-table/vendor-po-note-dialog.tsx
new file mode 100644
index 00000000..fbc14563
--- /dev/null
+++ b/lib/po/vendor-table/vendor-po-note-dialog.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { FileTextIcon, MessageSquareIcon } from "lucide-react"
+import { VendorPO } from "./types"
+
+interface VendorPONoteDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ po: VendorPO | null
+}
+
+interface PONoteItem {
+ itemNo: string
+ description: string
+ remark: string
+}
+
+export function VendorPONoteDialog({
+ open,
+ onOpenChange,
+ po,
+}: VendorPONoteDialogProps) {
+ // 계약서 내용 및 노트 데이터를 추출하는 함수
+ const extractContent = React.useCallback(() => {
+ if (!po) return { contractContent: null, remarks: null, itemNotes: [] }
+
+ const contractContent = po.contractContent || null // contracts.contractContent (ZMM_NOTE에서 추출)
+ const remarks = po.remarks || null // contracts.remarks (추가 비고)
+ const itemNotes: PONoteItem[] = []
+
+ // items 배열에서 remark이 있는 항목들 추출
+ if (po.items && po.items.length > 0) {
+ po.items.forEach((item, index) => {
+ if (item.remark && item.remark.trim()) {
+ itemNotes.push({
+ itemNo: item.itemNo || `Item ${index + 1}`,
+ description: item.itemDescription || item.materialSpec || '',
+ remark: item.remark,
+ })
+ }
+ })
+ }
+
+ return { contractContent, remarks, itemNotes }
+ }, [po])
+
+ const { contractContent, remarks, itemNotes } = extractContent()
+ const hasAnyContent = contractContent || remarks || itemNotes.length > 0
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileTextIcon className="h-5 w-5" />
+ 계약서 내용 - {po?.contractNo}
+ </DialogTitle>
+ <DialogDescription>
+ {po?.contractName} 계약의 계약서 내용 및 관련 노트입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[60vh] pr-4">
+ <div className="space-y-6">
+ {/* 계약서 내용 (메인) */}
+ {contractContent && (
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <FileTextIcon className="h-4 w-4 text-blue-600" />
+ <h3 className="text-sm font-semibold text-blue-600">
+ 계약서 내용
+ </h3>
+ </div>
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
+ <p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
+ {contractContent}
+ </p>
+ </div>
+ </div>
+ )}
+
+ {/* 구분선 */}
+ {contractContent && (remarks || itemNotes.length > 0) && <Separator />}
+
+ {/* 계약 비고 */}
+ {remarks && (
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <MessageSquareIcon className="h-4 w-4 text-orange-600" />
+ <h3 className="text-sm font-semibold text-orange-600">
+ 계약 비고
+ </h3>
+ </div>
+ <div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
+ <p className="text-sm text-gray-700 whitespace-pre-wrap">
+ {remarks}
+ </p>
+ </div>
+ </div>
+ )}
+
+ {/* 구분선 */}
+ {(contractContent || remarks) && itemNotes.length > 0 && <Separator />}
+
+ {/* 아이템별 노트들 */}
+ {itemNotes.length > 0 && (
+ <div className="space-y-4">
+ <div className="flex items-center gap-2">
+ <FileTextIcon className="h-4 w-4 text-green-600" />
+ <h3 className="text-sm font-semibold text-green-600">
+ 품목별 노트 ({itemNotes.length}개)
+ </h3>
+ </div>
+
+ <div className="space-y-3">
+ {itemNotes.map((item, index) => (
+ <div key={index} className="bg-green-50 border border-green-200 rounded-lg p-4">
+ <div className="flex items-start gap-3">
+ <Badge variant="outline" className="text-green-700 border-green-300">
+ {item.itemNo}
+ </Badge>
+ <div className="flex-1 min-w-0">
+ {item.description && (
+ <p className="text-sm font-medium text-gray-900 mb-1 truncate" title={item.description}>
+ {item.description}
+ </p>
+ )}
+ <p className="text-sm text-gray-700 whitespace-pre-wrap">
+ {item.remark}
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 내용이 없는 경우 */}
+ {!hasAnyContent && (
+ <div className="text-center py-8">
+ <FileTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500">등록된 계약서 내용이 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/po/vendor-table/vendor-po-table.tsx b/lib/po/vendor-table/vendor-po-table.tsx
index a3ad4949..99b0e5eb 100644
--- a/lib/po/vendor-table/vendor-po-table.tsx
+++ b/lib/po/vendor-table/vendor-po-table.tsx
@@ -4,18 +4,19 @@ 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 { toast } from "sonner"
+import { useRouter, useParams } from "next/navigation"
import { getVendorPOs, handleVendorPOAction } from "./service"
import { getVendorColumns } from "./vendor-po-columns"
import { VendorPO, VendorPOActionType } from "./types"
import { VendorPOItemsDialog } from "./vendor-po-items-dialog"
+import { VendorPONoteDialog } from "./vendor-po-note-dialog"
import { VendorPOToolbarActions } from "./vendor-po-toolbar-actions"
interface VendorPoTableProps {
@@ -27,6 +28,10 @@ interface VendorPoTableProps {
}
export function VendorPoTable({ promises }: VendorPoTableProps) {
+ const router = useRouter()
+ const params = useParams()
+ const lng = params.lng as string
+
const [data, setData] = React.useState<{
data: VendorPO[];
pageCount: number;
@@ -43,10 +48,11 @@ export function VendorPoTable({ promises }: VendorPoTableProps) {
}, [promises]);
const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<VendorPO> | null>(null)
+ React.useState<{ row: { original: VendorPO }; type: VendorPOActionType } | null>(null)
// 다이얼로그 상태
const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [noteDialogOpen, setNoteDialogOpen] = React.useState(false)
const [selectedPO, setSelectedPO] = React.useState<VendorPO | null>(null)
// 행 선택 처리 (1개만 선택 가능)
@@ -88,10 +94,10 @@ export function VendorPoTable({ promises }: VendorPoTableProps) {
setItemsDialogOpen(true)
break
case "contract-detail":
- toast.info("계약상세 기능은 개발 중입니다.")
+ router.push(`/${lng}/partners/po/${po.id}`)
break
case "po-note":
- toast.info("PO Note 기능은 개발 중입니다.")
+ setNoteDialogOpen(true)
break
case "price-index":
toast.info("연동표입력 기능은 개발 중입니다.")
@@ -242,6 +248,12 @@ export function VendorPoTable({ promises }: VendorPoTableProps) {
onOpenChange={setItemsDialogOpen}
po={selectedPO}
/>
+
+ <VendorPONoteDialog
+ open={noteDialogOpen}
+ onOpenChange={setNoteDialogOpen}
+ po={selectedPO}
+ />
</>
)
} \ No newline at end of file
diff --git a/lib/po/vendor-table/vendor-po-toolbar-actions.tsx b/lib/po/vendor-table/vendor-po-toolbar-actions.tsx
index 800a9e40..86e4379f 100644
--- a/lib/po/vendor-table/vendor-po-toolbar-actions.tsx
+++ b/lib/po/vendor-table/vendor-po-toolbar-actions.tsx
@@ -34,15 +34,15 @@ interface VendorPOToolbarActionsProps {
onViewItems?: (po: VendorPO) => void
}
-export function VendorPOToolbarActions({
- table,
- selectedRows,
+export function VendorPOToolbarActions({
+ table,
+ selectedRows,
onAction,
onViewItems
}: VendorPOToolbarActionsProps) {
const hasSelectedRow = selectedRows.length === 1
- const selectedPO = hasSelectedRow ?
- table.getRowModel().rows.find(row => selectedRows.includes(row.original.id))?.original
+ const selectedPO = hasSelectedRow ?
+ table.getRowModel().rows.find(row => selectedRows.includes(row.original.id))?.original
: null
const handleToolbarAction = async (action: string) => {
@@ -65,18 +65,18 @@ export function VendorPOToolbarActions({
return (
<div className="flex items-center gap-2">
{/* 주요 액션 버튼들 */}
- <TooltipProvider>
+ {/* <TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
- <Button
- variant="default"
- size="sm"
- onClick={() => handleToolbarAction("pcr-create")}
- disabled={!hasSelectedRow}
- className="h-8"
- >
- PCR생성
- </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => handleToolbarAction("pcr-create")}
+ disabled={!hasSelectedRow}
+ className="h-8"
+ >
+ PCR생성
+ </Button>
</TooltipTrigger>
<TooltipContent>선택된 PO에 대한 PCR을 생성합니다</TooltipContent>
</Tooltip>
@@ -97,10 +97,10 @@ export function VendorPOToolbarActions({
</TooltipTrigger>
<TooltipContent>상세품목 현황을 확인합니다</TooltipContent>
</Tooltip>
- </TooltipProvider>
+ </TooltipProvider> */}
{/* 승인 관련 액션 */}
- {selectedPO?.contractStatus !== "승인완료" && (
+ {/* {selectedPO?.contractStatus !== "승인완료" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -136,10 +136,10 @@ export function VendorPOToolbarActions({
<TooltipContent>승인을 취소합니다</TooltipContent>
</Tooltip>
</TooltipProvider>
- )}
+ )} */}
{/* 더 많은 액션 드롭다운 */}
- <DropdownMenu>
+ {/* <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -153,7 +153,7 @@ export function VendorPOToolbarActions({
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuLabel>계약 관련</DropdownMenuLabel>
<DropdownMenuSeparator />
-
+
<DropdownMenuItem
onClick={() => handleToolbarAction("contract-detail")}
disabled={!hasSelectedRow}
@@ -161,7 +161,7 @@ export function VendorPOToolbarActions({
<EyeIcon className="mr-2 h-4 w-4" />
계약상세
</DropdownMenuItem>
-
+
<DropdownMenuItem
onClick={() => handleToolbarAction("po-note")}
disabled={!hasSelectedRow}
@@ -169,7 +169,7 @@ export function VendorPOToolbarActions({
<EditIcon className="mr-2 h-4 w-4" />
PO Note
</DropdownMenuItem>
-
+
<DropdownMenuItem
onClick={() => handleToolbarAction("price-index")}
disabled={!hasSelectedRow}
@@ -179,7 +179,7 @@ export function VendorPOToolbarActions({
</DropdownMenuItem>
<DropdownMenuSeparator />
-
+
<DropdownMenuItem
onClick={() => handleToolbarAction("reject-contract")}
disabled={!hasSelectedRow}
@@ -188,9 +188,9 @@ export function VendorPOToolbarActions({
<FileXIcon className="mr-2 h-4 w-4" />
계약거절
</DropdownMenuItem>
-
+
<DropdownMenuSeparator />
-
+
<DropdownMenuItem
onClick={() => handleToolbarAction("print-contract")}
disabled={!hasSelectedRow}
@@ -199,16 +199,7 @@ export function VendorPOToolbarActions({
계약서출력
</DropdownMenuItem>
</DropdownMenuContent>
- </DropdownMenu>
-
- {/* 선택된 행 정보 표시
- {hasSelectedRow && selectedPO && (
- <div className="flex items-center gap-2 ml-4 text-sm text-muted-foreground">
- <span>선택됨:</span>
- <span className="font-medium">{selectedPO.contractNo}</span>
- <span>({selectedPO.contractName})</span>
- </div>
- )} */}
+ </DropdownMenu> */}
</div>
)
}
diff --git a/lib/soap/ecc/mapper/po-mapper.ts b/lib/soap/ecc/mapper/po-mapper.ts
index 9303cbcd..847ffdc0 100644
--- a/lib/soap/ecc/mapper/po-mapper.ts
+++ b/lib/soap/ecc/mapper/po-mapper.ts
@@ -14,8 +14,14 @@ import {
import { eq } from 'drizzle-orm';
// ECC 데이터 타입 정의
-export type ECCPOHeader = typeof ZMM_HD.$inferInsert;
+export type ECCPOHeader = typeof ZMM_HD.$inferInsert & {
+ notes?: ECCPONote[];
+};
export type ECCPODetail = typeof ZMM_DT.$inferInsert;
+export type ECCPONote = {
+ ZNOTE_SER: string;
+ ZNOTE_TXT: string;
+};
// 비즈니스 테이블 데이터 타입 정의
export type ContractData = typeof contracts.$inferInsert;
@@ -108,12 +114,26 @@ export async function mapECCPOHeaderToBusiness(
throw new Error(`벤더를 찾을 수 없습니다: LIFNR=${eccHeader.LIFNR}`);
}
+ // 계약서 내용 구성 (ZMM_NOTE에서 가져옴)
+ let contractContent: string | null = null;
+ if (eccHeader.notes && eccHeader.notes.length > 0) {
+ // ZNOTE_SER 순번으로 정렬 후 텍스트 합치기
+ const sortedNotes = eccHeader.notes.sort((a, b) =>
+ parseInt(a.ZNOTE_SER) - parseInt(b.ZNOTE_SER)
+ );
+ contractContent = sortedNotes
+ .map(note => note.ZNOTE_TXT || '')
+ .filter(text => text.trim() !== '')
+ .join('\n\n');
+ }
+
// 매핑 - SAP ECC 필드명과 함께 주석 추가
const mappedData: ContractData = {
projectId,
vendorId,
contractNo: eccHeader.EBELN || '', // EBELN - 구매오더번호
contractName: eccHeader.ZTITLE || eccHeader.EBELN || '', // ZTITLE - 발주제목
+ contractContent, // 계약서 내용
status: eccHeader.ZPO_CNFM_STAT || 'ACTIVE', // ZPO_CNFM_STAT - 구매오더확인상태
startDate: parseDate(eccHeader.ZPO_DT || null), // ZPO_DT - 발주일자
endDate: null, // ZMM_DT에서 가져와야 함