diff options
Diffstat (limited to 'components')
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> + ) +} |
