diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-13 17:29:33 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-13 17:29:33 +0900 |
| commit | e84cf02a1cb4959a9d3bb5bbf37885c13a447f78 (patch) | |
| tree | cfb2817e3bd8f5ef08b4428b9e6fc619ef3884a1 | |
| parent | 89274bffa596ffdfc4275fb8d11cdb02ff9a2d02 (diff) | |
(김준회) SHI/벤더 PO 구현
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에서 가져와야 함 |
