summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-13 17:29:33 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-13 17:29:33 +0900
commite84cf02a1cb4959a9d3bb5bbf37885c13a447f78 (patch)
treecfb2817e3bd8f5ef08b4428b9e6fc619ef3884a1 /app
parent89274bffa596ffdfc4275fb8d11cdb02ff9a2d02 (diff)
(김준회) SHI/벤더 PO 구현
Diffstat (limited to 'app')
-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
6 files changed, 512 insertions, 3 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
}));