diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-08 14:19:37 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-08 14:19:37 +0900 |
| commit | 2ac7deb8494cf4123f0cff3321860585a44f157c (patch) | |
| tree | 789b6980c8f863a0f675fad38c4a17d91ba28bf3 /components | |
| parent | 71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 (diff) | |
| parent | e37cce51ccfa3dcb91904b2492df3a29970fadf7 (diff) | |
Merge remote-tracking branch 'origin/sec-patch' into table-v2
Diffstat (limited to 'components')
18 files changed, 2119 insertions, 199 deletions
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index b3972e11..af33f1f6 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -63,7 +63,7 @@ import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchas 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 { createBidding } from '@/lib/bidding/service'
+import { createBidding, getUserDetails } from '@/lib/bidding/service'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
@@ -97,13 +97,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp sparePartOptions: '',
})
- // 구매요청자 정보 (현재 사용자)
- // React.useEffect(() => {
- // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함
- // // 임시로 기본값 설정
- // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함
- // }, [form])
-
const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
@@ -164,13 +157,41 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp React.useEffect(() => {
if (isOpen) {
- if (userId && session?.user?.name) {
- // 현재 사용자의 정보를 임시로 입찰담당자로 설정
- form.setValue('bidPicName', session.user.name)
- form.setValue('bidPicId', userId)
- // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함)
- // form.setValue('bidPicCode', session.user.name)
+ const initUser = async () => {
+ if (userId) {
+ try {
+ const user = await getUserDetails(userId)
+ if (user) {
+ // 현재 사용자의 정보를 입찰담당자로 설정
+ form.setValue('bidPicName', user.name)
+ form.setValue('bidPicId', user.id)
+ form.setValue('bidPicCode', user.userCode || '')
+
+ // 담당자 selector 상태 업데이트
+ setSelectedBidPic({
+ PURCHASE_GROUP_CODE: user.userCode || '',
+ DISPLAY_NAME: user.name,
+ EMPLOYEE_NUMBER: user.employeeNumber || '',
+ user: {
+ id: user.id,
+ name: user.name,
+ email: '',
+ employeeNumber: user.employeeNumber
+ }
+ } as any)
+ }
+ } catch (error) {
+ console.error('Failed to fetch user details:', error)
+ // 실패 시 세션 정보로 폴백
+ if (session?.user?.name) {
+ form.setValue('bidPicName', session.user.name)
+ form.setValue('bidPicId', userId)
+ }
+ }
+ }
}
+ initUser()
+
loadPaymentTerms()
loadIncoterms()
loadShippingPlaces()
@@ -181,7 +202,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp form.setValue('biddingConditions.taxConditions', 'V1')
}
}
- }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form])
+ }, [isOpen, userId, session, form, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
// SHI용 파일 첨부 핸들러
const handleShiFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index 27a2c097..13c58311 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -88,7 +88,6 @@ interface BiddingBasicInfo { contractEndDate?: string submissionStartDate?: string submissionEndDate?: string - evaluationDate?: string hasSpecificationMeeting?: boolean hasPrDocument?: boolean currency?: string @@ -252,7 +251,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB contractEndDate: formatDate(bidding.contractEndDate), submissionStartDate: formatDateTime(bidding.submissionStartDate), submissionEndDate: formatDateTime(bidding.submissionEndDate), - evaluationDate: formatDateTime(bidding.evaluationDate), hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, hasPrDocument: bidding.hasPrDocument || false, currency: bidding.currency || 'KRW', diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 6634f528..9bfea90e 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Building, User, Plus, Trash2 } from 'lucide-react' +import { Building, User, Plus, Trash2, Users } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -11,7 +11,9 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion + updateBiddingCompanyPriceAdjustmentQuestion, + getBiddingCompaniesByBidPicId, + addBiddingCompanyFromOtherBidding } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -36,6 +38,7 @@ import { } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' interface QuotationVendor { id: number // biddingCompanies.id @@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null) + // 협력사 멀티 선택 다이얼로그 + const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false) + const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined) + const [biddingCompaniesList, setBiddingCompaniesList] = React.useState<Array<{ + biddingId: number + biddingNumber: string + biddingTitle: string + companyId: number + vendorCode: string + vendorName: string + updatedAt: Date + }>>([]) + const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false) + const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{ + biddingId: number + companyId: number + } | null>(null) + const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([]) + const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false) + // 업체 목록 다시 로딩 함수 const reloadVendors = React.useCallback(async () => { try { @@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </p> </div> {!readonly && ( - <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> - <Plus className="h-4 w-4" /> - 업체 추가 - </Button> + <div className="flex gap-2"> + <Button onClick={() => setMultiSelectDialogOpen(true)} className="flex items-center gap-2" disabled={readonly} variant="outline"> + <Users className="h-4 w-4" /> + 협력사 멀티 선택 + </Button> + <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> + <Plus className="h-4 w-4" /> + 업체 추가 + </Button> + </div> )} </CardHeader> <CardContent> @@ -537,7 +566,22 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </TableCell> <TableCell className="font-medium">{vendor.vendorName}</TableCell> <TableCell>{vendor.vendorCode}</TableCell> - <TableCell>{vendor.businessSize || '-'}</TableCell> + <TableCell> + {(() => { + switch (vendor.businessSize) { + case 'A': + return '대기업'; + case 'B': + return '중견기업'; + case 'C': + return '중소기업'; + case 'D': + return '소기업'; + default: + return '-'; + } + })()} + </TableCell> <TableCell> {vendor.companyId && vendorFirstContacts.has(vendor.companyId) ? vendorFirstContacts.get(vendor.companyId)!.contactName @@ -740,6 +784,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </DialogContent> </Dialog> + {/* 협력사 멀티 선택 다이얼로그 */} + <Dialog open={multiSelectDialogOpen} onOpenChange={setMultiSelectDialogOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>참여협력사 선택</DialogTitle> + <DialogDescription> + 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 입찰담당자 선택 */} + <div className="space-y-2"> + <Label>입찰담당자 선택</Label> + <PurchaseGroupCodeSelector + selectedCode={selectedBidPic} + onCodeSelect={async (code) => { + setSelectedBidPic(code) + if (code.user?.id) { + setIsLoadingBiddingCompanies(true) + try { + const result = await getBiddingCompaniesByBidPicId(code.user.id) + if (result.success && result.data) { + setBiddingCompaniesList(result.data) + } else { + toast.error(result.error || '입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } + } catch (error) { + console.error('Failed to load bidding companies:', error) + toast.error('입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } finally { + setIsLoadingBiddingCompanies(false) + } + } + }} + placeholder="입찰담당자 선택" + disabled={readonly} + /> + </div> + + {/* 입찰 업체 목록 */} + {isLoadingBiddingCompanies ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">입찰 업체를 불러오는 중...</span> + </div> + ) : biddingCompaniesList.length === 0 && selectedBidPic ? ( + <div className="text-center py-8 text-muted-foreground"> + 해당 입찰담당자의 입찰 업체가 없습니다. + </div> + ) : biddingCompaniesList.length > 0 ? ( + <div className="space-y-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">선택</TableHead> + <TableHead>입찰번호</TableHead> + <TableHead>입찰명</TableHead> + <TableHead>협력사코드</TableHead> + <TableHead>협력사명</TableHead> + <TableHead>입찰 업데이트일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {biddingCompaniesList.map((company) => { + const isSelected = selectedBiddingCompany?.biddingId === company.biddingId && + selectedBiddingCompany?.companyId === company.companyId + return ( + <TableRow + key={`${company.biddingId}-${company.companyId}`} + className={`cursor-pointer hover:bg-muted/50 ${ + isSelected ? 'bg-muted/50' : '' + }`} + onClick={async () => { + if (isSelected) { + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + return + } + setSelectedBiddingCompany({ + biddingId: company.biddingId, + companyId: company.companyId + }) + setIsLoadingCompanyContacts(true) + try { + const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId) + if (contactsResult.success && contactsResult.data) { + setSelectedBiddingCompanyContacts(contactsResult.data) + } else { + setSelectedBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load company contacts:', error) + setSelectedBiddingCompanyContacts([]) + } finally { + setIsLoadingCompanyContacts(false) + } + }} + > + <TableCell onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={isSelected} + onCheckedChange={() => { + // 클릭 이벤트는 TableRow의 onClick에서 처리 + }} + disabled={readonly} + /> + </TableCell> + <TableCell className="font-medium">{company.biddingNumber}</TableCell> + <TableCell>{company.biddingTitle}</TableCell> + <TableCell>{company.vendorCode}</TableCell> + <TableCell>{company.vendorName}</TableCell> + <TableCell> + {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + + {/* 선택한 입찰 업체의 담당자 정보 */} + {selectedBiddingCompany !== null && ( + <div className="mt-4 p-4 border rounded-lg"> + <h4 className="font-medium mb-2">담당자 정보</h4> + {isLoadingCompanyContacts ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">담당자 정보를 불러오는 중...</span> + </div> + ) : selectedBiddingCompanyContacts.length === 0 ? ( + <div className="text-sm text-muted-foreground">등록된 담당자가 없습니다.</div> + ) : ( + <div className="space-y-2"> + {selectedBiddingCompanyContacts.map((contact) => ( + <div key={contact.id} className="text-sm"> + <span className="font-medium">{contact.contactName}</span> + <span className="text-muted-foreground ml-2">{contact.contactEmail}</span> + {contact.contactNumber && ( + <span className="text-muted-foreground ml-2">{contact.contactNumber}</span> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + ) : null} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + }} + > + 취소 + </Button> + <Button + onClick={async () => { + if (!selectedBiddingCompany) { + toast.error('입찰 업체를 선택해주세요.') + return + } + + const selectedCompany = biddingCompaniesList.find( + c => c.biddingId === selectedBiddingCompany.biddingId && + c.companyId === selectedBiddingCompany.companyId + ) + + if (!selectedCompany) { + toast.error('선택한 입찰 업체 정보를 찾을 수 없습니다.') + return + } + + try { + const contacts = selectedBiddingCompanyContacts.map(c => ({ + contactName: c.contactName, + contactEmail: c.contactEmail, + contactNumber: c.contactNumber || undefined, + })) + + const result = await addBiddingCompanyFromOtherBidding( + biddingId, + selectedCompany.biddingId, + selectedCompany.companyId, + contacts.length > 0 ? contacts : undefined + ) + + if (result.success) { + toast.success('업체가 성공적으로 추가되었습니다.') + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + await reloadVendors() + } else { + toast.error(result.error || '업체 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add bidding company:', error) + toast.error('업체 추가에 실패했습니다.') + } + }} + disabled={!selectedBiddingCompany || readonly} + > + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + {/* 벤더 담당자에서 추가 다이얼로그 */} <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx index 0dd9f0eb..489f104d 100644 --- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -408,7 +408,20 @@ export function BiddingDetailVendorCreateDialog({ 연동제 적용요건 문의 </Label> <span className="text-xs text-muted-foreground"> - 기업규모: {businessSizeMap[item.vendor.id] || '미정'} + 기업규모: {(() => { + switch (businessSizeMap[item.vendor.id]) { + case 'A': + return '대기업'; + case 'B': + return '중견기업'; + case 'C': + return '중소기업'; + case 'D': + return '소기업'; + default: + return '-'; + } + })()} </span> </div> </div> diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ 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' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState<File | null>(null) + const [importErrors, setImportErrors] = React.useState<string[]>([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return ( <div className="flex items-center justify-center p-8"> @@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <FileText className="h-4 w-4" /> 사전견적 </Button> + <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}> + <FileSpreadsheet className="h-4 w-4" /> + {isExporting ? "내보내는 중..." : "Excel 내보내기"} + </Button> + <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}> + <Upload className="h-4 w-4" /> + Excel 가져오기 + </Button> <Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}> <Plus className="h-4 w-4" /> 품목 추가 @@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems toast.success('사전견적용 일반견적이 생성되었습니다') }} /> + + {/* Excel 가져오기 다이얼로그 */} + <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Excel 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 업로드하여 품목을 일괄 추가합니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4"> + <div> + <Label htmlFor="import-file">Excel 파일 선택</Label> + <Input + id="import-file" + type="file" + accept=".xlsx,.xls" + onChange={handleImportFileSelect} + className="mt-2" + disabled={isImporting} + /> + {importFile && ( + <p className="text-sm text-muted-foreground mt-2"> + 선택된 파일: {importFile.name} + </p> + )} + </div> + + {importErrors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-destructive">오류 목록</Label> + <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5"> + <ul className="list-disc list-inside space-y-1"> + {importErrors.map((error, index) => ( + <li key={index} className="text-sm text-destructive"> + {error} + </li> + ))} + </ul> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground space-y-1"> + <p className="font-semibold">필수 컬럼:</p> + <ul className="list-disc list-inside ml-2"> + <li>자재그룹코드, 자재그룹명</li> + <li>수량 또는 중량 (둘 중 하나 필수)</li> + <li>수량단위 또는 중량단위</li> + <li>납품요청일 (YYYY-MM-DD 형식)</li> + <li>내정단가</li> + </ul> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + }} + disabled={isImporting} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!importFile || isImporting} + > + {isImporting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + "가져오기" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index 49659ae7..72961c3d 100644 --- a/components/bidding/manage/bidding-schedule-editor.tsx +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service' import { useToast } from '@/hooks/use-toast' import { format } from 'date-fns' interface BiddingSchedule { - submissionStartDate?: string - submissionEndDate?: string + submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일) + submissionStartTime?: string // 시작 시간 (HH:MM) + submissionDurationDays?: number // 기간 (시작일 + n일) + submissionEndTime?: string // 마감 시간 (HH:MM) remarks?: string isUrgent?: boolean hasSpecificationMeeting?: boolean @@ -149,6 +151,47 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc return new Date(kstTime).toISOString().slice(0, 16) } + // timestamp에서 시간(HH:MM) 추출 + // 수정: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야 + // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자를 가져올 수 있습니다. + const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => { + if (!date) return '' + const d = new Date(date) + + // 중요: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야 + // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자(09:00)를 가져올 수 있습니다. + const hours = d.getUTCHours().toString().padStart(2, '0') + const minutes = d.getUTCMinutes().toString().padStart(2, '0') + + return `${hours}:${minutes}` + } + + // 예상 일정 계산 (오늘 기준 미리보기) + const getPreviewDates = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const startOffset = schedule.submissionStartOffset ?? 0 + const durationDays = schedule.submissionDurationDays ?? 7 + const startTime = schedule.submissionStartTime || '09:00' + const endTime = schedule.submissionEndTime || '18:00' + + // 시작일 계산 + const startDate = new Date(today) + startDate.setDate(startDate.getDate() + startOffset) + const [startHour, startMinute] = startTime.split(':').map(Number) + startDate.setHours(startHour, startMinute, 0, 0) + + // 마감일 계산 + const endDate = new Date(startDate) + endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만 + endDate.setDate(endDate.getDate() + durationDays) + const [endHour, endMinute] = endTime.split(':').map(Number) + endDate.setHours(endHour, endMinute, 0, 0) + + return { startDate, endDate } + } + // 데이터 로딩 React.useEffect(() => { const loadSchedule = async () => { @@ -165,36 +208,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc }) setSchedule({ - submissionStartDate: toKstInputValue(bidding.submissionStartDate), - submissionEndDate: toKstInputValue(bidding.submissionEndDate), + submissionStartOffset: bidding.submissionStartOffset ?? 1, + submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00', + submissionDurationDays: bidding.submissionDurationDays ?? 7, + submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00', remarks: bidding.remarks || '', isUrgent: bidding.isUrgent || false, hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, }) - // 사양설명회 정보 로드 - if (bidding.hasSpecificationMeeting) { - try { - const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) - if (meetingDetails.success && meetingDetails.data) { - const meeting = meetingDetails.data - setSpecMeetingInfo({ - meetingDate: toKstInputValue(meeting.meetingDate), - meetingTime: meeting.meetingTime || '', - location: meeting.location || '', - address: meeting.address || '', - contactPerson: meeting.contactPerson || '', - contactPhone: meeting.contactPhone || '', - contactEmail: meeting.contactEmail || '', - agenda: meeting.agenda || '', - materials: meeting.materials || '', - notes: meeting.notes || '', - isRequired: meeting.isRequired || false, - }) - } - } catch (error) { - console.error('Failed to load specification meeting details:', error) + // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드) + try { + const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) + if (meetingDetails.success && meetingDetails.data) { + const meeting = meetingDetails.data + setSpecMeetingInfo({ + meetingDate: toKstInputValue(meeting.meetingDate), + meetingTime: meeting.meetingTime || '', + location: meeting.location || '', + address: meeting.address || '', + contactPerson: meeting.contactPerson || '', + contactPhone: meeting.contactPhone || '', + contactEmail: meeting.contactEmail || '', + agenda: meeting.agenda || '', + materials: meeting.materials || '', + notes: meeting.notes || '', + isRequired: meeting.isRequired || false, + }) } + } catch (error) { + console.error('Failed to load specification meeting details:', error) } } } catch (error) { @@ -258,10 +301,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc const handleBiddingInvitationClick = async () => { try { // 1. 입찰서 제출기간 검증 - if (!schedule.submissionStartDate || !schedule.submissionEndDate) { + if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', - description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', + description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', + variant: 'destructive', + }) + return + } + if (!schedule.submissionStartTime || !schedule.submissionEndTime) { + toast({ + title: '입찰서 제출시간 미설정', + description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', variant: 'destructive', }) return @@ -484,10 +535,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc const userId = session?.user?.id?.toString() || '1' // 입찰서 제출기간 필수 검증 - if (!schedule.submissionStartDate || !schedule.submissionEndDate) { + if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', - description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', + description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + if (!schedule.submissionStartTime || !schedule.submissionEndTime) { + toast({ + title: '입찰서 제출시간 미설정', + description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + // 오프셋/기간 검증 + if (schedule.submissionStartOffset < 0) { + toast({ + title: '시작일 오프셋 오류', + description: '시작일 오프셋은 0 이상이어야 합니다.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + if (schedule.submissionDurationDays < 1) { + toast({ + title: '기간 오류', + description: '입찰 기간은 최소 1일 이상이어야 합니다.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0) + if (!schedule.isUrgent && schedule.submissionStartOffset === 0) { + toast({ + title: '시작일 오류', + description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) setIsSubmitting(false) @@ -538,62 +627,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc } } - const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => { - // 마감일시 검증 - 현재일 이전 설정 불가 - if (field === 'submissionEndDate' && typeof value === 'string' && value) { - const selectedDate = new Date(value) - const now = new Date() - now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교 - - if (selectedDate < now) { + const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => { + // 시작일 오프셋 검증 + if (field === 'submissionStartOffset' && typeof value === 'number') { + if (value < 0) { + toast({ + title: '시작일 오프셋 오류', + description: '시작일 오프셋은 0 이상이어야 합니다.', + variant: 'destructive', + }) + return + } + // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가 + if (!schedule.isUrgent && value === 0) { toast({ - title: '마감일시 오류', - description: '마감일시는 현재일 이전으로 설정할 수 없습니다.', + title: '시작일 오프셋 오류', + description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) - return // 변경을 적용하지 않음 + return } } - // 긴급여부 미선택 시 당일 제출시작 불가 - if (field === 'submissionStartDate' && typeof value === 'string' && value) { - const selectedDate = new Date(value) - const today = new Date() - today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정 - selectedDate.setHours(0, 0, 0, 0) - - // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값) - const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false + // 기간 검증 + if (field === 'submissionDurationDays' && typeof value === 'number') { + if (value < 1) { + toast({ + title: '기간 오류', + description: '입찰 기간은 최소 1일 이상이어야 합니다.', + variant: 'destructive', + }) + return + } + } - // 긴급이 아닌 경우 당일 시작 불가 - if (!isUrgent && selectedDate.getTime() === today.getTime()) { + // 시간 형식 검증 (HH:MM) + if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') { + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/ + if (value && !timeRegex.test(value)) { toast({ - title: '제출 시작일시 오류', - description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.', + title: '시간 형식 오류', + description: '시간은 HH:MM 형식으로 입력해주세요.', variant: 'destructive', }) - return // 변경을 적용하지 않음 + return } } setSchedule(prev => ({ ...prev, [field]: value })) - - // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화 - if (field === 'hasSpecificationMeeting' && value === false) { - setSpecMeetingInfo({ - meetingDate: '', - meetingTime: '', - location: '', - address: '', - contactPerson: '', - contactPhone: '', - contactEmail: '', - agenda: '', - materials: '', - notes: '', - isRequired: false, - }) - } + // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지) } if (isLoading) { @@ -624,40 +706,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc <Clock className="h-4 w-4" /> 입찰서 제출 기간 </h3> + <p className="text-sm text-muted-foreground"> + 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다. + </p> + + {/* 시작일 설정 */} <div className="grid grid-cols-2 gap-4"> <div className="space-y-2"> - <Label htmlFor="submission-start">제출 시작일시 <span className="text-red-500">*</span></Label> + <Label htmlFor="submission-start-offset">시작일 (결재 후) <span className="text-red-500">*</span></Label> + <div className="flex items-center gap-2"> + <Input + id="submission-start-offset" + type="number" + min={schedule.isUrgent ? 0 : 1} + value={schedule.submissionStartOffset ?? ''} + onChange={(e) => handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)} + className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="0" + /> + <span className="text-sm text-muted-foreground whitespace-nowrap">일 후</span> + </div> + {schedule.submissionStartOffset === undefined && ( + <p className="text-sm text-red-500">시작일 오프셋은 필수입니다</p> + )} + {!schedule.isUrgent && schedule.submissionStartOffset === 0 && ( + <p className="text-sm text-amber-600">긴급 입찰만 당일 시작(0일) 가능</p> + )} + </div> + <div className="space-y-2"> + <Label htmlFor="submission-start-time">시작 시간 <span className="text-red-500">*</span></Label> <Input - id="submission-start" - type="datetime-local" - value={schedule.submissionStartDate} - onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)} - className={!schedule.submissionStartDate ? 'border-red-200' : ''} + id="submission-start-time" + type="time" + value={schedule.submissionStartTime || ''} + onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)} + className={!schedule.submissionStartTime ? 'border-red-200' : ''} disabled={readonly} - min="1900-01-01T00:00" - max="2100-12-31T23:59" /> - {!schedule.submissionStartDate && ( - <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p> + {!schedule.submissionStartTime && ( + <p className="text-sm text-red-500">시작 시간은 필수입니다</p> + )} + </div> + </div> + + {/* 마감일 설정 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="submission-duration">입찰 기간 (시작일 +) <span className="text-red-500">*</span></Label> + <div className="flex items-center gap-2"> + <Input + id="submission-duration" + type="number" + min={1} + value={schedule.submissionDurationDays ?? ''} + onChange={(e) => handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)} + className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="7" + /> + <span className="text-sm text-muted-foreground whitespace-nowrap">일간</span> + </div> + {schedule.submissionDurationDays === undefined && ( + <p className="text-sm text-red-500">입찰 기간은 필수입니다</p> )} </div> <div className="space-y-2"> - <Label htmlFor="submission-end">제출 마감일시 <span className="text-red-500">*</span></Label> + <Label htmlFor="submission-end-time">마감 시간 <span className="text-red-500">*</span></Label> <Input - id="submission-end" - type="datetime-local" - value={schedule.submissionEndDate} - onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)} - className={!schedule.submissionEndDate ? 'border-red-200' : ''} + id="submission-end-time" + type="time" + value={schedule.submissionEndTime || ''} + onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)} + className={!schedule.submissionEndTime ? 'border-red-200' : ''} disabled={readonly} - min="1900-01-01T00:00" - max="2100-12-31T23:59" /> - {!schedule.submissionEndDate && ( - <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p> + {!schedule.submissionEndTime && ( + <p className="text-sm text-red-500">마감 시간은 필수입니다</p> )} </div> </div> + + {/* 예상 일정 미리보기 */} + {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && ( + <div className="p-3 bg-blue-50 rounded-lg border border-blue-200"> + <p className="text-sm font-medium text-blue-800 mb-1">📅 예상 일정 (오늘 공고 기준)</p> + <div className="text-sm text-blue-700"> + <span>시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")}</span> + <span className="mx-2">~</span> + <span>마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}</span> + </div> + </div> + )} </div> {/* 긴급 여부 */} @@ -690,8 +830,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc /> </div> - {/* 사양설명회 상세 정보 */} - {schedule.hasSpecificationMeeting && ( + {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */} + {(schedule.hasSpecificationMeeting) && ( <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> <div className="grid grid-cols-2 gap-4"> <div> @@ -834,10 +974,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc <CardContent> <div className="space-y-2 text-sm"> <div className="flex justify-between"> - <span className="font-medium">입찰서 제출 기간:</span> + <span className="font-medium">시작일:</span> + <span> + {schedule.submissionStartOffset !== undefined + ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}` + : '미설정' + } + </span> + </div> + <div className="flex justify-between"> + <span className="font-medium">마감일:</span> <span> - {schedule.submissionStartDate && schedule.submissionEndDate - ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}` + {schedule.submissionDurationDays !== undefined + ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}` : '미설정' } </span> diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index de3c19ff..b0cecc25 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -26,13 +26,6 @@ import { FormMessage,
FormDescription,
} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
@@ -41,20 +34,15 @@ import { PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service"
-import { previewGeneralRfqCode } from "@/lib/rfq-last/service"
-import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
-import { MaterialSearchItem } from "@/lib/material/material-group-service"
-import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
-import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code"
import { getBiddingById } from "@/lib/bidding/service"
+import { getProjectIdByCodeAndName } from "@/lib/bidding/manage/project-utils"
// 아이템 스키마
const itemSchema = z.object({
@@ -64,6 +52,8 @@ const itemSchema = z.object({ materialName: z.string().optional(),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
+ totalWeight: z.union([z.number(), z.string(), z.null()]).optional(), // 중량 추가
+ weightUnit: z.string().optional().nullable(), // 중량단위 추가
remark: z.string().optional(),
})
@@ -125,8 +115,6 @@ export function CreatePreQuoteRfqDialog({ onSuccess
}: CreatePreQuoteRfqDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
const { data: session } = useSession()
@@ -143,6 +131,8 @@ export function CreatePreQuoteRfqDialog({ materialName: item.materialInfo || "",
quantity: item.quantity ? parseFloat(item.quantity) : 1,
uom: item.quantityUnit || item.weightUnit || "EA",
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
remark: "",
}))
}, [biddingItems])
@@ -164,6 +154,8 @@ export function CreatePreQuoteRfqDialog({ materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
@@ -231,6 +223,14 @@ export function CreatePreQuoteRfqDialog({ const pName = bidding.projectName || "";
setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || "");
+ // 프로젝트 ID 조회
+ if (pCode && pName) {
+ const fetchedProjectId = await getProjectIdByCodeAndName(pCode, pName)
+ if (fetchedProjectId) {
+ form.setValue("projectId", fetchedProjectId)
+ }
+ }
+
// 폼 값 설정
form.setValue("rfqTitle", rfqTitle);
form.setValue("rfqType", "pre_bidding"); // 기본값 설정
@@ -264,36 +264,15 @@ export function CreatePreQuoteRfqDialog({ materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
- setPreviewCode("")
}
}, [open, initialItems, form, selectedBidPic, biddingId])
- // 견적담당자 선택 시 RFQ 코드 미리보기 생성
- React.useEffect(() => {
- if (!selectedBidPic?.user?.id) {
- setPreviewCode("")
- return
- }
-
- // 즉시 실행 함수 패턴 사용
- (async () => {
- setIsLoadingPreview(true)
- try {
- const code = await previewGeneralRfqCode(selectedBidPic.user!.id)
- setPreviewCode(code)
- } catch (error) {
- console.error("코드 미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- })()
- }, [selectedBidPic])
-
// 견적 종류 변경
const handleRfqTypeChange = (value: string) => {
form.setValue("rfqType", value)
@@ -315,12 +294,13 @@ export function CreatePreQuoteRfqDialog({ materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
setSelectedBidPic(undefined)
- setPreviewCode("")
onOpenChange(false)
}
@@ -350,15 +330,17 @@ export function CreatePreQuoteRfqDialog({ biddingNumber: data.biddingNumber, // 추가
contractStartDate: data.contractStartDate, // 추가
contractEndDate: data.contractEndDate, // 추가
- items: data.items as Array<{
- itemCode: string;
- itemName: string;
- materialCode?: string;
- materialName?: string;
- quantity: number;
- uom: string;
- remark?: string;
- }>,
+ items: data.items.map(item => ({
+ itemCode: item.itemCode || "",
+ itemName: item.itemName || "",
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ quantity: item.quantity,
+ uom: item.uom,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+ remark: item.remark,
+ })),
biddingConditions: biddingConditions || undefined,
createdBy: userId,
updatedBy: userId,
@@ -465,7 +447,7 @@ export function CreatePreQuoteRfqDialog({ )}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +459,40 @@ export function CreatePreQuoteRfqDialog({ <Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
+ onSelect={(date) => {
+ if (!date) {
+ field.onChange(undefined)
+ return
+ }
+ const newDate = new Date(date)
+ if (field.value) {
+ newDate.setHours(field.value.getHours(), field.value.getMinutes())
+ } else {
+ newDate.setHours(0, 0, 0, 0)
+ }
+ field.onChange(newDate)
+ }}
+ disabled={(date) => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ return date < today || date < new Date("1900-01-01")
+ }}
initialFocus
/>
+ <div className="p-3 border-t border-border">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value) {
+ const [hours, minutes] = e.target.value.split(':').map(Number)
+ const newDate = new Date(field.value)
+ newDate.setHours(hours, minutes)
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
</PopoverContent>
</Popover>
<FormMessage />
@@ -562,17 +572,7 @@ export function CreatePreQuoteRfqDialog({ </FormItem>
)}
/>
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
- <div className="flex items-center gap-2">
- <Badge variant="secondary" className="font-mono text-sm">
- 예상 RFQ 코드: {previewCode}
- </Badge>
- {isLoadingPreview && (
- <Loader2 className="h-3 w-3 animate-spin" />
- )}
- </div>
- )}
+
{/* 계약기간 */}
<div className="grid grid-cols-2 gap-4">
diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx new file mode 100644 index 00000000..6e768601 --- /dev/null +++ b/components/common/date-picker/date-picker-with-input.tsx @@ -0,0 +1,322 @@ +"use client" + +import * as React from "react" +import { format, parse, isValid } from "date-fns" +import { ko } from "date-fns/locale" +import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +export interface DatePickerWithInputProps { + value?: Date + onChange?: (date: Date | undefined) => void + disabled?: boolean + placeholder?: string + className?: string + minDate?: Date + maxDate?: Date + dateFormat?: string + inputClassName?: string + locale?: "ko" | "en" +} + +/** + * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기 + * + * 사용법: + * ```tsx + * <DatePickerWithInput + * value={selectedDate} + * onChange={(date) => setSelectedDate(date)} + * placeholder="날짜를 선택하세요" + * minDate={new Date()} + * /> + * ``` + */ +export function DatePickerWithInput({ + value, + onChange, + disabled = false, + placeholder = "YYYY-MM-DD", + className, + minDate, + maxDate, + dateFormat = "yyyy-MM-dd", + inputClassName, + locale: localeProp = "en", +}: DatePickerWithInputProps) { + const [open, setOpen] = React.useState(false) + const [inputValue, setInputValue] = React.useState<string>("") + const [month, setMonth] = React.useState<Date>(value || new Date()) + const [hasError, setHasError] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState<string>("") + + // 외부 value가 변경되면 inputValue도 업데이트 + React.useEffect(() => { + if (value && isValid(value)) { + setInputValue(format(value, dateFormat)) + setMonth(value) + setHasError(false) + setErrorMessage("") + } else { + setInputValue("") + } + }, [value, dateFormat]) + + // 날짜 유효성 검사 및 에러 메시지 설정 + const validateDate = (date: Date): { valid: boolean; message: string } => { + if (minDate) { + const minDateStart = new Date(minDate) + minDateStart.setHours(0, 0, 0, 0) + const dateToCheck = new Date(date) + dateToCheck.setHours(0, 0, 0, 0) + if (dateToCheck < minDateStart) { + return { + valid: false, + message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요` + } + } + } + if (maxDate) { + const maxDateEnd = new Date(maxDate) + maxDateEnd.setHours(23, 59, 59, 999) + if (date > maxDateEnd) { + return { + valid: false, + message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요` + } + } + } + return { valid: true, message: "" } + } + + // 캘린더에서 날짜 선택 + const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => { + // 이벤트 전파 중지 + if (e) { + e.preventDefault() + e.stopPropagation() + } + + if (date) { + const validation = validateDate(date) + if (validation.valid) { + setInputValue(format(date, dateFormat)) + setHasError(false) + setErrorMessage("") + onChange?.(date) + setMonth(date) + } else { + setHasError(true) + setErrorMessage(validation.message) + } + } + setOpen(false) + }, [dateFormat, onChange, minDate, maxDate]) + + // 직접 입력값 변경 + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const newValue = e.target.value + setInputValue(newValue) + + // 입력 중에는 에러 상태 초기화 + if (hasError) { + setHasError(false) + setErrorMessage("") + } + + // YYYY-MM-DD 형식인 경우에만 파싱 시도 + if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) { + const parsedDate = parse(newValue, dateFormat, new Date()) + + if (isValid(parsedDate)) { + const validation = validateDate(parsedDate) + if (validation.valid) { + setHasError(false) + setErrorMessage("") + onChange?.(parsedDate) + setMonth(parsedDate) + } else { + setHasError(true) + setErrorMessage(validation.message) + } + } else { + setHasError(true) + setErrorMessage("유효하지 않은 날짜 형식입니다") + } + } + } + + // 입력 완료 시 (blur) 유효성 검사 + const handleInputBlur = () => { + if (!inputValue) { + setHasError(false) + setErrorMessage("") + onChange?.(undefined) + return + } + + const parsedDate = parse(inputValue, dateFormat, new Date()) + + if (isValid(parsedDate)) { + const validation = validateDate(parsedDate) + if (validation.valid) { + setHasError(false) + setErrorMessage("") + onChange?.(parsedDate) + } else { + setHasError(true) + setErrorMessage(validation.message) + // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시 + } + } else { + // 유효하지 않은 형식인 경우 + setHasError(true) + setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요") + } + } + + // 키보드 이벤트 처리 (Enter 키로 완료) + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + handleInputBlur() + } + } + + // 날짜 비활성화 체크 (캘린더용) + const isDateDisabled = (date: Date) => { + if (disabled) return true + if (minDate) { + const minDateStart = new Date(minDate) + minDateStart.setHours(0, 0, 0, 0) + const dateToCheck = new Date(date) + dateToCheck.setHours(0, 0, 0, 0) + if (dateToCheck < minDateStart) return true + } + if (maxDate) { + const maxDateEnd = new Date(maxDate) + maxDateEnd.setHours(23, 59, 59, 999) + if (date > maxDateEnd) return true + } + return false + } + + // 캘린더 버튼 클릭 핸들러 + const handleCalendarButtonClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setOpen(!open) + } + + // Popover 상태 변경 핸들러 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + } + + return ( + <div className={cn("relative", className)}> + <div className="flex items-center gap-1"> + <Input + type="text" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className={cn( + "pr-10", + hasError && "border-red-500 focus-visible:ring-red-500", + inputClassName + )} + /> + <Popover open={open} onOpenChange={handleOpenChange} modal={true}> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="icon" + className="absolute right-0 h-full px-3 hover:bg-transparent" + disabled={disabled} + type="button" + onClick={handleCalendarButtonClick} + > + <CalendarIcon className={cn( + "h-4 w-4", + hasError ? "text-red-500" : "text-muted-foreground" + )} /> + </Button> + </PopoverTrigger> + <PopoverContent + className="w-auto p-0" + align="end" + onPointerDownOutside={(e) => e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + <div + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + <DayPicker + mode="single" + selected={value} + onSelect={(date, selectedDay, activeModifiers, e) => { + handleCalendarSelect(date, e as unknown as React.MouseEvent) + }} + month={month} + onMonthChange={setMonth} + disabled={isDateDisabled} + locale={localeProp === "ko" ? ko : undefined} + showOutsideDays + className="p-3" + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md", + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_hidden: "invisible", + }} + components={{ + IconLeft: () => <ChevronLeft className="h-4 w-4" />, + IconRight: () => <ChevronRight className="h-4 w-4" />, + }} + /> + </div> + </PopoverContent> + </Popover> + </div> + {/* 에러 메시지 표시 */} + {hasError && errorMessage && ( + <p className="text-xs text-red-500 mt-1">{errorMessage}</p> + )} + </div> + ) +} + diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts new file mode 100644 index 00000000..85c0c259 --- /dev/null +++ b/components/common/date-picker/index.ts @@ -0,0 +1,3 @@ +// 공용 날짜 선택기 컴포넌트 +export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input' + diff --git a/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx new file mode 100644 index 00000000..aeefbb84 --- /dev/null +++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader, Database, Check } from "lucide-react" +import { toast } from "sonner" +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" + +import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service" + +interface CPVWWabQustListViewDialogProps { + onConfirm?: (selectedRows: CPVWWabQustListView[]) => void + requireSingleSelection?: boolean + triggerDisabled?: boolean + triggerTitle?: string +} + +export function CPVWWabQustListViewDialog({ + onConfirm, + requireSingleSelection = false, + triggerDisabled = false, + triggerTitle, +}: CPVWWabQustListViewDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [data, setData] = React.useState<CPVWWabQustListView[]>([]) + const [error, setError] = React.useState<string | null>(null) + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}) + + const loadData = async () => { + setIsLoading(true) + setError(null) + try { + const result = await getCPVWWabQustListViewData() + if (result.success) { + setData(result.data) + if (result.isUsingFallback) { + toast.info("테스트 데이터를 표시합니다.") + } + } else { + setError(result.error || "데이터 로딩 실패") + toast.error(result.error || "데이터 로딩 실패") + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류" + setError(errorMessage) + toast.error(errorMessage) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (open) { + loadData() + } else { + // 다이얼로그 닫힐 때 데이터 초기화 + setData([]) + setError(null) + setRowSelection({}) + } + }, [open]) + + // 테이블 컬럼 정의 (동적 생성) + const columns = React.useMemo<ColumnDef<CPVWWabQustListView>[]>(() => { + if (data.length === 0) return [] + + const dataKeys = Object.keys(data[0]) + + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...dataKeys.map((key) => ({ + accessorKey: key, + header: key, + cell: ({ getValue }: any) => { + const value = getValue() + return value !== null && value !== undefined ? String(value) : "" + }, + })), + ] + }, [data]) + + // 테이블 인스턴스 생성 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + // 확인 버튼 핸들러 + const handleConfirm = () => { + if (selectedRows.length === 0) { + toast.error("행을 선택해주세요.") + return + } + + if (requireSingleSelection && selectedRows.length !== 1) { + toast.error("하나의 행만 선택해주세요.") + return + } + + if (onConfirm) { + onConfirm(selectedRows) + toast.success( + requireSingleSelection + ? "선택한 행으로 준법문의 상태를 동기화합니다." + : `${selectedRows.length}개의 행을 선택했습니다.` + ) + } else { + // 임시로 선택된 데이터 콘솔 출력 + console.log("선택된 행들:", selectedRows) + toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`) + } + + setOpen(false) + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + disabled={triggerDisabled} + title={triggerTitle} + > + <Database className="mr-2 size-4" aria-hidden="true" /> + 준법문의 요청 데이터 조회 + </Button> + </DialogTrigger> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col overflow-hidden"> + <DialogHeader> + <DialogTitle>준법문의 요청 데이터</DialogTitle> + <DialogDescription> + 준법문의 요청 데이터를 조회합니다. + {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`} + </DialogDescription> + </DialogHeader> + + <div className="flex flex-col flex-1 min-h-0"> + {isLoading ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px]"> + <Loader className="mr-2 size-6 animate-spin" /> + <span>데이터 로딩 중...</span> + </div> + ) : error ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-red-500"> + <span>오류: {error}</span> + </div> + ) : data.length === 0 ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-muted-foreground"> + <span>데이터가 없습니다.</span> + </div> + ) : ( + <div className="flex flex-col flex-1 min-h-0"> + {/* 테이블 영역 - 스크롤 가능 */} + <ScrollArea className="flex-1 overflow-auto border rounded-md"> + <Table className="min-w-full"> + <TableHeader className="sticky top-0 bg-background z-10"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} className="font-medium 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"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id} className="text-sm"> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + + {/* 페이지네이션 컨트롤 - 고정 영역 */} + <div className="flex items-center justify-between px-2 py-4 border-t bg-background flex-shrink-0"> + <div className="flex-1 text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨 + </div> + <div className="flex items-center space-x-6 lg:space-x-8"> + <div className="flex items-center space-x-2"> + <p className="text-sm font-medium">페이지당 행 수</p> + <select + value={table.getState().pagination.pageSize} + onChange={(e) => { + table.setPageSize(Number(e.target.value)) + }} + className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + > + {[10, 20, 30, 40, 50].map((pageSize) => ( + <option key={pageSize} value={pageSize}> + {pageSize} + </option> + ))} + </select> + </div> + <div className="flex w-[100px] items-center justify-center text-sm font-medium"> + {table.getState().pagination.pageIndex + 1} /{" "} + {table.getPageCount()} + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + {"<<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지</span> + {"<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지</span> + {">"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + {">>"} + </Button> + </div> + </div> + </div> + </div> + )} + </div> + + <DialogFooter className="gap-2 flex-shrink-0"> + <Button variant="outline" onClick={() => setOpen(false)}> + 닫기 + </Button> + <Button onClick={loadData} disabled={isLoading} variant="outline"> + {isLoading ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + 로딩 중... + </> + ) : ( + "새로고침" + )} + </Button> + <Button + onClick={handleConfirm} + disabled={ + requireSingleSelection + ? selectedRows.length !== 1 + : selectedRows.length === 0 + } + > + <Check className="mr-2 size-4" /> + 확인 ({selectedRows.length}) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + 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 index 84fd85ff..a1b98468 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps { title?: string; description?: string; showConfirmButtons?: boolean; + disabled?: boolean; } /** @@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({ title = "1회성 품목 선택", description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, + disabled = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = @@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} size={triggerSize}> + <Button variant={triggerVariant} size={triggerSize} disabled={disabled}> {selectedProcurementItem ? ( <span className="truncate"> {`${selectedProcurementItem.itemCode}`} diff --git a/components/docu-list-rule/docu-list-rule-client.tsx b/components/docu-list-rule/docu-list-rule-client.tsx index ae3cdece..587ec7ff 100644 --- a/components/docu-list-rule/docu-list-rule-client.tsx +++ b/components/docu-list-rule/docu-list-rule-client.tsx @@ -2,9 +2,10 @@ import * as React from "react" import { useRouter, useParams } from "next/navigation" import { ProjectSelector } from "../ProjectSelector" +import { useTranslation } from "@/i18n/client" interface DocuListRuleClientProps { - children: React.ReactNode + children: React.ReactNode; } export default function DocuListRuleClient({ @@ -13,7 +14,7 @@ export default function DocuListRuleClient({ const router = useRouter() const params = useParams() const lng = (params?.lng as string) || "ko" - + const { t } = useTranslation(lng, 'menu') // Get the projectId from route parameters const projectIdFromUrl = React.useMemo(() => { if (params?.projectId) { @@ -53,10 +54,10 @@ export default function DocuListRuleClient({ {/* 왼쪽: 타이틀 & 설명 */} <div> <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">Document Numbering Rule (해양)</h2> + <h2 className="text-2xl font-bold tracking-tight">{t('menu.master_data.document_numbering_rule')}</h2> </div> <p className="text-muted-foreground"> - 벤더 제출 문서 리스트 작성 시에 사용되는 넘버링 + {t('menu.master_data.document_numbering_rule_desc')} </p> </div> diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 70e93a68..d9915b66 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1097,7 +1097,8 @@ export default function DynamicTable({ </Button> {/* COMPARE WITH SEDP 버튼 */} - <Button + {/* TODO: 스마트엑셀 No.184 조치 전까지 비활성화 요청 받아 주석처리 */} + {/* <Button variant="outline" size="sm" onClick={handleSEDPCompareClick} @@ -1105,7 +1106,7 @@ export default function DynamicTable({ > <GitCompareIcon className="mr-2 size-4" /> {t("buttons.compareWithSEDP")} - </Button> + </Button> */} {/* SEDP 전송 버튼 */} <Button diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx index e03fffd9..744b0867 100644 --- a/components/information/information-button.tsx +++ b/components/information/information-button.tsx @@ -589,4 +589,4 @@ export function InformationButton({ /> */}
</>
)
-}
\ No newline at end of file +}
diff --git a/components/items-tech/item-tech-container.tsx b/components/items-tech/item-tech-container.tsx index 65e4ac93..38750658 100644 --- a/components/items-tech/item-tech-container.tsx +++ b/components/items-tech/item-tech-container.tsx @@ -12,6 +12,8 @@ import { DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { InformationButton } from "@/components/information/information-button"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
interface ItemType {
id: string
name: string
@@ -29,7 +31,10 @@ export function ItemTechContainer({ const router = useRouter()
const pathname = usePathname()
const searchParamsObj = useSearchParams()
-
+
+ const params = useParams<{lng: string}>()
+ const lng = params?.lng ?? 'ko'
+ const {t} = useTranslation(lng, 'menu')
// useSearchParams를 메모이제이션하여 안정적인 참조 생성
const searchParams = React.useMemo(
() => searchParamsObj || new URLSearchParams(),
@@ -57,7 +62,7 @@ export function ItemTechContainer({ {/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">자재 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">{t('menu.tech_sales.items')}</h2>
<InformationButton pagePath="evcp/items-tech" />
</div>
{/* <p className="text-muted-foreground">
diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx new file mode 100644 index 00000000..f94223ae --- /dev/null +++ b/components/layout/DynamicMenuRender.tsx @@ -0,0 +1,146 @@ +"use client"; + +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { NavigationMenuLink } from "@/components/ui/navigation-menu"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface DynamicMenuRenderProps { + groups: MenuTreeNode[] | undefined; + lng: string; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; + onItemClick?: () => void; +} + +export default function DynamicMenuRender({ + groups, + lng, + getTitle, + getDescription, + onItemClick, +}: DynamicMenuRenderProps) { + if (!groups || groups.length === 0) { + return ( + <div className="p-4 text-sm text-muted-foreground"> + 메뉴가 없습니다. + </div> + ); + } + + // 그룹별로 메뉴 분류 + const groupedMenus = new Map<string, MenuTreeNode[]>(); + const ungroupedMenus: MenuTreeNode[] = []; + + for (const item of groups) { + if (item.nodeType === "group") { + // 그룹인 경우, 그룹의 children을 해당 그룹에 추가 + const groupTitle = getTitle(item); + if (!groupedMenus.has(groupTitle)) { + groupedMenus.set(groupTitle, []); + } + if (item.children) { + groupedMenus.get(groupTitle)!.push(...item.children); + } + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우) + ungroupedMenus.push(item); + } + } + + // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링 + if (groupedMenus.size === 0 && ungroupedMenus.length > 0) { + return ( + <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]"> + {ungroupedMenus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + ); + } + + // 그룹별 렌더링 - 가로 스크롤 지원 + // 컨텐츠가 85vw를 초과할 때만 스크롤 발생 + return ( + <div className="p-4 max-w-[85vw]"> + <div className="flex gap-6 overflow-x-auto"> + {/* 그룹화되지 않은 메뉴 (있는 경우) */} + {ungroupedMenus.length > 0 && ( + <div className="w-[200px] flex-shrink-0"> + <ul className="space-y-2"> + {ungroupedMenus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + </div> + )} + + {/* 그룹별 메뉴 - 순서대로 가로 배치 */} + {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => ( + <div key={groupTitle} className="w-[200px] flex-shrink-0"> + <h4 className="mb-2 text-sm font-semibold text-muted-foreground whitespace-nowrap"> + {groupTitle} + </h4> + <ul className="space-y-2"> + {menus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + </div> + ))} + </div> + </div> + ); +} + +interface MenuListItemProps { + href: string; + title: string; + children?: React.ReactNode; + onClick?: () => void; +} + +function MenuListItem({ href, title, children, onClick }: MenuListItemProps) { + return ( + <li> + <NavigationMenuLink asChild> + <Link + href={href} + onClick={onClick} + className={cn( + "block select-none space-y-1 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" + )} + > + <div className="text-sm font-medium leading-none">{title}</div> + {children && ( + <p className="line-clamp-2 text-xs leading-snug text-muted-foreground"> + {children} + </p> + )} + </Link> + </NavigationMenuLink> + </li> + ); +} + diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx new file mode 100644 index 00000000..88d50cc5 --- /dev/null +++ b/components/layout/HeaderV2.tsx @@ -0,0 +1,295 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { SearchIcon, Loader2 } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { useSession } from "next-auth/react"; +import { customSignOut } from "@/lib/auth/custom-signout"; +import DynamicMenuRender from "./DynamicMenuRender"; +import { MobileMenuV2 } from "./MobileMenuV2"; +import { CommandMenu } from "./command-menu"; +import { NotificationDropdown } from "./NotificationDropdown"; +import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree"; +import { useTranslation } from "@/i18n/client"; +import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types"; + +// 도메인별 브랜드명 +const domainBrandingKeys: Record<MenuDomain, string> = { + evcp: "branding.evcp_main", + partners: "branding.evcp_partners", +}; + +export function HeaderV2() { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const pathname = usePathname(); + const { data: session } = useSession(); + const { t } = useTranslation(lng, "menu"); + + // 현재 도메인 결정 + const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp"; + + // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨) + const { tree, isLoading } = useVisibleMenuTree(domain); + + const userName = session?.user?.name || ""; + const initials = userName + .split(" ") + .map((word) => word[0]?.toUpperCase()) + .join(""); + + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + const [openMenuKey, setOpenMenuKey] = React.useState<string>(""); + + const toggleMobileMenu = () => { + setIsMobileMenuOpen(!isMobileMenuOpen); + }; + + const toggleMenu = React.useCallback((menuKey: string) => { + setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey)); + }, []); + + // 페이지 이동 시 메뉴 닫기 + React.useEffect(() => { + setOpenMenuKey(""); + }, [pathname]); + + // 브랜딩 및 경로 설정 + const brandNameKey = domainBrandingKeys[domain]; + const logoHref = `/${lng}/${domain}`; + const basePath = `/${lng}/${domain}`; + + // 다국어 텍스트 선택 + const getTitle = (node: MenuTreeNode) => + lng === "ko" ? node.titleKo : node.titleEn || node.titleKo; + + const getDescription = (node: MenuTreeNode) => + lng === "ko" + ? node.descriptionKo + : node.descriptionEn || node.descriptionKo; + + // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( + <> + <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="container-wrapper"> + <div className="container flex h-14 items-center"> + {/* 햄버거 메뉴 버튼 (모바일) */} + <Button + onClick={toggleMobileMenu} + variant="ghost" + className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth="1.5" + stroke="currentColor" + className="!size-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M3.75 9h16.5m-16.5 6.75h16.5" + /> + </svg> + <span className="sr-only">{t("menu.toggle_menu")}</span> + </Button> + + {/* 로고 영역 */} + <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> + <Link href={logoHref} className="flex items-center gap-2"> + <Image + className="dark:invert" + src="/images/vercel.svg" + alt="EVCP Logo" + width={20} + height={20} + /> + <span className="hidden font-bold lg:inline-block"> + {t(brandNameKey)} + </span> + </Link> + </div> + + {/* 네비게이션 메뉴 */} + <div className="hidden md:block flex-1 min-w-0"> + {isLoading ? ( + <div className="flex items-center justify-center h-10"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + </div> + ) : ( + <NavigationMenu + className="relative z-50" + value={openMenuKey} + onValueChange={setOpenMenuKey} + > + <div className="w-full overflow-x-auto pb-1"> + <NavigationMenuList className="flex-nowrap w-max"> + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + <NavigationMenuItem + key={node.id} + value={String(node.id)} + > + <NavigationMenuTrigger + className="px-2 xl:px-3 text-sm whitespace-nowrap" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(String(node.id)); + }} + onPointerEnter={(e) => e.preventDefault()} + onPointerMove={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + </NavigationMenuTrigger> + + <NavigationMenuContent + className="max-h-[80vh] overflow-y-auto overflow-x-hidden" + onPointerEnter={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + forceMount={ + openMenuKey === String(node.id) + ? true + : undefined + } + > + <DynamicMenuRender + groups={node.children} + lng={lng} + getTitle={getTitle} + getDescription={getDescription} + onItemClick={() => setOpenMenuKey("")} + /> + </NavigationMenuContent> + </NavigationMenuItem> + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + <NavigationMenuItem key={node.id}> + <Link + href={`/${lng}${node.menuPath}`} + legacyBehavior + passHref + > + <NavigationMenuLink + className={cn( + navigationMenuTriggerStyle(), + "px-2 xl:px-3 text-sm whitespace-nowrap" + )} + onPointerEnter={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + </NavigationMenuLink> + </Link> + </NavigationMenuItem> + ); + } + + return null; + })} + </NavigationMenuList> + </div> + </NavigationMenu> + )} + </div> + + {/* 우측 영역 */} + <div className="ml-auto flex flex-shrink-0 items-center space-x-2"> + {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} + <div className="hidden md:block md:w-auto"> + <CommandMenu /> + </div> + <Button + variant="ghost" + size="icon" + className="md:hidden" + aria-label={t("common.search")} + > + <SearchIcon className="h-5 w-5" /> + </Button> + + {/* 알림 버튼 */} + <NotificationDropdown /> + + {/* 사용자 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Avatar className="cursor-pointer h-8 w-8"> + <AvatarImage + src={`${session?.user?.image}` || "/user-avatar.jpg"} + alt="User Avatar" + /> + <AvatarFallback>{initials || "?"}</AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-48" align="end"> + <DropdownMenuLabel>{t("user.my_account")}</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <Link href={`${basePath}/settings`}>{t("user.settings")}</Link> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => + customSignOut({ + callbackUrl: `${window.location.origin}${basePath}`, + }) + } + > + {t("user.logout")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + + {/* 모바일 메뉴 */} + {isMobileMenuOpen && ( + <MobileMenuV2 + lng={lng} + onClose={toggleMobileMenu} + tree={tree} + getTitle={getTitle} + getDescription={getDescription} + /> + )} + </header> + </> + ); +} diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx new file mode 100644 index 00000000..c83ba779 --- /dev/null +++ b/components/layout/MobileMenuV2.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { X, ChevronDown, ChevronRight } from "lucide-react"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface MobileMenuV2Props { + lng: string; + onClose: () => void; + tree: MenuTreeNode[]; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; +} + +export function MobileMenuV2({ + lng, + onClose, + tree, + getTitle, + getDescription, +}: MobileMenuV2Props) { + const [expandedGroups, setExpandedGroups] = React.useState<Set<number>>( + new Set() + ); + + const toggleGroup = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 드롭다운 메뉴인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( + <div className="fixed inset-0 z-50 bg-background md:hidden"> + {/* 헤더 */} + <div className="flex items-center justify-between px-4 h-14 border-b"> + <span className="font-semibold">메뉴</span> + <Button variant="ghost" size="icon" onClick={onClose}> + <X className="h-5 w-5" /> + <span className="sr-only">닫기</span> + </Button> + </div> + + {/* 스크롤 영역 */} + <ScrollArea className="h-[calc(100vh-56px)]"> + <div className="px-4 py-4 space-y-2"> + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + <div key={node.id} className="space-y-2"> + {/* 메뉴그룹 헤더 */} + <button + onClick={() => toggleGroup(node.id)} + className="flex items-center justify-between w-full py-2 text-left font-semibold" + > + <span>{getTitle(node)}</span> + {expandedGroups.has(node.id) ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + + {/* 하위 메뉴 */} + {expandedGroups.has(node.id) && ( + <div className="pl-4 space-y-1"> + {node.children?.map((item) => { + if (item.nodeType === "group") { + // 그룹인 경우 + return ( + <div key={item.id} className="space-y-1"> + <div className="text-xs text-muted-foreground font-medium py-1"> + {getTitle(item)} + </div> + <div className="pl-2 space-y-1"> + {item.children?.map((menu) => ( + <MobileMenuLink + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onClose} + /> + ))} + </div> + </div> + ); + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 + return ( + <MobileMenuLink + key={item.id} + href={`/${lng}${item.menuPath}`} + title={getTitle(item)} + onClick={onClose} + /> + ); + } + return null; + })} + </div> + )} + </div> + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + <MobileMenuLink + key={node.id} + href={`/${lng}${node.menuPath}`} + title={getTitle(node)} + onClick={onClose} + /> + ); + } + + return null; + })} + </div> + </ScrollArea> + </div> + ); +} + +interface MobileMenuLinkProps { + href: string; + title: string; + onClick: () => void; +} + +function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) { + return ( + <Link + href={href} + onClick={onClick} + className={cn( + "block py-2 px-2 rounded-md text-sm", + "hover:bg-accent hover:text-accent-foreground", + "transition-colors" + )} + > + {title} + </Link> + ); +} |
