diff options
| author | joonhoekim <26rote@gmail.com> | 2025-08-27 08:24:58 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-08-27 08:24:58 +0000 |
| commit | e2ed31dd0112dc3bede53ceef9b957d2810e141e (patch) | |
| tree | 9727511febf5b51ee897b0ae2f24f6b68a95cbad | |
| parent | 026ce088c638b50f493fe9aedf36e0659cb368c3 (diff) | |
(김준회) 임시 관리자 페이지 - EDP 데이터 수동 관리 추가 및 세션검증 추가
| -rw-r--r-- | app/[lng]/admin/edp/actions/contract-actions.ts | 200 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/actions/data-actions.ts | 155 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/contract-edit-form.tsx | 231 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/contract-form.tsx | 145 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/contract-items-edit-form.tsx | 204 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/contract-items-form.tsx | 230 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/contract-selector.tsx | 334 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/item-selector.tsx | 368 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/project-selector.tsx | 258 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/components/vendor-selector.tsx | 281 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/page.tsx | 69 | ||||
| -rw-r--r-- | app/[lng]/admin/edp/types/item.ts | 18 | ||||
| -rw-r--r-- | app/[lng]/admin/layout.tsx | 34 |
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> + ) +} |
