summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/bidding/bidding-round-actions.tsx32
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx39
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx204
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx2
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx168
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector.tsx176
-rw-r--r--components/common/selectors/procurement-item/procurement-item-service.ts55
-rw-r--r--components/ui/file-upload.tsx169
8 files changed, 759 insertions, 86 deletions
diff --git a/components/bidding/bidding-round-actions.tsx b/components/bidding/bidding-round-actions.tsx
index b2db0dfb..86fea72a 100644
--- a/components/bidding/bidding-round-actions.tsx
+++ b/components/bidding/bidding-round-actions.tsx
@@ -35,16 +35,27 @@ export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundAc
const { data: session } = useSession()
const userId = session?.user?.id
- // 차수증가는 유찰 상태에서만 가능
- const canIncreaseRound = biddingStatus === 'bidding_disposal'
-
- // 재입찰도 유찰 상태에서만 가능
+ // 차수증가는 입찰공고 또는 입찰 진행중 상태에서 가능
+ const canIncreaseRound = biddingStatus === 'bidding_generated' || biddingStatus === 'bidding_opened'
+
+ // 유찰 및 낙찰은 입찰 진행중 상태에서 가능 (이 컴포넌트에서는 사용하지 않음)
+
+ // 재입찰은 유찰 상태에서만 가능
const canRebid = biddingStatus === 'bidding_disposal'
const handleRoundIncrease = () => {
+ if (!userId) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ const userIdStr = userId as string
startTransition(async () => {
try {
- const result = await increaseRoundOrRebid(biddingId, userId, 'round_increase')
+ const result = await increaseRoundOrRebid(biddingId, userIdStr, 'round_increase')
if (result.success) {
toast({
@@ -77,9 +88,18 @@ export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundAc
}
const handleRebid = () => {
+ if (!userId) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ const userIdStr = userId as string
startTransition(async () => {
try {
- const result = await increaseRoundOrRebid(biddingId, userId, 'rebidding')
+ const result = await increaseRoundOrRebid(biddingId, userIdStr, 'rebidding')
if (result.success) {
toast({
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index d60c5d88..a956d73c 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -26,8 +26,9 @@ import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
// CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다.
-import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions } from '@/lib/bidding/service'
-import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions, getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import { getPurchaseGroupCodes } from '@/components/common/selectors/purchase-group-code'
+import { getProcurementManagers } from '@/components/common/selectors/procurement-manager'
import {
getIncotermsForSelection,
getPaymentTermsForSelection,
@@ -39,8 +40,8 @@ import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeT
import TiptapEditor from '@/components/qna/tiptap-editor'
import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
-import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
-import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
+import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code'
+import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager'
import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service'
import { downloadFile } from '@/lib/file-download'
@@ -259,7 +260,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
}
// Procurement 데이터 로드
- const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([
+ const [paymentTermsData, incotermsData, shippingData, destinationData, purchaseGroupCodes, procurementManagers] = await Promise.all([
getPaymentTermsForSelection().catch(() => []),
getIncotermsForSelection().catch(() => []),
getPlaceOfShippingForSelection().catch(() => []),
@@ -269,6 +270,34 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
setIncotermsOptions(incotermsData)
setShippingPlaces(shippingData)
setDestinationPlaces(destinationData)
+ setSelectedBidPic({
+ DISPLAY_NAME: bidding.bidPicName || '',
+ PURCHASE_GROUP_CODE: bidding.bidPicCode || '',
+ user: {
+ id: bidding.bidPicUserId || undefined,
+ }
+ })
+ setSelectedSupplyPic({
+ DISPLAY_NAME: bidding.supplyPicName || '',
+ PROCUREMENT_MANAGER_CODE: bidding.supplyPicCode || '',
+ user: {
+ id: bidding.supplyPicUserId || undefined,
+ }
+ })
+ // // 입찰담당자 및 조달담당자 초기 선택값 설정
+ // if (bidding.bidPicCode && purchaseGroupCodes.length > 0) {
+ // const selectedBidPicData = purchaseGroupCodes.find(code => code.PURCHASE_GROUP_CODE === bidding.bidPicCode)
+ // if (selectedBidPicData) {
+
+ // }
+ // }
+
+ // if (bidding.supplyPicCode && procurementManagers.length > 0) {
+ // const selectedSupplyPicData = procurementManagers.find(manager => manager.PROCUREMENT_MANAGER_CODE === bidding.supplyPicCode)
+ // if (selectedSupplyPicData) {
+
+ // }
+ // }
// 공고 템플릿 로드
await loadNoticeTemplate(biddingExtended.noticeType || undefined)
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index 96a8d2ae..dc0aaeec 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -80,6 +80,7 @@ interface BiddingItemsEditorProps {
import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service'
import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog'
+import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
@@ -100,6 +101,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
const [preQuoteDialogOpen, setPreQuoteDialogOpen] = React.useState(false)
const [targetPriceCalculationCriteria, setTargetPriceCalculationCriteria] = React.useState('')
const [biddingPicUserId, setBiddingPicUserId] = React.useState<number | null>(null)
+ const [biddingType, setBiddingType] = React.useState<string | null>(null)
const [biddingConditions, setBiddingConditions] = React.useState<{
paymentTerms?: string | null
taxConditions?: string | null
@@ -159,13 +161,15 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
actualCurrency: item.actualCurrency || 'KRW',
}))
-
+
// 첫 번째 아이템을 대표로 설정
- formattedItems[0].isRepresentative = true
-
+ if (formattedItems.length > 0) {
+ formattedItems[0].isRepresentative = true
+ }
+
setItems(formattedItems)
setDeletedItemIds(new Set()) // 삭제 목록 초기화
-
+
// 기존 품목 로드 성공 알림 (조용히 표시, 선택적)
console.log(`기존 품목 ${formattedItems.length}개를 불러왔습니다.`)
} else {
@@ -199,7 +203,9 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
])
if (bidding) {
+ console.log('📋 bidding:', bidding.biddingType)
setBiddingPicUserId(bidding.bidPicId || null)
+ setBiddingType(bidding.biddingType || null)
setTargetPriceCalculationCriteria(bidding.targetPriceCalculationCriteria || '')
}
@@ -332,52 +338,73 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
toast.success('품목 정보가 성공적으로 저장되었습니다.')
// 삭제 목록 초기화
setDeletedItemIds(new Set())
+
// 데이터 다시 로딩하여 최신 상태 반영
+ console.log('🔄 저장 후 데이터 재로드 시작 - biddingId:', biddingId)
const prItems = await getPRItemsForBidding(biddingId)
- const formattedItems: PRItemInfo[] = prItems.map((item) => ({
- id: item.id,
- prNumber: item.prNumber || null,
- projectId: item.projectId || null,
- projectInfo: item.projectInfo || null,
- shi: item.shi || null,
- quantity: item.quantity ? item.quantity.toString() : null,
- quantityUnit: item.quantityUnit || null,
- totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
- weightUnit: item.weightUnit || null,
- materialDescription: item.itemInfo || null,
- hasSpecDocument: item.hasSpecDocument || false,
- requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
- isRepresentative: false,
- annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
- currency: item.currency || 'KRW',
- materialGroupNumber: item.materialGroupNumber || null,
- materialGroupInfo: item.materialGroupInfo || null,
- materialNumber: item.materialNumber || null,
- materialInfo: item.materialInfo || null,
- priceUnit: item.priceUnit || null,
- purchaseUnit: item.purchaseUnit || null,
- materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
- wbsCode: item.wbsCode || null,
- wbsName: item.wbsName || null,
- costCenterCode: item.costCenterCode || null,
- costCenterName: item.costCenterName || null,
- glAccountCode: item.glAccountCode || null,
- glAccountName: item.glAccountName || null,
- targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
- targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
- targetCurrency: item.targetCurrency || 'KRW',
- budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
- budgetCurrency: item.budgetCurrency || 'KRW',
- actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
- actualCurrency: item.actualCurrency || 'KRW',
- }))
-
- // 첫 번째 아이템을 대표로 설정
- if (formattedItems.length > 0) {
- formattedItems[0].isRepresentative = true
+ console.log('📦 getPRItemsForBidding 결과:', prItems)
+
+ if (prItems && prItems.length > 0) {
+ console.log('✅ 저장된 아이템 수:', prItems.length)
+ const formattedItems: PRItemInfo[] = prItems.map((item, index) => {
+ console.log(`🔍 아이템 ${index + 1}:`, {
+ id: item.id,
+ materialGroupNumber: item.materialGroupNumber,
+ materialNumber: item.materialNumber,
+ quantity: item.quantity
+ })
+ return {
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false,
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }
+ })
+
+ // 첫 번째 아이템을 대표로 설정
+ if (formattedItems.length > 0) {
+ formattedItems[0].isRepresentative = true
+ }
+
+ console.log('📋 최종 formattedItems:', formattedItems)
+ setItems(formattedItems)
+ console.log('✅ 상태 업데이트 완료')
+ } else {
+ console.log('❌ 저장 후 데이터가 없음 - 빈 배열 설정')
+ // 저장 후 데이터가 없으면 빈 배열로 설정
+ setItems([])
}
-
- setItems(formattedItems)
}
} catch (error) {
console.error('Failed to save items:', error)
@@ -593,30 +620,57 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
/>
</td>
<td className="border-r px-3 py-2">
- <MaterialGroupSelectorDialogSingle
- triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
- triggerVariant="outline"
- selectedMaterial={item.materialGroupNumber ? {
- materialGroupCode: item.materialGroupNumber,
- materialGroupDescription: item.materialGroupInfo || '',
- displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo || ''}`
- } : null}
- onMaterialSelect={(material) => {
- if (material) {
- updatePRItem(item.id, {
- materialGroupNumber: material.materialGroupCode,
- materialGroupInfo: material.materialGroupDescription
- })
- } else {
- updatePRItem(item.id, {
- materialGroupNumber: '',
- materialGroupInfo: ''
- })
- }
- }}
- title="자재그룹 선택"
- description="자재그룹을 검색하고 선택해주세요."
- />
+ {biddingType === 'equipment' ? (
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "품목 선택"}
+ triggerVariant="outline"
+ selectedProcurementItem={item.materialGroupNumber ? {
+ itemCode: item.materialGroupNumber,
+ itemName: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber}`
+ } : null}
+ onProcurementItemSelect={(procurementItem) => {
+ if (procurementItem) {
+ updatePRItem(item.id, {
+ materialGroupNumber: procurementItem.itemCode,
+ materialGroupInfo: procurementItem.itemName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="품목 선택"
+ description="품목을 검색하고 선택해주세요."
+ />
+ ) : (
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ )}
</td>
<td className="border-r px-3 py-2">
<Input
@@ -633,7 +687,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
selectedMaterial={item.materialNumber ? {
materialCode: item.materialNumber,
materialName: item.materialInfo || '',
- displayText: `${item.materialNumber} - ${item.materialInfo || ''}`
+ displayText: `${item.materialNumber}`
} : null}
onMaterialSelect={(material) => {
if (material) {
@@ -830,7 +884,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
>
{item.wbsCode ? (
<span className="truncate">
- {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ {`${item.wbsCode}`}
</span>
) : (
<span className="text-muted-foreground">WBS 코드 선택</span>
@@ -880,7 +934,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
>
{item.costCenterCode ? (
<span className="truncate">
- {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ {`${item.costCenterCode}`}
</span>
) : (
<span className="text-muted-foreground">코스트센터 선택</span>
@@ -931,7 +985,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
>
{item.glAccountCode ? (
<span className="truncate">
- {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ {`${item.glAccountCode}`}
</span>
) : (
<span className="text-muted-foreground">GL계정 선택</span>
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
index 88732deb..c49f6232 100644
--- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -95,6 +95,7 @@ interface CreatePreQuoteRfqDialogProps {
totalWeight?: string | null
weightUnit?: string | null
}>
+ picUserId?: number | null
biddingConditions?: {
paymentTerms?: string | null
taxConditions?: string | null
@@ -114,6 +115,7 @@ export function CreatePreQuoteRfqDialog({
onOpenChange,
biddingId,
biddingItems,
+ picUserId,
biddingConditions,
onSuccess
}: CreatePreQuoteRfqDialogProps) {
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
new file mode 100644
index 00000000..dab65780
--- /dev/null
+++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import React, { useState, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ProcurementItemSelector } from "./procurement-item-selector";
+import { ProcurementSearchItem } from "./procurement-item-service";
+
+export interface ProcurementItemSelectorDialogSingleProps {
+ triggerLabel?: string;
+ triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
+ triggerSize?: "default" | "sm" | "lg" | "icon";
+ selectedProcurementItem?: ProcurementSearchItem | null;
+ onProcurementItemSelect?: (item: ProcurementSearchItem | null) => void;
+ title?: string;
+ description?: string;
+ showConfirmButtons?: boolean;
+}
+
+/**
+ * 품목 단일 선택 Dialog 컴포넌트
+ *
+ * @description
+ * - ProcurementItemSelector를 Dialog로 래핑한 단일 선택 컴포넌트
+ * - 버튼 클릭 시 Dialog가 열리고, 품목을 선택하면 Dialog가 닫히며 결과를 반환
+ *
+ * @ProcurementSearchItem_Structure
+ * 상태에서 관리되는 품목 객체의 형태:
+ * ```typescript
+ * interface ProcurementSearchItem {
+ * itemCode: string; // 품목코드
+ * itemName: string; // 품목명
+ * material?: string; // 재질
+ * specification?: string; // 규격
+ * unit?: string; // 단위
+ * displayText: string; // 표시용 텍스트 (code + " - " + name)
+ * }
+ * ```
+ *
+ * @state
+ * - open: Dialog 열림/닫힘 상태
+ * - selectedProcurementItem: 현재 선택된 품목 (단일)
+ * - tempSelectedProcurementItem: Dialog 내에서 임시로 선택된 품목 (확인 버튼 클릭 전까지)
+ *
+ * @callback
+ * - onProcurementItemSelect: 품목 선택 완료 시 호출되는 콜백
+ * - 매개변수: ProcurementSearchItem | null
+ * - 선택된 품목 정보 또는 null (선택 해제 시)
+ *
+ * @usage
+ * ```tsx
+ * <ProcurementItemSelectorDialogSingle
+ * triggerLabel="품목 선택"
+ * selectedProcurementItem={selectedProcurementItem}
+ * onProcurementItemSelect={(item) => {
+ * console.log('선택된 품목:', item);
+ * setSelectedProcurementItem(item);
+ * }}
+ * title="품목 선택"
+ * description="품목을 검색하고 선택해주세요."
+ * />
+ * ```
+ */
+export function ProcurementItemSelectorDialogSingle({
+ triggerLabel = "품목 선택",
+ triggerVariant = "outline",
+ triggerSize = "default",
+ selectedProcurementItem = null,
+ onProcurementItemSelect,
+ title = "품목 선택",
+ description = "품목을 검색하고 선택해주세요.",
+ showConfirmButtons = false,
+}: ProcurementItemSelectorDialogSingleProps) {
+ const [open, setOpen] = useState(false);
+ const [tempSelectedProcurementItem, setTempSelectedProcurementItem] =
+ useState<ProcurementSearchItem | null>(selectedProcurementItem);
+
+ // Dialog가 열릴 때 임시 선택 상태 초기화
+ const handleOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen);
+ if (newOpen) {
+ // Dialog 열 때 현재 선택된 값으로 임시 상태 초기화
+ setTempSelectedProcurementItem(selectedProcurementItem);
+ }
+ }, [selectedProcurementItem]);
+
+ // 품목 선택 시 임시 상태 업데이트
+ const handleProcurementItemSelect = useCallback((item: ProcurementSearchItem | null) => {
+ setTempSelectedProcurementItem(item);
+
+ // 확인 버튼이 없는 경우 즉시 적용하고 Dialog 닫기
+ if (!showConfirmButtons) {
+ onProcurementItemSelect?.(item);
+ setOpen(false);
+ }
+ }, [onProcurementItemSelect, showConfirmButtons]);
+
+ // 확인 버튼 클릭 시 선택 적용
+ const handleConfirm = useCallback(() => {
+ onProcurementItemSelect?.(tempSelectedProcurementItem);
+ setOpen(false);
+ }, [onProcurementItemSelect, tempSelectedProcurementItem]);
+
+ // 취소 버튼 클릭 시 Dialog 닫기 (변경사항 적용 안 함)
+ const handleCancel = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ // 선택 해제
+ const handleClear = useCallback(() => {
+ const newSelection = null;
+ setTempSelectedProcurementItem(newSelection);
+
+ if (!showConfirmButtons) {
+ onProcurementItemSelect?.(newSelection);
+ setOpen(false);
+ }
+ }, [onProcurementItemSelect, showConfirmButtons]);
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant={triggerVariant} size={triggerSize}>
+ {selectedProcurementItem ? (
+ <span className="truncate">
+ {`${selectedProcurementItem.itemCode} - ${selectedProcurementItem.itemName}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">{triggerLabel}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>{description}</DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden">
+ <ProcurementItemSelector
+ selectedProcurementItem={tempSelectedProcurementItem}
+ onProcurementItemSelect={handleProcurementItemSelect}
+ onClear={handleClear}
+ />
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button onClick={handleConfirm}>
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/common/selectors/procurement-item/procurement-item-selector.tsx b/components/common/selectors/procurement-item/procurement-item-selector.tsx
new file mode 100644
index 00000000..5650959c
--- /dev/null
+++ b/components/common/selectors/procurement-item/procurement-item-selector.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import React, { useState, useCallback, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import {
+ Command,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+} from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Check, ChevronsUpDown, X, Search } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useDebounce } from "@/hooks/use-debounce";
+import { searchProcurementItemsForSelector, ProcurementSearchItem, getProcurementItemByCode } from "./procurement-item-service";
+
+interface ProcurementItemSelectorProps {
+ selectedProcurementItem?: ProcurementSearchItem | null;
+ onProcurementItemSelect?: (item: ProcurementSearchItem | null) => void;
+ onClear?: () => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function ProcurementItemSelector({
+ selectedProcurementItem,
+ onProcurementItemSelect,
+ onClear,
+ placeholder = "품목을 검색하세요...",
+ disabled = false,
+ className,
+}: ProcurementItemSelectorProps) {
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState<ProcurementSearchItem[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const debouncedSearchQuery = useDebounce(searchQuery, 300);
+
+ // 검색 쿼리가 변경될 때마다 검색 실행
+ useEffect(() => {
+ const performSearch = async () => {
+ if (debouncedSearchQuery.length < 1) {
+ setSearchResults([]);
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const results = await searchProcurementItemsForSelector(debouncedSearchQuery);
+ setSearchResults(results);
+ } catch (error) {
+ console.error("품목 검색 실패:", error);
+ setSearchResults([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ performSearch();
+ }, [debouncedSearchQuery]);
+
+ // 품목 선택 핸들러
+ const handleSelect = useCallback((item: ProcurementSearchItem) => {
+ onProcurementItemSelect?.(item);
+ setOpen(false);
+ setSearchQuery("");
+ setSearchResults([]);
+ }, [onProcurementItemSelect]);
+
+ // 선택 해제 핸들러
+ const handleClear = useCallback(() => {
+ onProcurementItemSelect?.(null);
+ onClear?.();
+ setSearchQuery("");
+ setSearchResults([]);
+ }, [onProcurementItemSelect, onClear]);
+
+ return (
+ <div className={cn("flex items-center space-x-2", className)}>
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between"
+ disabled={disabled}
+ >
+ {selectedProcurementItem ? (
+ <span className="truncate">
+ {selectedProcurementItem.displayText}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <div className="p-2">
+ <div className="relative">
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="품목코드 또는 품목명으로 검색..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ autoFocus
+ />
+ </div>
+ </div>
+ <Command>
+ <CommandList>
+ <CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 중...
+ </div>
+ ) : searchQuery.length < 1 ? (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 품목코드 또는 품목명을 입력하세요
+ </div>
+ ) : (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 결과가 없습니다
+ </div>
+ )}
+ </CommandEmpty>
+ <CommandGroup>
+ {searchResults.map((item) => (
+ <CommandItem
+ key={item.itemCode}
+ value={item.itemCode}
+ onSelect={() => handleSelect(item)}
+ className="cursor-pointer"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedProcurementItem?.itemCode === item.itemCode
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">{item.itemCode}</span>
+ <span className="text-sm text-muted-foreground">{item.itemName}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* 선택 해제 버튼 */}
+ {selectedProcurementItem && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleClear}
+ disabled={disabled}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ );
+}
diff --git a/components/common/selectors/procurement-item/procurement-item-service.ts b/components/common/selectors/procurement-item/procurement-item-service.ts
new file mode 100644
index 00000000..8e7b2c12
--- /dev/null
+++ b/components/common/selectors/procurement-item/procurement-item-service.ts
@@ -0,0 +1,55 @@
+import { searchProcurementItems } from "@/lib/procurement-items/service";
+
+/**
+ * 품목 검색을 위한 인터페이스
+ */
+export interface ProcurementSearchItem {
+ itemCode: string; // 품목코드
+ itemName: string; // 품목명
+ material?: string; // 재질
+ specification?: string; // 규격
+ unit?: string; // 단위
+ displayText: string; // 표시용 텍스트 (code + " - " + name)
+}
+
+/**
+ * 품목 검색 함수
+ * procurement-items 서비스를 통해 품목을 검색합니다.
+ */
+export async function searchProcurementItemsForSelector(query: string): Promise<ProcurementSearchItem[]> {
+ try {
+ const results = await searchProcurementItems(query);
+
+ return results.map(item => ({
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ displayText: `${item.itemCode} - ${item.itemName}`,
+ }));
+ } catch (error) {
+ console.error("품목 검색 오류:", error);
+ return [];
+ }
+}
+
+/**
+ * 품목코드로 품목 상세 정보 조회
+ */
+export async function getProcurementItemByCode(itemCode: string): Promise<ProcurementSearchItem | null> {
+ try {
+ const results = await searchProcurementItems(itemCode);
+
+ const exactMatch = results.find(item => item.itemCode === itemCode);
+ if (exactMatch) {
+ return {
+ itemCode: exactMatch.itemCode,
+ itemName: exactMatch.itemName,
+ displayText: `${exactMatch.itemCode} - ${exactMatch.itemName}`,
+ };
+ }
+
+ return null;
+ } catch (error) {
+ console.error("품목 상세 조회 오류:", error);
+ return null;
+ }
+}
diff --git a/components/ui/file-upload.tsx b/components/ui/file-upload.tsx
new file mode 100644
index 00000000..01f09d48
--- /dev/null
+++ b/components/ui/file-upload.tsx
@@ -0,0 +1,169 @@
+'use client'
+
+import * as React from 'react'
+import { Upload, X, FileText } from 'lucide-react'
+import { Button } from './button'
+import { cn } from '@/lib/utils'
+
+interface FileUploadProps {
+ value: File[]
+ onChange: (files: File[]) => void
+ accept?: Record<string, string[]>
+ maxSize?: number
+ maxFiles?: number
+ placeholder?: string
+ disabled?: boolean
+ className?: string
+}
+
+export function FileUpload({
+ value = [],
+ onChange,
+ accept,
+ maxSize = 10 * 1024 * 1024, // 10MB
+ maxFiles = 5,
+ placeholder = '파일을 선택하거나 드래그하세요',
+ disabled = false,
+ className
+}: FileUploadProps) {
+ const [isDragOver, setIsDragOver] = React.useState(false)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ const handleFileSelect = (files: FileList | null) => {
+ if (!files || disabled) return
+
+ const fileArray = Array.from(files)
+ const validFiles = fileArray.filter(file => {
+ // 파일 크기 검증
+ if (file.size > maxSize) {
+ console.warn(`파일 ${file.name}이(가) 최대 크기(${maxSize / 1024 / 1024}MB)를 초과합니다.`)
+ return false
+ }
+
+ // 파일 타입 검증
+ if (accept) {
+ const fileType = file.type
+ const fileName = file.name.toLowerCase()
+ const isAccepted = Object.entries(accept).some(([mimeType, extensions]) => {
+ if (fileType && mimeType !== '*/*') {
+ return fileType.startsWith(mimeType.split('/')[0])
+ }
+ return extensions.some(ext => fileName.endsWith(ext.toLowerCase()))
+ })
+
+ if (!isAccepted) {
+ console.warn(`파일 ${file.name}이(가) 지원되지 않는 형식입니다.`)
+ return false
+ }
+ }
+
+ return true
+ })
+
+ const newFiles = [...value, ...validFiles].slice(0, maxFiles)
+ onChange(newFiles)
+ }
+
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ handleFileSelect(event.target.files)
+ // Reset input value to allow re-uploading the same file
+ event.target.value = ''
+ }
+
+ const handleDrop = (event: React.DragEvent) => {
+ event.preventDefault()
+ setIsDragOver(false)
+ handleFileSelect(event.dataTransfer.files)
+ }
+
+ const handleDragOver = (event: React.DragEvent) => {
+ event.preventDefault()
+ if (!disabled) {
+ setIsDragOver(true)
+ }
+ }
+
+ const handleDragLeave = () => {
+ setIsDragOver(false)
+ }
+
+ const removeFile = (index: number) => {
+ const newFiles = value.filter((_, i) => i !== index)
+ onChange(newFiles)
+ }
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ return (
+ <div className={cn('space-y-2', className)}>
+ {/* Drop zone */}
+ <div
+ className={cn(
+ 'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
+ isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25',
+ disabled ? 'cursor-not-allowed opacity-50' : 'hover:border-primary/50'
+ )}
+ onDrop={handleDrop}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onClick={() => !disabled && fileInputRef.current?.click()}
+ >
+ <Upload className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
+ <p className="text-sm text-muted-foreground">{placeholder}</p>
+ <p className="text-xs text-muted-foreground mt-1">
+ 최대 {maxFiles}개 파일, 각 파일 {formatFileSize(maxSize)}까지
+ </p>
+ </div>
+
+ {/* Hidden file input */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ accept={accept ? Object.values(accept).flat().join(',') : undefined}
+ onChange={handleInputChange}
+ className="hidden"
+ disabled={disabled}
+ />
+
+ {/* File list */}
+ {value.length > 0 && (
+ <div className="space-y-2">
+ {value.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 bg-muted rounded-md"
+ >
+ <div className="flex items-center space-x-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(file.size)}
+ </p>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ removeFile(index)
+ }}
+ disabled={disabled}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )
+}