summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx51
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx2
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx279
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx15
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx181
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx343
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx128
-rw-r--r--components/common/date-picker/date-picker-with-input.tsx322
-rw-r--r--components/common/date-picker/index.ts3
-rw-r--r--components/common/legal/cpvw-wab-qust-list-view-dialog.tsx364
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx4
-rw-r--r--components/docu-list-rule/docu-list-rule-client.tsx9
-rw-r--r--components/form-data/form-data-table.tsx5
-rw-r--r--components/information/information-button.tsx2
-rw-r--r--components/items-tech/item-tech-container.tsx9
-rw-r--r--components/layout/DynamicMenuRender.tsx146
-rw-r--r--components/layout/HeaderV2.tsx295
-rw-r--r--components/layout/MobileMenuV2.tsx160
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>
+ );
+}