summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/admin/edp/actions/contract-actions.ts200
-rw-r--r--app/[lng]/admin/edp/actions/data-actions.ts155
-rw-r--r--app/[lng]/admin/edp/components/contract-edit-form.tsx231
-rw-r--r--app/[lng]/admin/edp/components/contract-form.tsx145
-rw-r--r--app/[lng]/admin/edp/components/contract-items-edit-form.tsx204
-rw-r--r--app/[lng]/admin/edp/components/contract-items-form.tsx230
-rw-r--r--app/[lng]/admin/edp/components/contract-selector.tsx334
-rw-r--r--app/[lng]/admin/edp/components/item-selector.tsx368
-rw-r--r--app/[lng]/admin/edp/components/project-selector.tsx258
-rw-r--r--app/[lng]/admin/edp/components/vendor-selector.tsx281
-rw-r--r--app/[lng]/admin/edp/page.tsx69
-rw-r--r--app/[lng]/admin/edp/types/item.ts18
-rw-r--r--app/[lng]/admin/layout.tsx34
13 files changed, 2527 insertions, 0 deletions
diff --git a/app/[lng]/admin/edp/actions/contract-actions.ts b/app/[lng]/admin/edp/actions/contract-actions.ts
new file mode 100644
index 00000000..f6e1bed2
--- /dev/null
+++ b/app/[lng]/admin/edp/actions/contract-actions.ts
@@ -0,0 +1,200 @@
+'use server'
+
+import db from '@/db/db'
+import { contracts, contractItems } from '@/db/schema/contract'
+import { eq } from 'drizzle-orm'
+
+// 계약 생성 타입 정의
+export interface CreateContractData {
+ projectId: number
+ vendorId: number
+ contractName: string
+ status?: string
+}
+
+// 계약 아이템 생성 타입 정의
+export interface CreateContractItemData {
+ contractId: number
+ itemId: number
+ description?: string
+ quantity?: number
+ unitPrice?: number
+}
+
+// 계약 생성
+export async function createContract(data: CreateContractData) {
+ try {
+ // contractNo 생성 (간단한 방식으로 timestamp 기반)
+ const contractNo = `TEST-${Date.now()}`
+
+ const [newContract] = await db.insert(contracts).values({
+ projectId: data.projectId,
+ vendorId: data.vendorId,
+ contractNo,
+ contractName: data.contractName,
+ status: data.status || 'TEST',
+ }).returning({
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ })
+
+ return {
+ success: true,
+ data: newContract,
+ message: '계약이 성공적으로 생성되었습니다.'
+ }
+ } catch (error) {
+ console.error('계약 생성 오류:', error)
+ return {
+ success: false,
+ error: '계약을 생성할 수 없습니다.'
+ }
+ }
+}
+
+// 계약 아이템 생성
+export async function createContractItem(data: CreateContractItemData) {
+ try {
+ const [newContractItem] = await db.insert(contractItems).values({
+ contractId: data.contractId,
+ itemId: data.itemId,
+ description: data.description,
+ quantity: data.quantity || 1,
+ unitPrice: data.unitPrice,
+ }).returning({
+ id: contractItems.id,
+ contractId: contractItems.contractId,
+ itemId: contractItems.itemId,
+ quantity: contractItems.quantity,
+ })
+
+ return {
+ success: true,
+ data: newContractItem,
+ message: '계약 아이템이 성공적으로 생성되었습니다.'
+ }
+ } catch (error) {
+ console.error('계약 아이템 생성 오류:', error)
+ return {
+ success: false,
+ error: '계약 아이템을 생성할 수 없습니다.'
+ }
+ }
+}
+
+// 여러 계약 아이템 일괄 생성
+export async function createMultipleContractItems(contractId: number, itemsData: Omit<CreateContractItemData, 'contractId'>[]) {
+ try {
+ const itemsToInsert = itemsData.map(item => ({
+ contractId,
+ itemId: item.itemId,
+ description: item.description,
+ quantity: item.quantity || 1,
+ unitPrice: item.unitPrice,
+ }))
+
+ const newContractItems = await db.insert(contractItems).values(itemsToInsert).returning({
+ id: contractItems.id,
+ contractId: contractItems.contractId,
+ itemId: contractItems.itemId,
+ quantity: contractItems.quantity,
+ })
+
+ return {
+ success: true,
+ data: newContractItems,
+ message: `${newContractItems.length}개의 계약 아이템이 성공적으로 생성되었습니다.`
+ }
+ } catch (error) {
+ console.error('계약 아이템 일괄 생성 오류:', error)
+ return {
+ success: false,
+ error: '계약 아이템을 일괄 생성할 수 없습니다.'
+ }
+ }
+}
+
+// 계약 수정
+export async function updateContract(contractId: number, data: Partial<CreateContractData>) {
+ try {
+ const [updatedContract] = await db.update(contracts)
+ .set({
+ projectId: data.projectId,
+ vendorId: data.vendorId,
+ contractName: data.contractName,
+ status: data.status,
+ updatedAt: new Date(),
+ })
+ .where(eq(contracts.id, contractId))
+ .returning({
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ })
+
+ return {
+ success: true,
+ data: updatedContract,
+ message: '계약이 성공적으로 수정되었습니다.'
+ }
+ } catch (error) {
+ console.error('계약 수정 오류:', error)
+ return {
+ success: false,
+ error: '계약을 수정할 수 없습니다.'
+ }
+ }
+}
+
+// 계약 아이템 수정
+export async function updateContractItem(contractItemId: number, data: Partial<CreateContractItemData>) {
+ try {
+ const [updatedContractItem] = await db.update(contractItems)
+ .set({
+ description: data.description,
+ quantity: data.quantity,
+ unitPrice: data.unitPrice,
+ updatedAt: new Date(),
+ })
+ .where(eq(contractItems.id, contractItemId))
+ .returning({
+ id: contractItems.id,
+ contractId: contractItems.contractId,
+ itemId: contractItems.itemId,
+ quantity: contractItems.quantity,
+ })
+
+ return {
+ success: true,
+ data: updatedContractItem,
+ message: '계약 아이템이 성공적으로 수정되었습니다.'
+ }
+ } catch (error) {
+ console.error('계약 아이템 수정 오류:', error)
+ return {
+ success: false,
+ error: '계약 아이템을 수정할 수 없습니다.'
+ }
+ }
+}
+
+// 계약 아이템 삭제
+export async function deleteContractItem(contractItemId: number) {
+ try {
+ await db.delete(contractItems).where(eq(contractItems.id, contractItemId))
+
+ return {
+ success: true,
+ message: '계약 아이템이 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('계약 아이템 삭제 오류:', error)
+ return {
+ success: false,
+ error: '계약 아이템을 삭제할 수 없습니다.'
+ }
+ }
+}
diff --git a/app/[lng]/admin/edp/actions/data-actions.ts b/app/[lng]/admin/edp/actions/data-actions.ts
new file mode 100644
index 00000000..66fdc919
--- /dev/null
+++ b/app/[lng]/admin/edp/actions/data-actions.ts
@@ -0,0 +1,155 @@
+'use server'
+
+import db from '@/db/db'
+import { projects } from '@/db/schema/projects'
+import { vendors } from '@/db/schema/vendors'
+import { items } from '@/db/schema/items'
+import { contracts, contractItems } from '@/db/schema/contract'
+import { eq } from 'drizzle-orm'
+
+// 프로젝트 목록 조회
+export async function getProjects() {
+ try {
+ const projectList = await db.select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ type: projects.type,
+ }).from(projects)
+
+ return { success: true, data: projectList }
+ } catch (error) {
+ console.error('프로젝트 조회 오류:', error)
+ return { success: false, error: '프로젝트를 조회할 수 없습니다.' }
+ }
+}
+
+// 벤더 목록 조회
+export async function getVendors() {
+ try {
+ const vendorList = await db.select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ status: vendors.status,
+ }).from(vendors)
+
+ return { success: true, data: vendorList }
+ } catch (error) {
+ console.error('벤더 조회 오류:', error)
+ return { success: false, error: '벤더를 조회할 수 없습니다.' }
+ }
+}
+
+// 아이템 목록 조회 (모든 필드 포함)
+export async function getItems() {
+ try {
+ const itemList = await db.select({
+ id: items.id,
+ ProjectNo: items.ProjectNo,
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ packageCode: items.packageCode,
+ smCode: items.smCode,
+ description: items.description,
+ parentItemCode: items.parentItemCode,
+ itemLevel: items.itemLevel,
+ deleteFlag: items.deleteFlag,
+ unitOfMeasure: items.unitOfMeasure,
+ steelType: items.steelType,
+ gradeMaterial: items.gradeMaterial,
+ changeDate: items.changeDate,
+ baseUnitOfMeasure: items.baseUnitOfMeasure,
+ }).from(items)
+
+ return { success: true, data: itemList }
+ } catch (error) {
+ console.error('아이템 조회 오류:', error)
+ return { success: false, error: '아이템을 조회할 수 없습니다.' }
+ }
+}
+
+// 기존 계약 목록 조회 (프로젝트 코드, 벤더명 포함)
+export async function getContracts() {
+ try {
+ const contractList = await db.select({
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ projectId: contracts.projectId,
+ vendorId: contracts.vendorId,
+ projectCode: projects.code,
+ projectName: projects.name,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(contracts)
+ .leftJoin(projects, eq(contracts.projectId, projects.id))
+ .leftJoin(vendors, eq(contracts.vendorId, vendors.id))
+
+ return { success: true, data: contractList }
+ } catch (error) {
+ console.error('계약 조회 오류:', error)
+ return { success: false, error: '계약을 조회할 수 없습니다.' }
+ }
+}
+
+// 특정 계약의 상세 정보 조회
+export async function getContractById(contractId: number) {
+ try {
+ const [contract] = await db.select({
+ id: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+ status: contracts.status,
+ projectId: contracts.projectId,
+ vendorId: contracts.vendorId,
+ projectCode: projects.code,
+ projectName: projects.name,
+ 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))
+
+ if (!contract) {
+ return { success: false, error: '계약을 찾을 수 없습니다.' }
+ }
+
+ return { success: true, data: contract }
+ } catch (error) {
+ console.error('계약 상세 조회 오류:', error)
+ return { success: false, error: '계약 상세 정보를 조회할 수 없습니다.' }
+ }
+}
+
+// 특정 계약의 아이템들 조회
+export async function getContractItems(contractId: number) {
+ try {
+ const contractItemList = await db.select({
+ id: contractItems.id,
+ contractId: contractItems.contractId,
+ itemId: contractItems.itemId,
+ description: contractItems.description,
+ quantity: contractItems.quantity,
+ unitPrice: contractItems.unitPrice,
+ // 아이템 정보
+ ProjectNo: items.ProjectNo,
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ packageCode: items.packageCode,
+ unitOfMeasure: items.unitOfMeasure,
+ })
+ .from(contractItems)
+ .leftJoin(items, eq(contractItems.itemId, items.id))
+ .where(eq(contractItems.contractId, contractId))
+
+ return { success: true, data: contractItemList }
+ } catch (error) {
+ console.error('계약 아이템 조회 오류:', error)
+ return { success: false, error: '계약 아이템을 조회할 수 없습니다.' }
+ }
+}
diff --git a/app/[lng]/admin/edp/components/contract-edit-form.tsx b/app/[lng]/admin/edp/components/contract-edit-form.tsx
new file mode 100644
index 00000000..5a9ec03e
--- /dev/null
+++ b/app/[lng]/admin/edp/components/contract-edit-form.tsx
@@ -0,0 +1,231 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { toast } from 'sonner'
+import { updateContract } from '../actions/contract-actions'
+import { getContractById } from '../actions/data-actions'
+import { ProjectSelector } from './project-selector'
+import { VendorSelector } from './vendor-selector'
+import { ContractSelector } from './contract-selector'
+
+interface Project {
+ id: number
+ code: string
+ name: string
+ type: string
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+}
+
+interface Contract {
+ id: number
+ contractNo: string
+ contractName: string
+ status: string
+ projectId: number
+ vendorId: number
+ projectCode: string | null
+ projectName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+}
+
+interface ContractEditFormProps {
+ onContractUpdated?: (contract: { id: number; contractNo: string; contractName: string; status: string }) => void
+}
+
+export function ContractEditForm({ onContractUpdated }: ContractEditFormProps) {
+ const [loading, setLoading] = useState(false)
+ const [selectedContract, setSelectedContract] = useState<Contract | undefined>()
+ const [selectedProject, setSelectedProject] = useState<Project | undefined>()
+ const [selectedVendor, setSelectedVendor] = useState<Vendor | undefined>()
+ const [formData, setFormData] = useState({
+ contractName: '',
+ status: 'TEST'
+ })
+
+ // 계약 선택 시 데이터 로드
+ useEffect(() => {
+ if (selectedContract) {
+ loadContractData(selectedContract.id)
+ }
+ }, [selectedContract])
+
+ const loadContractData = async (contractId: number) => {
+ try {
+ const result = await getContractById(contractId)
+ if (result.success) {
+ const contract = result.data
+ setFormData({
+ contractName: contract.contractName,
+ status: contract.status
+ })
+
+ // 프로젝트 정보 설정
+ if (contract.projectId && contract.projectCode) {
+ setSelectedProject({
+ id: contract.projectId,
+ code: contract.projectCode,
+ name: contract.projectName || '',
+ type: ''
+ })
+ }
+
+ // 벤더 정보 설정
+ if (contract.vendorId && contract.vendorName) {
+ setSelectedVendor({
+ id: contract.vendorId,
+ vendorName: contract.vendorName,
+ vendorCode: contract.vendorCode,
+ status: 'ACTIVE'
+ })
+ }
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('계약 정보를 불러오는 중 오류가 발생했습니다.')
+ }
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!selectedContract) {
+ toast.error('수정할 계약을 선택해주세요.')
+ return
+ }
+
+ if (!selectedProject || !selectedVendor || !formData.contractName.trim()) {
+ toast.error('모든 필수 항목을 입력해주세요.')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const result = await updateContract(selectedContract.id, {
+ projectId: selectedProject.id,
+ vendorId: selectedVendor.id,
+ contractName: formData.contractName,
+ status: formData.status
+ })
+
+ if (result.success) {
+ toast.success(result.message)
+ onContractUpdated?.(result.data)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('계약 수정 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleContractSelect = (contract: Contract) => {
+ setSelectedContract(contract)
+ // 폼 초기화
+ setSelectedProject(undefined)
+ setSelectedVendor(undefined)
+ setFormData({
+ contractName: '',
+ status: 'TEST'
+ })
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>계약 수정</CardTitle>
+ <CardDescription>
+ 기존 계약의 정보를 수정합니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div>
+ <Label>수정할 계약 선택 *</Label>
+ <ContractSelector
+ selectedContract={selectedContract}
+ onContractSelect={handleContractSelect}
+ disabled={loading}
+ />
+ </div>
+
+ {selectedContract && (
+ <>
+ <div className="bg-muted/50 p-3 rounded-md">
+ <div className="text-sm font-medium">선택된 계약</div>
+ <div className="text-sm text-muted-foreground">
+ [{selectedContract.contractNo}] {selectedContract.contractName}
+ </div>
+ </div>
+
+ <div>
+ <Label>프로젝트 *</Label>
+ <ProjectSelector
+ selectedProject={selectedProject}
+ onProjectSelect={setSelectedProject}
+ disabled={loading}
+ />
+ </div>
+
+ <div>
+ <Label>벤더 *</Label>
+ <VendorSelector
+ selectedVendor={selectedVendor}
+ onVendorSelect={setSelectedVendor}
+ disabled={loading}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="contractName">계약명 *</Label>
+ <Input
+ id="contractName"
+ type="text"
+ value={formData.contractName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractName: e.target.value }))}
+ placeholder="계약명을 입력하세요"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="status">계약 상태</Label>
+ <Select
+ value={formData.status}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="TEST">TEST</SelectItem>
+ <SelectItem value="DRAFT">DRAFT</SelectItem>
+ <SelectItem value="ACTIVE">ACTIVE</SelectItem>
+ <SelectItem value="PENDING">PENDING</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Button type="submit" disabled={loading} className="w-full">
+ {loading ? '수정 중...' : '계약 수정'}
+ </Button>
+ </>
+ )}
+ </form>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/contract-form.tsx b/app/[lng]/admin/edp/components/contract-form.tsx
new file mode 100644
index 00000000..45c2f629
--- /dev/null
+++ b/app/[lng]/admin/edp/components/contract-form.tsx
@@ -0,0 +1,145 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { toast } from 'sonner'
+import { createContract, CreateContractData } from '../actions/contract-actions'
+import { ProjectSelector } from './project-selector'
+import { VendorSelector } from './vendor-selector'
+
+interface Project {
+ id: number
+ code: string
+ name: string
+ type: string
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+}
+
+interface ContractFormProps {
+ onContractCreated?: (contract: { id: number; contractNo: string; contractName: string; status: string }) => void
+}
+
+export function ContractForm({ onContractCreated }: ContractFormProps) {
+ const [loading, setLoading] = useState(false)
+ const [selectedProject, setSelectedProject] = useState<Project | undefined>()
+ const [selectedVendor, setSelectedVendor] = useState<Vendor | undefined>()
+ const [formData, setFormData] = useState({
+ contractName: '',
+ status: 'TEST'
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!selectedProject || !selectedVendor || !formData.contractName.trim()) {
+ toast.error('모든 필수 항목을 입력해주세요.')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const contractData: CreateContractData = {
+ projectId: selectedProject.id,
+ vendorId: selectedVendor.id,
+ contractName: formData.contractName,
+ status: formData.status
+ }
+
+ const result = await createContract(contractData)
+
+ if (result.success) {
+ toast.success(result.message)
+ // 폼 초기화
+ setSelectedProject(undefined)
+ setSelectedVendor(undefined)
+ setFormData({
+ contractName: '',
+ status: 'TEST'
+ })
+ // 부모 컴포넌트에 생성된 계약 정보 전달
+ onContractCreated?.(result.data)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('계약 생성 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>계약 생성</CardTitle>
+ <CardDescription>
+ 새로운 테스트 계약을 생성합니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div>
+ <Label>프로젝트 *</Label>
+ <ProjectSelector
+ selectedProject={selectedProject}
+ onProjectSelect={setSelectedProject}
+ disabled={loading}
+ />
+ </div>
+
+ <div>
+ <Label>벤더 *</Label>
+ <VendorSelector
+ selectedVendor={selectedVendor}
+ onVendorSelect={setSelectedVendor}
+ disabled={loading}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="contractName">계약명 *</Label>
+ <Input
+ id="contractName"
+ type="text"
+ value={formData.contractName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractName: e.target.value }))}
+ placeholder="계약명을 입력하세요"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="status">계약 상태</Label>
+ <Select
+ value={formData.status}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="TEST">TEST</SelectItem>
+ <SelectItem value="DRAFT">DRAFT</SelectItem>
+ <SelectItem value="ACTIVE">ACTIVE</SelectItem>
+ <SelectItem value="PENDING">PENDING</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Button type="submit" disabled={loading} className="w-full">
+ {loading ? '생성 중...' : '계약 생성'}
+ </Button>
+ </form>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/contract-items-edit-form.tsx b/app/[lng]/admin/edp/components/contract-items-edit-form.tsx
new file mode 100644
index 00000000..18fe75de
--- /dev/null
+++ b/app/[lng]/admin/edp/components/contract-items-edit-form.tsx
@@ -0,0 +1,204 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { toast } from 'sonner'
+import { deleteContractItem } from '../actions/contract-actions'
+import { getContractItems } from '../actions/data-actions'
+import { ContractSelector } from './contract-selector'
+import { Trash2 } from 'lucide-react'
+
+interface Contract {
+ id: number
+ contractNo: string
+ contractName: string
+ status: string
+ projectId: number
+ vendorId: number
+ projectCode: string | null
+ projectName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+}
+
+interface ContractItem {
+ id: number
+ contractId: number
+ itemId: number
+ description: string | null
+ quantity: number
+ unitPrice: number | null
+ ProjectNo: string | null
+ itemCode: string | null
+ itemName: string | null
+ packageCode: string | null
+ unitOfMeasure: string | null
+}
+
+interface ContractItemsEditFormProps {
+ preselectedContractId?: number
+}
+
+export function ContractItemsEditForm({ preselectedContractId }: ContractItemsEditFormProps) {
+ const [loading, setLoading] = useState(false)
+ const [selectedContract, setSelectedContract] = useState<Contract | undefined>()
+ const [contractItems, setContractItems] = useState<ContractItem[]>([])
+
+ // 계약 선택 시 아이템들 로드
+ useEffect(() => {
+ if (selectedContract) {
+ loadContractItems(selectedContract.id)
+ }
+ }, [selectedContract])
+
+ const loadContractItems = async (contractId: number) => {
+ setLoading(true)
+ try {
+ const result = await getContractItems(contractId)
+ if (result.success) {
+ setContractItems(result.data)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('계약 아이템을 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+
+
+ const handleDelete = async (itemId: number) => {
+ if (!confirm('정말로 이 계약 아이템을 삭제하시겠습니까?')) {
+ return
+ }
+
+ setLoading(true)
+ try {
+ const result = await deleteContractItem(itemId)
+
+ if (result.success) {
+ toast.success(result.message)
+ // 아이템 목록 새로고침
+ if (selectedContract) {
+ await loadContractItems(selectedContract.id)
+ }
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('계약 아이템 삭제 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>계약 아이템 삭제</CardTitle>
+ <CardDescription>
+ 기존 계약의 아이템들을 삭제할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-6">
+ {/* 계약 선택 */}
+ <div>
+ <Label>계약 선택 *</Label>
+ <ContractSelector
+ selectedContract={selectedContract}
+ onContractSelect={setSelectedContract}
+ disabled={loading}
+ preselectedContractId={preselectedContractId}
+ />
+ </div>
+
+ {selectedContract && (
+ <div className="bg-muted/50 p-3 rounded-md">
+ <div className="text-sm font-medium">선택된 계약</div>
+ <div className="text-sm text-muted-foreground">
+ [{selectedContract.contractNo}] {selectedContract.contractName}
+ </div>
+ </div>
+ )}
+
+ {/* 계약 아이템 목록 */}
+ {contractItems.length > 0 && (
+ <div>
+ <Label>계약 아이템 목록 ({contractItems.length}개)</Label>
+ <div className="mt-2 space-y-3 max-h-96 overflow-y-auto border rounded-md p-3">
+ {contractItems.map(item => (
+ <div key={item.id} className="p-3 bg-white border rounded-md">
+ <div className="flex items-start justify-between">
+ <div className="flex-1">
+ <div className="flex items-center gap-2 mb-2">
+ <span className="font-medium">{item.itemName || `아이템 ${item.itemId}`}</span>
+ {item.itemCode && (
+ <Badge variant="outline" className="text-xs">
+ {item.itemCode}
+ </Badge>
+ )}
+ {item.unitOfMeasure && (
+ <Badge variant="secondary" className="text-xs">
+ {item.unitOfMeasure}
+ </Badge>
+ )}
+ </div>
+
+ {item.ProjectNo && (
+ <div className="text-xs text-muted-foreground mb-1">
+ 프로젝트: {item.ProjectNo} | 패키지: {item.packageCode}
+ </div>
+ )}
+
+ {/* 보기 모드만 유지 */}
+ <div className="text-sm space-y-1">
+ <div>수량: {item.quantity} | 단가: {item.unitPrice || 0}</div>
+ {item.description && (
+ <div className="text-muted-foreground">설명: {item.description}</div>
+ )}
+ </div>
+ </div>
+
+ {/* 삭제 버튼만 유지 */}
+ <div className="flex gap-1 ml-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDelete(item.id)}
+ disabled={loading}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {selectedContract && contractItems.length === 0 && !loading && (
+ <div className="text-center py-8 text-muted-foreground">
+ 선택된 계약에 아이템이 없습니다.
+ </div>
+ )}
+
+ {loading && (
+ <div className="text-center py-8 text-muted-foreground">
+ 로딩 중...
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/contract-items-form.tsx b/app/[lng]/admin/edp/components/contract-items-form.tsx
new file mode 100644
index 00000000..ef8f09ef
--- /dev/null
+++ b/app/[lng]/admin/edp/components/contract-items-form.tsx
@@ -0,0 +1,230 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+
+import { toast } from 'sonner'
+import { createMultipleContractItems } from '../actions/contract-actions'
+import { ContractSelector } from './contract-selector'
+import { ItemSelector } from './item-selector'
+import { Item } from '../types/item'
+import { X } from 'lucide-react'
+
+interface Contract {
+ id: number
+ contractNo: string
+ contractName: string
+ status: string
+ projectId: number
+ vendorId: number
+ projectCode: string | null
+ projectName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+}
+
+
+
+interface SelectedItem {
+ itemId: number
+ ProjectNo: string
+ itemName: string
+ packageCode: string
+ itemCode: string | null
+ description?: string
+ quantity?: number
+ unitPrice?: number
+}
+
+interface ContractItemsFormProps {
+ preselectedContractId?: number
+}
+
+export function ContractItemsForm({ preselectedContractId }: ContractItemsFormProps) {
+ const [loading, setLoading] = useState(false)
+ const [selectedContract, setSelectedContract] = useState<Contract | undefined>()
+ const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([])
+ const [selectedItemIds, setSelectedItemIds] = useState<number[]>([])
+
+ // 아이템 선택 처리
+ const handleItemsSelect = (itemIds: number[], itemsData?: Item[]) => {
+ setSelectedItemIds(itemIds)
+
+ if (itemsData) {
+ // 새로운 아이템 데이터로 선택된 아이템 목록 업데이트
+ const newSelectedItems: SelectedItem[] = itemsData.map(item => {
+ // 기존 선택된 아이템에서 수량, 단가, 설명 정보 유지
+ const existing = selectedItems.find(selected => selected.itemId === item.id)
+ return {
+ itemId: item.id,
+ ProjectNo: item.ProjectNo,
+ itemName: item.itemName,
+ packageCode: item.packageCode,
+ itemCode: item.itemCode,
+ description: existing?.description || item.description || '',
+ quantity: existing?.quantity || 1,
+ unitPrice: existing?.unitPrice || 0
+ }
+ })
+ setSelectedItems(newSelectedItems)
+ } else {
+ // 기존 선택된 아이템들 중에서 새로 선택되지 않은 것들은 제거
+ setSelectedItems(prev => prev.filter(item => itemIds.includes(item.itemId)))
+ }
+ }
+
+ // 선택된 아이템 정보 반환 (이제 실제 데이터가 있음)
+ const getSelectedItemsWithDetails = (): SelectedItem[] => {
+ return selectedItems
+ }
+
+ // 선택된 아이템 정보 업데이트
+ const updateSelectedItem = (itemId: number, field: keyof SelectedItem, value: string | number) => {
+ setSelectedItems(prev => prev.map(item =>
+ item.itemId === itemId ? { ...item, [field]: value } : item
+ ))
+ }
+
+ // 선택된 아이템 제거
+ const removeSelectedItem = (itemId: number) => {
+ setSelectedItemIds(prev => prev.filter(id => id !== itemId))
+ setSelectedItems(prev => prev.filter(item => item.itemId !== itemId))
+ }
+
+ // 폼 제출
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!selectedContract) {
+ toast.error('계약을 선택해주세요.')
+ return
+ }
+
+ if (selectedItemIds.length === 0) {
+ toast.error('최소 하나의 아이템을 선택해주세요.')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const currentSelectedItems = getSelectedItemsWithDetails()
+ const itemsData = currentSelectedItems.map(item => ({
+ itemId: item.itemId,
+ description: item.description,
+ quantity: item.quantity || 1,
+ unitPrice: item.unitPrice || 0
+ }))
+
+ const result = await createMultipleContractItems(selectedContract.id, itemsData)
+
+ if (result.success) {
+ toast.success(result.message)
+ setSelectedItems([])
+ setSelectedItemIds([])
+ } else {
+ toast.error(result.error)
+ }
+ } catch {
+ toast.error('계약 아이템 생성 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>계약 아이템 생성</CardTitle>
+ <CardDescription>
+ 계약에 아이템들을 추가합니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={handleSubmit} className="space-y-6">
+ {/* 계약 선택 */}
+ <div>
+ <Label>계약 선택 *</Label>
+ <ContractSelector
+ selectedContract={selectedContract}
+ onContractSelect={setSelectedContract}
+ disabled={loading}
+ preselectedContractId={preselectedContractId}
+ />
+ </div>
+
+ {/* 선택된 아이템 목록 */}
+ {selectedItemIds.length > 0 && (
+ <div>
+ <Label>선택된 아이템 ({selectedItemIds.length}개)</Label>
+ <div className="mt-2 space-y-3 max-h-60 overflow-y-auto border rounded-md p-3">
+ {getSelectedItemsWithDetails().map(item => (
+ <div key={item.itemId} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
+ <div className="flex-1">
+ <div className="font-medium text-sm">
+ {item.itemName} {item.itemCode && `(${item.itemCode})`}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 프로젝트: {item.ProjectNo} | 패키지: {item.packageCode}
+ </div>
+ <div className="flex gap-2 mt-1">
+ <Input
+ type="number"
+ placeholder="수량"
+ value={item.quantity || ''}
+ onChange={(e) => updateSelectedItem(item.itemId, 'quantity', parseInt(e.target.value) || 1)}
+ className="w-20 h-8 text-xs"
+ />
+ <Input
+ type="number"
+ placeholder="단가"
+ value={item.unitPrice || ''}
+ onChange={(e) => updateSelectedItem(item.itemId, 'unitPrice', parseFloat(e.target.value) || 0)}
+ className="w-24 h-8 text-xs"
+ />
+ <Input
+ type="text"
+ placeholder="설명"
+ value={item.description || ''}
+ onChange={(e) => updateSelectedItem(item.itemId, 'description', e.target.value)}
+ className="flex-1 h-8 text-xs"
+ />
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeSelectedItem(item.itemId)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 선택 */}
+ <div>
+ <Label>아이템 선택</Label>
+ <div className="mt-1">
+ <ItemSelector
+ selectedItems={selectedItemIds}
+ onItemsSelect={handleItemsSelect}
+ disabled={loading}
+ />
+ </div>
+ </div>
+
+ <Button type="submit" disabled={loading || selectedItemIds.length === 0} className="w-full">
+ {loading ? '생성 중...' : `선택된 ${selectedItemIds.length}개 아이템 추가`}
+ </Button>
+ </form>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/contract-selector.tsx b/app/[lng]/admin/edp/components/contract-selector.tsx
new file mode 100644
index 00000000..88986a88
--- /dev/null
+++ b/app/[lng]/admin/edp/components/contract-selector.tsx
@@ -0,0 +1,334 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { getContracts } from '../actions/data-actions'
+import { toast } from 'sonner'
+
+interface Contract {
+ id: number
+ contractNo: string
+ contractName: string
+ status: string
+ projectId: number
+ vendorId: number
+ projectCode: string | null
+ projectName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+}
+
+interface ContractSelectorProps {
+ selectedContract?: Contract
+ onContractSelect: (contract: Contract) => void
+ disabled?: boolean
+ preselectedContractId?: number
+}
+
+export function ContractSelector({ selectedContract, onContractSelect, disabled, preselectedContractId }: ContractSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [contracts, setContracts] = useState<Contract[]>([])
+ const [loading, setLoading] = useState(false)
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+
+ const columns: ColumnDef<Contract>[] = [
+ {
+ accessorKey: 'contractNo',
+ header: '계약번호',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('contractNo')}</div>
+ ),
+ },
+ {
+ accessorKey: 'contractName',
+ header: '계약명',
+ cell: ({ row }) => (
+ <div className="max-w-[300px] truncate">{row.getValue('contractName')}</div>
+ ),
+ },
+ {
+ accessorKey: 'status',
+ header: '상태',
+ cell: ({ row }) => {
+ const status = row.getValue('status') as string
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'ACTIVE': return 'bg-green-100 text-green-800'
+ case 'TEST': return 'bg-blue-100 text-blue-800'
+ case 'DRAFT': return 'bg-yellow-100 text-yellow-800'
+ case 'PENDING': return 'bg-orange-100 text-orange-800'
+ default: return 'bg-gray-100 text-gray-800'
+ }
+ }
+ return (
+ <Badge className={getStatusColor(status)}>
+ {status}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'projectCode',
+ header: '프로젝트',
+ cell: ({ row }) => {
+ const projectCode = row.getValue('projectCode') as string | null
+ const projectName = row.original.projectName
+ return projectCode ? (
+ <div>
+ <div className="font-mono text-sm">{projectCode}</div>
+ {projectName && (
+ <div className="text-xs text-muted-foreground truncate max-w-[150px]">
+ {projectName}
+ </div>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'vendorName',
+ header: '벤더',
+ cell: ({ row }) => {
+ const vendorName = row.getValue('vendorName') as string | null
+ const vendorCode = row.original.vendorCode
+ return vendorName ? (
+ <div>
+ <div className="font-medium text-sm">{vendorName}</div>
+ {vendorCode && (
+ <div className="text-xs text-muted-foreground font-mono">
+ {vendorCode}
+ </div>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleContractSelect(row.original)}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ]
+
+ const table = useReactTable({
+ data: contracts,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ const loadContracts = async () => {
+ setLoading(true)
+ try {
+ const result = await getContracts()
+ if (result.success) {
+ setContracts(result.data)
+
+ // preselectedContractId가 있으면 자동 선택
+ if (preselectedContractId && !selectedContract) {
+ const preselectedContract = result.data.find(c => c.id === preselectedContractId)
+ if (preselectedContract) {
+ onContractSelect(preselectedContract)
+ }
+ }
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('계약을 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleContractSelect = (contract: Contract) => {
+ onContractSelect(contract)
+ setOpen(false)
+ }
+
+ useEffect(() => {
+ if (open && contracts.length === 0) {
+ loadContracts()
+ }
+ }, [open])
+
+ // preselectedContractId가 변경되면 계약 목록 다시 로드
+ useEffect(() => {
+ if (preselectedContractId && contracts.length === 0) {
+ loadContracts()
+ }
+ }, [preselectedContractId])
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" disabled={disabled} className="w-full justify-start">
+ {selectedContract ? (
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm">[{selectedContract.contractNo}]</span>
+ <span className="truncate">{selectedContract.contractName}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">계약을 선택하세요</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>계약 선택</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="계약번호, 계약명, 프로젝트 코드, 벤더명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {loading ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">계약을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleContractSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 계약
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/item-selector.tsx b/app/[lng]/admin/edp/components/item-selector.tsx
new file mode 100644
index 00000000..a81d2ff6
--- /dev/null
+++ b/app/[lng]/admin/edp/components/item-selector.tsx
@@ -0,0 +1,368 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Checkbox } from '@/components/ui/checkbox'
+import { getItems } from '../actions/data-actions'
+import { toast } from 'sonner'
+import { Item } from '../types/item'
+
+interface ItemSelectorProps {
+ selectedItems: number[]
+ onItemsSelect: (itemIds: number[], itemsData?: Item[]) => void
+ disabled?: boolean
+}
+
+export function ItemSelector({ selectedItems, onItemsSelect, disabled }: ItemSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [items, setItems] = useState<Item[]>([])
+ const [loading, setLoading] = useState(false)
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+
+ const columns: ColumnDef<Item>[] = [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected()}
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ // 현업 관심 필드들 (우선 순위)
+ {
+ accessorKey: 'ProjectNo',
+ header: '프로젝트 번호',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm font-medium">{row.getValue('ProjectNo')}</div>
+ ),
+ },
+ {
+ accessorKey: 'itemName',
+ header: '아이템명',
+ cell: ({ row }) => (
+ <div className="font-medium max-w-[200px] truncate">{row.getValue('itemName')}</div>
+ ),
+ },
+ {
+ accessorKey: 'packageCode',
+ header: '패키지 코드',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('packageCode')}</div>
+ ),
+ },
+ // 추가 필드들
+ {
+ accessorKey: 'itemCode',
+ header: '아이템 코드',
+ cell: ({ row }) => {
+ const code = row.getValue('itemCode') as string | null
+ return code ? (
+ <div className="font-mono text-sm">{code}</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'description',
+ header: '설명',
+ cell: ({ row }) => {
+ const description = row.getValue('description') as string | null
+ return description ? (
+ <div className="max-w-[250px] truncate text-sm">{description}</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'unitOfMeasure',
+ header: '단위',
+ cell: ({ row }) => {
+ const unit = row.getValue('unitOfMeasure') as string | null
+ return unit ? (
+ <Badge variant="outline" className="text-xs">{unit}</Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'smCode',
+ header: 'SM 코드',
+ cell: ({ row }) => {
+ const code = row.getValue('smCode') as string | null
+ return code ? (
+ <div className="font-mono text-xs">{code}</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'steelType',
+ header: '강종',
+ cell: ({ row }) => {
+ const steelType = row.getValue('steelType') as string | null
+ return steelType ? (
+ <Badge variant="secondary" className="text-xs">{steelType}</Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'itemLevel',
+ header: '레벨',
+ cell: ({ row }) => {
+ const level = row.getValue('itemLevel') as number | null
+ return level !== null ? (
+ <div className="text-sm text-center">{level}</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ ]
+
+ const table = useReactTable({
+ data: items,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ initialState: {
+ pagination: {
+ pageSize: 10,
+ },
+ },
+ })
+
+ const loadItems = async () => {
+ setLoading(true)
+ try {
+ const result = await getItems()
+ if (result.success && result.data) {
+ setItems(result.data)
+ } else {
+ toast.error(result.error)
+ }
+ } catch {
+ toast.error('아이템을 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleConfirmSelection = () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedItemIds = selectedRows.map(row => row.original.id)
+ const selectedItemsData = selectedRows.map(row => row.original)
+ onItemsSelect(selectedItemIds, selectedItemsData)
+ setOpen(false)
+ }
+
+ // 선택된 아이템 수 계산
+ const selectedCount = Object.keys(rowSelection).length
+
+ useEffect(() => {
+ if (open && items.length === 0) {
+ loadItems()
+ }
+ }, [open])
+
+ // 기존 선택 상태를 rowSelection에 반영
+ useEffect(() => {
+ if (items.length > 0 && selectedItems.length > 0) {
+ const newRowSelection: RowSelectionState = {}
+ items.forEach((item, index) => {
+ if (selectedItems.includes(item.id)) {
+ newRowSelection[index] = true
+ }
+ })
+ setRowSelection(newRowSelection)
+ }
+ }, [items, selectedItems])
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" disabled={disabled} className="w-full justify-start">
+ {selectedItems.length > 0 ? (
+ <span>{selectedItems.length}개 아이템 선택됨</span>
+ ) : (
+ <span className="text-muted-foreground">아이템을 선택하세요</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-7xl max-h-[90vh]">
+ <DialogHeader>
+ <DialogTitle>아이템 선택</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2 flex-1">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="프로젝트 번호, 아이템명, 패키지 코드, 설명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground ml-4">
+ {selectedCount}개 선택됨
+ </div>
+ </div>
+
+ {loading ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">아이템을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md max-h-[60vh] overflow-auto">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="sticky top-0 bg-background">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => row.toggleSelected()}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id} className="py-2">
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 아이템
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+
+ <div className="flex justify-end space-x-2 pt-4 border-t">
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleConfirmSelection} disabled={selectedCount === 0}>
+ {selectedCount}개 아이템 선택 확인
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/project-selector.tsx b/app/[lng]/admin/edp/components/project-selector.tsx
new file mode 100644
index 00000000..68895cd1
--- /dev/null
+++ b/app/[lng]/admin/edp/components/project-selector.tsx
@@ -0,0 +1,258 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { getProjects } from '../actions/data-actions'
+import { toast } from 'sonner'
+
+interface Project {
+ id: number
+ code: string
+ name: string
+ type: string
+}
+
+interface ProjectSelectorProps {
+ selectedProject?: Project
+ onProjectSelect: (project: Project) => void
+ disabled?: boolean
+}
+
+export function ProjectSelector({ selectedProject, onProjectSelect, disabled }: ProjectSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [projects, setProjects] = useState<Project[]>([])
+ const [loading, setLoading] = useState(false)
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+
+ const columns: ColumnDef<Project>[] = [
+ {
+ accessorKey: 'code',
+ header: '프로젝트 코드',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('code')}</div>
+ ),
+ },
+ {
+ accessorKey: 'name',
+ header: '프로젝트명',
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate">{row.getValue('name')}</div>
+ ),
+ },
+ {
+ accessorKey: 'type',
+ header: '타입',
+ cell: ({ row }) => (
+ <Badge variant="outline">{row.getValue('type')}</Badge>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleProjectSelect(row.original)}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ]
+
+ const table = useReactTable({
+ data: projects,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ const loadProjects = async () => {
+ setLoading(true)
+ try {
+ const result = await getProjects()
+ if (result.success) {
+ setProjects(result.data)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('프로젝트를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleProjectSelect = (project: Project) => {
+ onProjectSelect(project)
+ setOpen(false)
+ }
+
+ useEffect(() => {
+ if (open && projects.length === 0) {
+ loadProjects()
+ }
+ }, [open])
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" disabled={disabled} className="w-full justify-start">
+ {selectedProject ? (
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm">[{selectedProject.code}]</span>
+ <span className="truncate">{selectedProject.name}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">프로젝트를 선택하세요</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>프로젝트 선택</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="프로젝트 코드, 이름으로 검색..."
+ value={globalFilter}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {loading ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">프로젝트를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleProjectSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 프로젝트
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/app/[lng]/admin/edp/components/vendor-selector.tsx b/app/[lng]/admin/edp/components/vendor-selector.tsx
new file mode 100644
index 00000000..22dfad65
--- /dev/null
+++ b/app/[lng]/admin/edp/components/vendor-selector.tsx
@@ -0,0 +1,281 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { getVendors } from '../actions/data-actions'
+import { toast } from 'sonner'
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+}
+
+interface VendorSelectorProps {
+ selectedVendor?: Vendor
+ onVendorSelect: (vendor: Vendor) => void
+ disabled?: boolean
+}
+
+export function VendorSelector({ selectedVendor, onVendorSelect, disabled }: VendorSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [vendors, setVendors] = useState<Vendor[]>([])
+ const [loading, setLoading] = useState(false)
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+
+ const columns: ColumnDef<Vendor>[] = [
+ {
+ accessorKey: 'vendorName',
+ header: '벤더명',
+ cell: ({ row }) => (
+ <div className="font-medium">{row.getValue('vendorName')}</div>
+ ),
+ },
+ {
+ accessorKey: 'vendorCode',
+ header: '벤더 코드',
+ cell: ({ row }) => {
+ const code = row.getValue('vendorCode') as string | null
+ return code ? (
+ <div className="font-mono text-sm">{code}</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ },
+ {
+ accessorKey: 'status',
+ header: '상태',
+ cell: ({ row }) => {
+ const status = row.getValue('status') as string
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'ACTIVE': return 'bg-green-100 text-green-800'
+ case 'APPROVED': return 'bg-blue-100 text-blue-800'
+ case 'PENDING_REVIEW': return 'bg-yellow-100 text-yellow-800'
+ case 'INACTIVE': return 'bg-gray-100 text-gray-800'
+ default: return 'bg-gray-100 text-gray-800'
+ }
+ }
+ return (
+ <Badge className={getStatusColor(status)}>
+ {status}
+ </Badge>
+ )
+ },
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleVendorSelect(row.original)}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ]
+
+ const table = useReactTable({
+ data: vendors,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ const loadVendors = async () => {
+ setLoading(true)
+ try {
+ const result = await getVendors()
+ if (result.success) {
+ setVendors(result.data)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error('벤더를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleVendorSelect = (vendor: Vendor) => {
+ onVendorSelect(vendor)
+ setOpen(false)
+ }
+
+ useEffect(() => {
+ if (open && vendors.length === 0) {
+ loadVendors()
+ }
+ }, [open])
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" disabled={disabled} className="w-full justify-start">
+ {selectedVendor ? (
+ <div className="flex items-center gap-2">
+ <span className="truncate">{selectedVendor.vendorName}</span>
+ {selectedVendor.vendorCode && (
+ <span className="font-mono text-sm text-muted-foreground">
+ ({selectedVendor.vendorCode})
+ </span>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">벤더를 선택하세요</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>벤더 선택</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="벤더명, 벤더 코드로 검색..."
+ value={globalFilter}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {loading ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">벤더를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleVendorSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 벤더
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/app/[lng]/admin/edp/page.tsx b/app/[lng]/admin/edp/page.tsx
new file mode 100644
index 00000000..4b72196f
--- /dev/null
+++ b/app/[lng]/admin/edp/page.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import { useState } from 'react'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ContractForm } from './components/contract-form'
+import { ContractItemsForm } from './components/contract-items-form'
+import { ContractEditForm } from './components/contract-edit-form'
+import { ContractItemsEditForm } from './components/contract-items-edit-form'
+
+export default function EDPPage() {
+ const [activeTab, setActiveTab] = useState('contracts')
+ const [selectedContractId, setSelectedContractId] = useState<number | undefined>()
+
+ // 계약 생성 완료시 계약 아이템 탭으로 이동하고 해당 계약을 사전 선택
+ const handleContractCreated = (contract: { id: number; contractNo: string; contractName: string; status: string }) => {
+ setSelectedContractId(contract.id)
+ setActiveTab('contract-items')
+ }
+
+ // 계약 수정 완료시 계약 아이템 삭제 탭으로 이동하고 해당 계약을 사전 선택
+ const handleContractUpdated = (contract: { id: number; contractNo: string; contractName: string; status: string }) => {
+ setSelectedContractId(contract.id)
+ setActiveTab('contract-items-edit')
+ }
+
+ return (
+ <div className="container mx-auto py-6">
+ <div className="mb-8">
+ <h1 className="text-3xl font-bold tracking-tight">EDP 테스트 데이터 관리</h1>
+ <p className="text-muted-foreground mt-2">
+ 현업 테스트를 위한 계약 및 계약 아이템 데이터를 수동으로 생성할 수 있습니다.
+ </p>
+ </div>
+
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="contracts">계약 생성</TabsTrigger>
+ <TabsTrigger value="contract-items">계약 아이템 생성</TabsTrigger>
+ <TabsTrigger value="contracts-edit">계약 수정</TabsTrigger>
+ <TabsTrigger value="contract-items-edit">계약 아이템 삭제</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="contracts" className="space-y-6">
+ <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-1">
+ <ContractForm onContractCreated={handleContractCreated} />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="contract-items" className="space-y-6">
+ <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-1">
+ <ContractItemsForm preselectedContractId={selectedContractId} />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="contracts-edit" className="space-y-6">
+ <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-1">
+ <ContractEditForm onContractUpdated={handleContractUpdated} />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="contract-items-edit" className="space-y-6">
+ <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-1">
+ <ContractItemsEditForm preselectedContractId={selectedContractId} />
+ </div>
+ </TabsContent>
+ </Tabs>
+ </div>
+ )
+}
diff --git a/app/[lng]/admin/edp/types/item.ts b/app/[lng]/admin/edp/types/item.ts
new file mode 100644
index 00000000..332e3878
--- /dev/null
+++ b/app/[lng]/admin/edp/types/item.ts
@@ -0,0 +1,18 @@
+// 공통 Item 타입 정의
+export interface Item {
+ id: number
+ ProjectNo: string
+ itemCode: string | null
+ itemName: string
+ packageCode: string
+ smCode: string | null
+ description: string | null
+ parentItemCode: string | null
+ itemLevel: number | null
+ deleteFlag: string | null
+ unitOfMeasure: string | null
+ steelType: string | null
+ gradeMaterial: string | null
+ changeDate: string | null
+ baseUnitOfMeasure: string | null
+}
diff --git a/app/[lng]/admin/layout.tsx b/app/[lng]/admin/layout.tsx
new file mode 100644
index 00000000..918a8554
--- /dev/null
+++ b/app/[lng]/admin/layout.tsx
@@ -0,0 +1,34 @@
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { redirect } from "next/navigation"
+
+export default async function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const session = await getServerSession(authOptions)
+
+ // 세션이 없거나 domain이 'evcp'가 아닌 경우 접근 거부
+ if (!session?.user || session.user.domain !== 'evcp') {
+ redirect('/en/evcp')
+ }
+
+ return (
+ <div className="min-h-screen bg-gray-50">
+ <div className="bg-white shadow-sm border-b">
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
+ <div className="flex justify-between items-center py-4">
+ <h1 className="text-2xl font-bold text-gray-900">임시 관리자 페이지</h1>
+ <div className="text-sm text-gray-600">
+ {session.user.name} ({session.user.email})
+ </div>
+ </div>
+ </div>
+ </div>
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
+ {children}
+ </div>
+ </div>
+ )
+}