diff options
24 files changed, 716 insertions, 443 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx index 3a45e61f..aa9f33b5 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx @@ -7,6 +7,7 @@ import { getBiddingTypeCounts, getBiddingManagerCounts, getBiddingMonthlyStats, + getUserCodeByEmail, } from "@/lib/bidding/service" import { searchParamsCache } from "@/lib/bidding/validation" import { BiddingsPageHeader } from "@/lib/bidding/list/biddings-page-header" @@ -32,12 +33,26 @@ export default async function BiddingsPage(props: IndexPageProps) { const validFilters = getValidFilters(search.filters) + // ✅ 입찰 데이터를 먼저 가져옴 + const biddingsResult = await getBiddings({ + ...search, + filters: validFilters, + }) + + // ✅ 입찰 데이터에 managerCode 추가 + const biddingsDataWithManagerCode = await Promise.all( + biddingsResult.data.map(async (item) => { + let managerCode: string | null = null + if (item.managerEmail) { + managerCode = await getUserCodeByEmail(item.managerEmail) + } + return { ...item, managerCode: managerCode || null } + }) + ) + // ✅ 모든 데이터를 병렬로 로드 const promises = Promise.all([ - getBiddings({ - ...search, - filters: validFilters, - }), + Promise.resolve({ ...biddingsResult, data: biddingsDataWithManagerCode }), getBiddingStatusCounts(), getBiddingTypeCounts(), getBiddingManagerCounts(), diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/vendor/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/vendor/page.tsx index 296e46fe..c3a786b9 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/vendor/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/vendor/page.tsx @@ -112,7 +112,8 @@ export default async function VendorPage(props: VendorPageProps) { 벤더 목록 </CardTitle> <CardDescription> - 견적 요청 대상 벤더와 응답 상태를 관리합니다. + Short List 확정, 견적 비교, RFQ 발송 및 응답 관리, AVL 연동 등<br /> + 종합적인 벤더 관리 기능을 제공합니다. </CardDescription> </div> <Badge variant="outline" className="font-mono"> diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx index 7db60654..18442c0e 100644 --- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx @@ -90,7 +90,7 @@ interface User { domain?: string; // 'partners' | 'internal' 등 } -export default async function ProjectMembersPage({ +export default function ProjectMembersPage({ params: promiseParams }: { params: Promise<{ projectId: string }> diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 89f00a3c..8d736f80 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -52,7 +52,8 @@ const isAllowedPath = (requestedPath: string): boolean => { 'pq', 'pq/vendor', 'information', - 'general-contract-templates' + 'general-contract-templates', + 'purchase-requests' ]; return allowedPaths.some(allowed => diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index 587beb22..fa2d8c38 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -335,7 +335,7 @@ export function FileManager({ projectId }: FileManagerProps) { const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); - console.log(items,"items") + console.log(items, "items") // Upload states const [uploadDialogOpen, setUploadDialogOpen] = useState(false); @@ -754,9 +754,9 @@ export function FileManager({ projectId }: FileManagerProps) { // View file with PDFTron const viewFile = async (file: FileItem) => { try { - - + + setViewerFileUrl(file.filePath); setSelectedFile(file); setViewerDialogOpen(true); @@ -991,7 +991,16 @@ export function FileManager({ projectId }: FileManagerProps) { <Button size="sm" variant="outline" - onClick={() => setUploadDialogOpen(true)} + onClick={() => { + // 현재 폴더의 카테고리를 기본값으로 설정 + if (currentParentId) { + const currentFolder = items.find(item => item.parentId === currentParentId); + if (currentFolder) { + setUploadCategory(currentFolder.category); + } + } + setUploadDialogOpen(true); + }} > <Upload className="h-4 w-4 mr-1" /> Upload @@ -1005,7 +1014,7 @@ export function FileManager({ projectId }: FileManagerProps) { {items.filter(item => selectedItems.has(item.id) && item.type === 'file' && - item.permissions?.canDownload ==='true' + item.permissions?.canDownload === 'true' ).length > 0 && ( <Button size="sm" @@ -1296,16 +1305,42 @@ export function FileManager({ projectId }: FileManagerProps) { <SelectValue /> </SelectTrigger> <SelectContent> - {Object.entries(categoryConfig).map(([key, config]) => ( - <SelectItem key={key} value={key}> - <div className="flex items-center"> - <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> - <span>{config.label}</span> - </div> - </SelectItem> - ))} + {Object.entries(categoryConfig) + .filter(([key]) => { + // 현재 폴더가 있는 경우 + if (currentParentId) { + const currentFolder = items.find(item => item.parentId === currentParentId); + // 현재 폴더가 public이 아니면 public 옵션 제외 + if (currentFolder && currentFolder.category !== 'public') { + return key !== 'public'; + } + } + // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시 + return true; + }) + .map(([key, config]) => ( + <SelectItem key={key} value={key}> + <div className="flex items-center"> + <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> + <span>{config.label}</span> + </div> + </SelectItem> + ))} </SelectContent> </Select> + {/* 현재 폴더 정보 표시 (선택사항) */} + {currentParentId && (() => { + const currentFolder = items.find(item => item.parentId === currentParentId); + if (currentFolder && currentFolder.category !== 'public') { + return ( + <p className="text-xs text-muted-foreground mt-1 flex items-center"> + <AlertCircle className="h-3 w-3 mr-1" /> + Current folder is {categoryConfig[currentFolder.category].label}. + Public uploads are not allowed. + </p> + ); + } + })()} </div> {/* Dropzone */} @@ -1644,13 +1679,13 @@ export function FileManager({ projectId }: FileManagerProps) { Changing category for {selectedFile?.name} folder. </DialogDescription> </DialogHeader> - + <div className="space-y-4"> <div> <Label>New Category</Label> <div className="mt-2 space-y-2"> {Object.entries(categoryConfig).map(([key, config]) => ( - <div + <div key={key} className={cn( "flex items-center p-3 rounded-lg border cursor-pointer transition-colors", @@ -1672,24 +1707,35 @@ export function FileManager({ projectId }: FileManagerProps) { ))} </div> </div> - {selectedFile?.type === 'folder' && ( <div className="flex items-center space-x-2"> <Switch id="apply-to-children" - checked={applyToChildren} - onCheckedChange={setApplyToChildren} + checked={newCategory !== 'public' ? true : applyToChildren} + onCheckedChange={(checked) => { + if (newCategory === 'public') { + setApplyToChildren(checked); + } + }} + disabled={newCategory !== 'public'} /> - <Label htmlFor="apply-to-children"> + <Label htmlFor="apply-to-children" className={cn( + newCategory !== 'public' && "text-muted-foreground" + )}> Apply to all files and subfolders + {newCategory !== 'public' && ( + <span className="text-xs block mt-1"> + (Required for security categories) + </span> + )} </Label> </div> )} </div> - + <DialogFooter> - <Button - variant="outline" + <Button + variant="outline" onClick={() => { setCategoryDialogOpen(false); setSelectedFile(null); @@ -1698,7 +1744,7 @@ export function FileManager({ projectId }: FileManagerProps) { > Cancel </Button> - <Button + <Button onClick={() => { if (selectedFile) { changeCategory(selectedFile.id, newCategory, applyToChildren); @@ -1715,8 +1761,8 @@ export function FileManager({ projectId }: FileManagerProps) { </Dialog> {/* Secure Document Viewer Dialog */} - <Dialog - open={viewerDialogOpen} + <Dialog + open={viewerDialogOpen} onOpenChange={(open) => { if (!open) { setViewerDialogOpen(false); @@ -1747,7 +1793,7 @@ export function FileManager({ projectId }: FileManagerProps) { </div> </DialogDescription> </DialogHeader> - + <div className="relative flex-1 h-[calc(90vh-120px)]"> {viewerFileUrl && selectedFile && ( <SecurePDFViewer @@ -1761,7 +1807,7 @@ export function FileManager({ projectId }: FileManagerProps) { /> )} </div> - + <div className="px-6 py-3 border-t bg-muted/50"> <div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center gap-4"> diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx index c994b844..bf533ae8 100644 --- a/components/layout/Footer.tsx +++ b/components/layout/Footer.tsx @@ -1,15 +1,24 @@ +'use client' + import { siteConfig } from "@/config/site" +import { usePathname } from "next/navigation" export function SiteFooter() { + const pathname = usePathname() + const isDataRoom = pathname?.includes('data-room') + return ( <footer className="border-grid border-t py-6 md:px-8 md:py-0"> <div className="container-wrapper"> <div className="container py-4"> <div className="text-balance text-center text-sm leading-loose text-muted-foreground md:text-left"> - enterprise Vendor Co-work Platform - 삼성중공업 전사벤더협업플랫폼 + {isDataRoom + ? "Data Room - 삼성중공업 데이터룸" + : "enterprise Vendor Co-work Platform - 삼성중공업 전사벤더협업플랫폼" + } </div> </div> </div> </footer> ) -} +}
\ No newline at end of file diff --git a/components/project/ProjectDashboard.tsx b/components/project/ProjectDashboard.tsx index 5f8afb75..581b7b95 100644 --- a/components/project/ProjectDashboard.tsx +++ b/components/project/ProjectDashboard.tsx @@ -103,7 +103,9 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { // Dialog states const [addMemberOpen, setAddMemberOpen] = useState(false); const [transferOwnershipOpen, setTransferOwnershipOpen] = useState(false); + const [deleteProjectOpen, setDeleteProjectOpen] = useState(false); const [newOwnerId, setNewOwnerId] = useState(''); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); // User selection related states const [availableUsers, setAvailableUsers] = useState<User[]>([]); @@ -256,6 +258,42 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { } }; + // Delete project + const handleDeleteProject = async () => { + if (deleteConfirmText !== 'DELETE') { + toast({ + title: 'Error', + description: 'Please type DELETE to confirm.', + variant: 'destructive', + }); + return; + } + + try { + const response = await fetch(`/api/projects/${projectId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete project'); + } + + toast({ + title: 'Success', + description: 'Project has been deleted.', + }); + + // 프로젝트 목록 페이지로 리다이렉트 + window.location.href = '/evcp/data-room'; + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to delete project.', + variant: 'destructive', + }); + } + }; + const formatBytes = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; @@ -457,7 +495,10 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { Permanently delete project and all files </p> </div> - <Button variant="destructive"> + <Button + variant="destructive" + onClick={() => setDeleteProjectOpen(true)} + > <Trash2 className="h-4 w-4 mr-2" /> Delete Project </Button> @@ -744,6 +785,66 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { </DialogFooter> </DialogContent> </Dialog> + + {/* Delete Project Dialog */} + <Dialog open={deleteProjectOpen} onOpenChange={(open) => { + setDeleteProjectOpen(open); + if (!open) setDeleteConfirmText(''); + }}> + <DialogContent> + <DialogHeader> + <DialogTitle className="text-red-600">Delete Project</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete the project and all associated files. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="rounded-lg bg-red-50 border border-red-200 p-4"> + <h4 className="font-semibold text-red-800 mb-2">Warning</h4> + <ul className="text-sm text-red-700 space-y-1 list-disc list-inside"> + <li>All files will be permanently deleted</li> + <li>All project members will lose access</li> + <li>All sharing links will be invalidated</li> + <li>This action cannot be reversed</li> + </ul> + </div> + + <div className="space-y-2"> + <Label htmlFor="delete-confirm"> + Type <span className="font-mono font-bold">DELETE</span> to confirm + </Label> + <Input + id="delete-confirm" + placeholder="DELETE" + value={deleteConfirmText} + onChange={(e) => setDeleteConfirmText(e.target.value)} + className="font-mono" + /> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setDeleteProjectOpen(false); + setDeleteConfirmText(''); + }} + > + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDeleteProject} + disabled={deleteConfirmText !== 'DELETE'} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete Project + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ); }
\ No newline at end of file diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx index 9dec7e77..e267b21c 100644 --- a/components/project/ProjectList.tsx +++ b/components/project/ProjectList.tsx @@ -98,20 +98,31 @@ export function ProjectList() { fetchProjects(); }, []); - const fetchProjects = async () => { - try { - const response = await fetch('/api/projects'); - const data = await response.json(); - setProjects(data); - } catch (error) { - toast({ - title: 'Error', - description: 'Unable to load project list.', - variant: 'destructive', - }); +// components/project/ProjectList.tsx 의 fetchProjects 함수 수정 + +const fetchProjects = async () => { + try { + const response = await fetch('/api/projects'); + const data = await response.json(); + setProjects(data); + + // 멤버인 프로젝트가 정확히 1개일 때 자동 리다이렉트 + const memberProjects = data.member || []; + const ownedProjects = data.owned || []; + const totalProjects = [...memberProjects, ...ownedProjects]; + + if (totalProjects.length === 1) { + const singleProject = totalProjects[0]; + router.push(`/evcp/data-room/${singleProject.id}/files`); } - }; - + } catch (error) { + toast({ + title: 'Error', + description: 'Unable to load project list.', + variant: 'destructive', + }); + } +}; const onSubmit = async (data: ProjectFormData) => { setIsSubmitting(true); try { diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx index aac934ad..c62f760e 100644 --- a/components/project/ProjectNav.tsx +++ b/components/project/ProjectNav.tsx @@ -59,6 +59,7 @@ export function ProjectNav({ projectId }: ProjectNavProps) { }; console.log(pathname, projectId) + console.log(projectRole, "projectRole") const navItems = [ { @@ -66,6 +67,7 @@ export function ProjectNav({ projectId }: ProjectNavProps) { icon: Home, href: `/evcp/data-room/${projectId}`, active: pathname === `/${lng}/evcp/data-room/${projectId}`, + requireRole: ['owner', 'admin'], }, { label: 'Files', diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index a4d64e86..74603df0 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -60,7 +60,9 @@ export const biddingTypeEnum = pgEnum('bidding_type', [ 'piping', // 배관 'transport', // 운송 'waste', // 폐기물 - 'sale' // 매각 + 'sale', // 매각 + 'steel', // 강재 + 'other' // 기타(직접입력) ]) // 4. 낙찰수 enum @@ -578,7 +580,9 @@ export const biddingTypeLabels = { piping: '배관', transport: '운송', waste: '폐기물', - sale: '매각' + sale: '매각', + steel: '강재', + other: '기타(직접입력)' } as const export const awardCountLabels = { diff --git a/lib/bidding/list/biddings-stats-cards.tsx b/lib/bidding/list/biddings-stats-cards.tsx index 2926adac..14e29c16 100644 --- a/lib/bidding/list/biddings-stats-cards.tsx +++ b/lib/bidding/list/biddings-stats-cards.tsx @@ -60,9 +60,9 @@ export function BiddingsStatsCards({ </CardHeader> <CardContent> <div className="text-2xl font-bold">{total.toLocaleString()}</div> - <p className="text-xs text-muted-foreground"> + {/* <p className="text-xs text-muted-foreground"> 이번 달 <span className="font-medium text-green-600">+{thisMonthCount}</span>건 - </p> + </p> */} </CardContent> </Card> diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 7f0b8e40..4900d18a 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -5,6 +5,7 @@ import { type ColumnDef } from "@tanstack/react-table" import { Checkbox } from "@/components/ui/checkbox" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { getUserCodeByEmail } from "@/lib/bidding/service" import { Eye, Edit, MoreHorizontal, FileText, Users, Calendar, Building, Package, DollarSign, Clock, CheckCircle, XCircle, @@ -26,6 +27,12 @@ import { import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { BiddingListItem } from "@/db/schema" import { DataTableRowAction } from "@/types/table" + +// BiddingListItem에 manager 정보 추가 +type BiddingListItemWithManagerCode = BiddingListItem & { + managerName?: string | null + managerCode?: string | null +} import { biddingStatusLabels, contractTypeLabels, @@ -35,7 +42,7 @@ import { import { formatDate } from "@/lib/utils" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingListItem> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingListItemWithManagerCode> | null>> } // 상태별 배지 색상 @@ -78,7 +85,8 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => { -export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingListItem>[] { +export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingListItemWithManagerCode>[] { + return [ // ═══════════════════════════════════════════════════════════════ // 선택 및 기본 정보 @@ -191,11 +199,11 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef { accessorKey: "managerName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />, - cell: ({ row }) => ( - <div className="truncate max-w-[100px]" title={row.original.managerName || ''}> - {row.original.managerName || '-'} - </div> - ), + cell: ({ row }) => { + const name = row.original.managerName || "-"; + const managerCode = row.original.managerCode || ""; + return name === "-" ? "-" : `${name}(${managerCode})`; + }, size: 100, meta: { excelHeader: "입찰담당자" }, }, @@ -237,10 +245,12 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef <div className="truncate max-w-[200px]" title={row.original.title}> <Button variant="link" - className="p-0 h-auto text-left justify-start" + className="p-0 h-auto text-left justify-start font-bold underline" onClick={() => setRowAction({ row, type: "view" })} > - {row.original.title} + <div className="whitespace-pre-line"> + {row.original.title} + </div> </Button> </div> ), @@ -394,7 +404,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />, cell: ({ row }) => ( <span className="text-sm font-medium"> - {formatCurrency(row.original.budget, row.original.currency)} + {row.original.budget} </span> ), size: 120, @@ -406,7 +416,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />, cell: ({ row }) => ( <span className="text-sm font-medium text-orange-600"> - {formatCurrency(row.original.targetPrice, row.original.currency)} + {row.original.targetPrice} </span> ), size: 120, @@ -418,7 +428,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종입찰가" />, cell: ({ row }) => ( <span className="text-sm font-medium text-green-600"> - {formatCurrency(row.original.finalBidPrice, row.original.currency)} + {row.original.finalBidPrice} </span> ), size: 120, diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index 2ecfaa73..8920d9db 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -14,6 +14,7 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { getBiddingsColumns } from "./biddings-table-columns" import { getBiddings, getBiddingStatusCounts } from "@/lib/bidding/service" import { BiddingListItem } from "@/db/schema" +import { BiddingListItemWithManagerCode } from "./biddings-table-columns" import { BiddingsTableToolbarActions } from "./biddings-table-toolbar-actions" import { biddingStatusLabels, @@ -42,11 +43,11 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { const [isCompact, setIsCompact] = React.useState<boolean>(false) const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) - const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItem | null>(null) + const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItemWithManagerCode | null>(null) console.log(data,"data") - const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItem> | null>(null) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItemWithManagerCode> | null>(null) const router = useRouter() diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index e99ac06f..50246f58 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -126,7 +126,7 @@ interface PRItemInfo { } // 탭 순서 정의 -const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const +const TAB_ORDER = ["basic", "schedule", "details", "manager"] as const type TabType = typeof TAB_ORDER[number] export function CreateBiddingDialog() { @@ -184,11 +184,11 @@ export function CreateBiddingDialog() { // 파일 첨부를 위해 선택된 아이템 ID const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null) - // 입찰 조건 상태 + // 입찰 조건 상태 (기본값 설정 포함) const [biddingConditions, setBiddingConditions] = React.useState({ - paymentTerms: "", - taxConditions: "", - incoterms: "", + paymentTerms: "", // 초기값 빈값, 데이터 로드 후 설정 + taxConditions: "", // 초기값 빈값, 데이터 로드 후 설정 + incoterms: "", // 초기값 빈값, 데이터 로드 후 설정 contractDeliveryDate: "", shippingPort: "", destinationPort: "", @@ -202,26 +202,49 @@ export function CreateBiddingDialog() { try { const data = await getPaymentTermsForSelection(); setPaymentTermsOptions(data); + // 기본값 설정 로직: P008이 있으면 P008로, 없으면 첫 번째 항목으로 설정 + const setDefaultPaymentTerms = () => { + const p008Exists = data.some(item => item.code === "P008"); + if (p008Exists) { + setBiddingConditions(prev => ({ ...prev, paymentTerms: "P008" })); + } + }; + + setDefaultPaymentTerms(); } catch (error) { console.error("Failed to load payment terms:", error); toast.error("결제조건 목록을 불러오는데 실패했습니다."); + // 에러 시 기본값 초기화 + if (biddingConditions.paymentTerms === "P008") { + setBiddingConditions(prev => ({ ...prev, paymentTerms: "" })); + } } finally { setProcurementLoading(false); } - }, []); + }, [biddingConditions.paymentTerms]); const loadIncoterms = React.useCallback(async () => { setProcurementLoading(true); try { const data = await getIncotermsForSelection(); setIncotermsOptions(data); + + // 기본값 설정 로직: DAP가 있으면 DAP로, 없으면 첫 번째 항목으로 설정 + const setDefaultIncoterms = () => { + const dapExists = data.some(item => item.code === "DAP"); + if (dapExists) { + setBiddingConditions(prev => ({ ...prev, incoterms: "DAP" })); + } + }; + + setDefaultIncoterms(); } catch (error) { console.error("Failed to load incoterms:", error); toast.error("운송조건 목록을 불러오는데 실패했습니다."); } finally { setProcurementLoading(false); } - }, []); + }, [biddingConditions.incoterms]); const loadShippingPlaces = React.useCallback(async () => { setProcurementLoading(true); @@ -249,13 +272,19 @@ export function CreateBiddingDialog() { } }, []); - // 다이얼로그 열릴 때 procurement 데이터 로드 + // 다이얼로그 열릴 때 procurement 데이터 로드 및 기본값 설정 React.useEffect(() => { if (open) { loadPaymentTerms(); loadIncoterms(); loadShippingPlaces(); loadDestinationPlaces(); + + // 세금조건 기본값 설정 (V1이 있는지 확인하고 설정) + const v1Exists = TAX_CONDITIONS.some(item => item.code === "V1"); + if (v1Exists) { + setBiddingConditions(prev => ({ ...prev, taxConditions: "V1" })); + } } }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) @@ -294,6 +323,7 @@ export function CreateBiddingDialog() { contractType: "general", biddingType: "equipment", + biddingTypeCustom: "", awardCount: "single", contractStartDate: "", contractEndDate: "", @@ -344,10 +374,8 @@ export function CreateBiddingDialog() { return { basic: { - isValid: formValues.projectId > 0 && - formValues.itemName.trim() !== "" && - formValues.title.trim() !== "", - hasErrors: !!(formErrors.projectId || formErrors.itemName || formErrors.title) + isValid: formValues.title.trim() !== "", + hasErrors: !!(formErrors.title) }, contract: { isValid: formValues.contractType && @@ -399,11 +427,18 @@ export function CreateBiddingDialog() { return representativeItem?.prNumber || "" }, [prItems]) - // hasPrDocument 필드와 prNumber를 자동으로 업데이트 + // 대표 품목명 자동 계산 (첫 번째 PR 아이템의 itemInfo) + const representativeItemName = React.useMemo(() => { + const representativeItem = prItems.find(item => item.isRepresentative) + return representativeItem?.itemInfo || "" + }, [prItems]) + + // hasPrDocument 필드와 prNumber, itemName을 자동으로 업데이트 React.useEffect(() => { form.setValue("hasPrDocument", hasPrDocuments) form.setValue("prNumber", representativePrNumber) - }, [hasPrDocuments, representativePrNumber, form]) + form.setValue("itemName", representativeItemName) + }, [hasPrDocuments, representativePrNumber, representativeItemName, form]) @@ -525,7 +560,7 @@ export function CreateBiddingDialog() { if (!isCurrentTabValid()) { // 특정 탭별 에러 메시지 if (activeTab === "basic") { - toast.error("기본 정보를 모두 입력해주세요 (프로젝트, 품목명, 입찰명)") + toast.error("기본 정보를 모두 입력해주세요 (품목명, 입찰명)") } else if (activeTab === "contract") { toast.error("계약 정보를 모두 입력해주세요") } else if (activeTab === "schedule") { @@ -535,7 +570,7 @@ export function CreateBiddingDialog() { toast.error("제출 시작일시와 마감일시를 입력해주세요") } } else if (activeTab === "conditions") { - toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 하역지)") + toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일)") } else if (activeTab === "details") { toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요") } @@ -617,6 +652,7 @@ export function CreateBiddingDialog() { content: "", contractType: "general", biddingType: "equipment", + biddingTypeCustom: "", awardCount: "single", contractStartDate: "", contractEndDate: "", @@ -625,9 +661,6 @@ export function CreateBiddingDialog() { hasSpecificationMeeting: false, prNumber: "", currency: "KRW", - budget: "", - targetPrice: "", - finalBidPrice: "", status: "bidding_generated", isPublic: false, managerName: "", @@ -780,20 +813,6 @@ export function CreateBiddingDialog() { </button> <button type="button" - onClick={() => setActiveTab("contract")} - className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ - activeTab === "contract" - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground" - }`} - > - 계약정보 - {!tabValidation.contract.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </button> - <button - type="button" onClick={() => setActiveTab("schedule")} className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ activeTab === "schedule" @@ -801,27 +820,13 @@ export function CreateBiddingDialog() { : "text-muted-foreground hover:text-foreground" }`} > - 일정회의 + 입찰계획 {!tabValidation.schedule.isValid && ( <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> )} </button> <button type="button" - onClick={() => setActiveTab("conditions")} - className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ - activeTab === "conditions" - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground" - }`} - > - 입찰조건 - {!tabValidation.conditions.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </button> - <button - type="button" onClick={() => setActiveTab("details")} className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ activeTab === "details" @@ -853,7 +858,7 @@ export function CreateBiddingDialog() { <TabsContent value="basic" className="mt-0 space-y-6"> <Card> <CardHeader> - <CardTitle>기본 정보</CardTitle> + <CardTitle>기본 정보 및 계약 정보</CardTitle> </CardHeader> <CardContent className="space-y-6"> {/* 프로젝트 선택 */} @@ -863,7 +868,7 @@ export function CreateBiddingDialog() { render={({ field }) => ( <FormItem> <FormLabel> - 프로젝트 <span className="text-red-500">*</span> + 프로젝트 </FormLabel> <FormControl> <ProjectSelector @@ -877,9 +882,9 @@ export function CreateBiddingDialog() { )} /> - <div className="grid grid-cols-2 gap-6"> + {/* <div className="grid grid-cols-2 gap-6"> */} {/* 품목명 */} - <FormField + {/* <FormField control={form.control} name="itemName" render={({ field }) => ( @@ -896,10 +901,10 @@ export function CreateBiddingDialog() { <FormMessage /> </FormItem> )} - /> + /> */} {/* 리비전 */} - <FormField + {/* <FormField control={form.control} name="revision" render={({ field }) => ( @@ -916,8 +921,8 @@ export function CreateBiddingDialog() { <FormMessage /> </FormItem> )} - /> - </div> + /> */} + {/* </div> */} {/* 입찰명 */} <FormField @@ -957,17 +962,8 @@ export function CreateBiddingDialog() { </FormItem> )} /> - </CardContent> - </Card> - </TabsContent> - {/* 계약 정보 탭 */} - <TabsContent value="contract" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>계약 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> + {/* 계약 정보 섹션 */} <div className="grid grid-cols-2 gap-6"> {/* 계약구분 */} <FormField @@ -1024,6 +1020,28 @@ export function CreateBiddingDialog() { </FormItem> )} /> + + {/* 기타 입찰유형 직접입력 */} + {form.watch("biddingType") === "other" && ( + <FormField + control={form.control} + name="biddingTypeCustom" + render={({ field }) => ( + <FormItem> + <FormLabel> + 기타 입찰유형 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="직접 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} </div> <div className="grid grid-cols-2 gap-6"> @@ -1091,15 +1109,8 @@ export function CreateBiddingDialog() { )} /> </div> - </CardContent> - </Card> - <Card> - <CardHeader> - <CardTitle>가격 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - {/* 통화 */} + {/* 통화 선택만 유지 */} <FormField control={form.control} name="currency" @@ -1126,71 +1137,204 @@ export function CreateBiddingDialog() { )} /> - <div className="grid grid-cols-3 gap-6"> - {/* 예산 */} - <FormField - control={form.control} - name="budget" - render={({ field }) => ( - <FormItem> - <FormLabel>예산</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 입찰 조건 섹션 */} + <Card> + <CardHeader> + <CardTitle>입찰 조건</CardTitle> + <p className="text-sm text-muted-foreground"> + 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요 + </p> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-2 gap-6"> + <div className="space-y-2"> + <label className="text-sm font-medium"> + 지급조건 <span className="text-red-500">*</span> + </label> + <Select + value={biddingConditions.paymentTerms} + onValueChange={(value) => setBiddingConditions(prev => ({ + ...prev, + paymentTerms: value + }))} + > + <SelectTrigger> + <SelectValue placeholder="지급조건 선택" /> + </SelectTrigger> + <SelectContent> + {paymentTermsOptions.length > 0 ? ( + paymentTermsOptions.map((option) => ( + <SelectItem key={option.code} value={option.code}> + {option.code} {option.description && `(${option.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> - {/* 내정가 */} - <FormField - control={form.control} - name="targetPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>내정가</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + <div className="space-y-2"> + <label className="text-sm font-medium"> + 세금조건 <span className="text-red-500">*</span> + </label> + <Select + value={biddingConditions.taxConditions} + onValueChange={(value) => setBiddingConditions(prev => ({ + ...prev, + taxConditions: value + }))} + > + <SelectTrigger> + <SelectValue placeholder="세금조건 선택" /> + </SelectTrigger> + <SelectContent> + {TAX_CONDITIONS.map((condition) => ( + <SelectItem key={condition.code} value={condition.code}> + {condition.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> - {/* 최종입찰가 */} - <FormField - control={form.control} - name="finalBidPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>최종입찰가</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> + <div className="space-y-2"> + <label className="text-sm font-medium"> + 운송조건(인코텀즈) <span className="text-red-500">*</span> + </label> + <Select + value={biddingConditions.incoterms} + onValueChange={(value) => setBiddingConditions(prev => ({ + ...prev, + incoterms: value + }))} + > + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + <SelectContent> + {incotermsOptions.length > 0 ? ( + incotermsOptions.map((option) => ( + <SelectItem key={option.code} value={option.code}> + {option.code} {option.description && `(${option.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium"> + 계약 납품일 <span className="text-red-500">*</span> + </label> + <Input + type="date" + value={biddingConditions.contractDeliveryDate} + onChange={(e) => setBiddingConditions(prev => ({ + ...prev, + contractDeliveryDate: e.target.value + }))} + /> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium">선적지 (선택사항)</label> + <Select + value={biddingConditions.shippingPort} + onValueChange={(value) => setBiddingConditions(prev => ({ + ...prev, + shippingPort: value + }))} + > + <SelectTrigger> + <SelectValue placeholder="선적지 선택" /> + </SelectTrigger> + <SelectContent> + {shippingPlaces.length > 0 ? ( + shippingPlaces.map((place) => ( + <SelectItem key={place.code} value={place.code}> + {place.code} {place.description && `(${place.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium">하역지 (선택사항)</label> + <Select + value={biddingConditions.destinationPort} + onValueChange={(value) => setBiddingConditions(prev => ({ + ...prev, + destinationPort: value + }))} + > + <SelectTrigger> + <SelectValue placeholder="하역지 선택" /> + </SelectTrigger> + <SelectContent> + {destinationPlaces.length > 0 ? ( + destinationPlaces.map((place) => ( + <SelectItem key={place.code} value={place.code}> + {place.code} {place.description && `(${place.description})`} + </SelectItem> + )) + ) : ( + <SelectItem value="loading" disabled> + 데이터를 불러오는 중... + </SelectItem> + )} + </SelectContent> + </Select> + </div> + </div> + + <div className="flex items-center space-x-2"> + <Switch + id="price-adjustment" + checked={biddingConditions.isPriceAdjustmentApplicable} + onCheckedChange={(checked) => setBiddingConditions(prev => ({ + ...prev, + isPriceAdjustmentApplicable: checked + }))} + /> + <label htmlFor="price-adjustment" className="text-sm font-medium"> + 연동제 적용 요건 문의 + </label> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium">스페어파트 옵션</label> + <Textarea + placeholder="스페어파트 관련 옵션을 입력하세요" + value={biddingConditions.sparePartOptions} + onChange={(e) => setBiddingConditions(prev => ({ + ...prev, + sparePartOptions: e.target.value + }))} + rows={3} + /> + </div> + </CardContent> + </Card> </CardContent> </Card> </TabsContent> + {/* 일정 & 회의 탭 */} <TabsContent value="schedule" className="mt-0 space-y-6"> <Card> @@ -1444,204 +1588,40 @@ export function CreateBiddingDialog() { )} </CardContent> </Card> - </TabsContent> - {/* 입찰 조건 탭 */} - <TabsContent value="conditions" className="mt-0 space-y-6"> + {/* 긴급 입찰 설정 */} <Card> <CardHeader> - <CardTitle>입찰 조건</CardTitle> - <p className="text-sm text-muted-foreground"> - 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요 - </p> + <CardTitle>긴급 입찰 설정</CardTitle> </CardHeader> <CardContent className="space-y-6"> - <div className="grid grid-cols-2 gap-6"> - <div className="space-y-2"> - <label className="text-sm font-medium"> - 지급조건 <span className="text-red-500">*</span> - </label> - <Select - value={biddingConditions.paymentTerms} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - paymentTerms: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="지급조건 선택" /> - </SelectTrigger> - <SelectContent> - {paymentTermsOptions.length > 0 ? ( - paymentTermsOptions.map((option) => ( - <SelectItem key={option.code} value={option.code}> - {option.code} {option.description && `(${option.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium"> - 세금조건 <span className="text-red-500">*</span> - </label> - <Select - value={biddingConditions.taxConditions} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - taxConditions: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="세금조건 선택" /> - </SelectTrigger> - <SelectContent> - {TAX_CONDITIONS.map((condition) => ( - <SelectItem key={condition.code} value={condition.code}> - {condition.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium"> - 운송조건(인코텀즈) <span className="text-red-500">*</span> - </label> - <Select - value={biddingConditions.incoterms} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - incoterms: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - <SelectContent> - {incotermsOptions.length > 0 ? ( - incotermsOptions.map((option) => ( - <SelectItem key={option.code} value={option.code}> - {option.code} {option.description && `(${option.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium"> - 계약 납품일 <span className="text-red-500">*</span> - </label> - <Input - type="date" - value={biddingConditions.contractDeliveryDate} - onChange={(e) => setBiddingConditions(prev => ({ - ...prev, - contractDeliveryDate: e.target.value - }))} - /> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium">선적지 <span className="text-red-500">*</span></label> - <Select - value={biddingConditions.shippingPort} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - shippingPort: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="선적지 선택" /> - </SelectTrigger> - <SelectContent> - {shippingPlaces.length > 0 ? ( - shippingPlaces.map((place) => ( - <SelectItem key={place.code} value={place.code}> - {place.code} {place.description && `(${place.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium">하역지 <span className="text-red-500">*</span></label> - <Select - value={biddingConditions.destinationPort} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - destinationPort: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="하역지 선택" /> - </SelectTrigger> - <SelectContent> - {destinationPlaces.length > 0 ? ( - destinationPlaces.map((place) => ( - <SelectItem key={place.code} value={place.code}> - {place.code} {place.description && `(${place.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - </div> - - <div className="flex items-center space-x-2"> - <Switch - id="price-adjustment" - checked={biddingConditions.isPriceAdjustmentApplicable} - onCheckedChange={(checked) => setBiddingConditions(prev => ({ - ...prev, - isPriceAdjustmentApplicable: checked - }))} - /> - <label htmlFor="price-adjustment" className="text-sm font-medium"> - 연동제 적용 가능 - </label> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium">스페어파트 옵션</label> - <Textarea - placeholder="스페어파트 관련 옵션을 입력하세요" - value={biddingConditions.sparePartOptions} - onChange={(e) => setBiddingConditions(prev => ({ - ...prev, - sparePartOptions: e.target.value - }))} - rows={3} - /> - </div> + <FormField + control={form.control} + name="isUrgent" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base"> + 긴급 입찰 + </FormLabel> + <FormDescription> + 긴급 입찰 여부를 설정합니다 + </FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> </CardContent> </Card> </TabsContent> + {/* 세부내역 탭 */} <TabsContent value="details" className="mt-0 space-y-6"> <Card> @@ -2029,28 +2009,6 @@ export function CreateBiddingDialog() { )} /> - <FormField - control={form.control} - name="isUrgent" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> - <div className="space-y-0.5"> - <FormLabel className="text-base"> - 긴급 입찰 - </FormLabel> - <FormDescription> - 긴급 입찰 여부를 설정합니다 - </FormDescription> - </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> <FormField control={form.control} @@ -2073,7 +2031,7 @@ export function CreateBiddingDialog() { </Card> {/* 입찰 생성 요약 */} - <Card> + {/* <Card> <CardHeader> <CardTitle>입찰 생성 요약</CardTitle> </CardHeader> @@ -2129,7 +2087,7 @@ export function CreateBiddingDialog() { </div> </div> </CardContent> - </Card> + </Card> */} </TabsContent> </div> diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx index dc24d0cf..ed3d3f41 100644 --- a/lib/bidding/list/edit-bidding-sheet.tsx +++ b/lib/bidding/list/edit-bidding-sheet.tsx @@ -389,7 +389,7 @@ export function EditBiddingSheet({ /> </div> - <FormField + {/* <FormField control={form.control} name="status" render={({ field }) => ( @@ -412,7 +412,7 @@ export function EditBiddingSheet({ <FormMessage /> </FormItem> )} - /> + /> */} <div className="space-y-3"> <FormField diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 8cbe2a2b..5ab18ef1 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -31,6 +31,22 @@ import { like, notInArray } from 'drizzle-orm' + +// 사용자 이메일로 사용자 코드 조회 +export async function getUserCodeByEmail(email: string): Promise<string | null> { + try { + const user = await db + .select({ userCode: users.userCode }) + .from(users) + .where(and(eq(users.email, email), eq(users.isActive, true))) + .limit(1) + + return user[0]?.userCode || null + } catch (error) { + console.error('Failed to get user code by email:', error) + return null + } +} import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation' diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 2011cd27..8476be1c 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -71,6 +71,7 @@ export const createBiddingSchema = z.object({ biddingType: z.enum(biddings.biddingType.enumValues, { required_error: "입찰유형을 선택해주세요" }), + biddingTypeCustom: z.string().optional(), awardCount: z.enum(biddings.awardCount.enumValues, { required_error: "낙찰수를 선택해주세요" }), @@ -89,9 +90,6 @@ export const createBiddingSchema = z.object({ // ✅ 가격 정보 (통화 필수) currency: z.string().min(1, "통화를 선택해주세요").default("KRW"), - budget: z.string().optional(), - targetPrice: z.string().optional(), - finalBidPrice: z.string().optional(), // 상태 및 담당자 status: z.enum(biddings.status.enumValues).default("bidding_generated"), @@ -110,8 +108,8 @@ export const createBiddingSchema = z.object({ taxConditions: z.string().min(1, "세금조건은 필수입니다"), incoterms: z.string().min(1, "운송조건은 필수입니다"), contractDeliveryDate: z.string().min(1, "계약납품일은 필수입니다"), - shippingPort: z.string().min(1, "선적지는 필수입니다"), - destinationPort: z.string().min(1, "하역지는 필수입니다"), + shippingPort: z.string().optional(), + destinationPort: z.string().optional(), isPriceAdjustmentApplicable: z.boolean().default(false), sparePartOptions: z.string().optional(), }).optional(), @@ -126,6 +124,15 @@ export const createBiddingSchema = z.object({ }, { message: "제출시작일시가 제출마감일시보다 늦을 수 없습니다", path: ["submissionEndDate"] + }).refine((data) => { + // 기타 입찰유형 선택 시 직접입력 필드 검증 + if (data.biddingType === "other") { + return data.biddingTypeCustom && data.biddingTypeCustom.trim().length > 0 + } + return true + }, { + message: "기타 입찰유형을 선택한 경우 직접 입력해주세요", + path: ["biddingTypeCustom"] }) export const updateBiddingSchema = z.object({ diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index 3098f8f5..d97d32fd 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -416,7 +416,7 @@ export function RfqAttachmentsTable({ if (activeTab !== '구매') { return <span className="text-muted-foreground text-sm">-</span>; } - + return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -442,7 +442,7 @@ export function RfqAttachmentsTable({ 새 버전 업로드 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem + <DropdownMenuItem onClick={() => handleAction({ type: "delete", row })} className="text-red-600" > @@ -455,7 +455,7 @@ export function RfqAttachmentsTable({ size: 60, enablePinning: true, }, - ], [handleAction]); + ], [handleAction, activeTab]); const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [ { id: "serialNo", label: "일련번호", type: "text" }, diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index f536a142..f600d04b 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -2574,6 +2574,35 @@ export async function getRfqAttachments(rfqId: number) { return fullInfo.attachments; } +/** + * 특정 벤더의 현재 조건 조회 + */ +export async function getVendorConditions(rfqId: number, vendorId: number) { + const fullInfo = await getRfqFullInfo(rfqId); + const vendor = fullInfo.vendors?.find(v => v.vendorId === vendorId); + + if (!vendor) { + throw new Error('벤더 정보를 찾을 수 없습니다.'); + } + + return { + currency: vendor.currency, + paymentTermsCode: vendor.paymentTermsCode, + incotermsCode: vendor.incotermsCode, + incotermsDetail: vendor.incotermsDetail, + deliveryDate: vendor.deliveryDate, + contractDuration: vendor.contractDuration, + taxCode: vendor.taxCode, + placeOfShipping: vendor.placeOfShipping, + placeOfDestination: vendor.placeOfDestination, + materialPriceRelatedYn: vendor.materialPriceRelatedYn, + sparepartYn: vendor.sparepartYn, + firstYn: vendor.firstYn, + firstDescription: vendor.firstDescription, + sparepartDescription: vendor.sparepartDescription, + }; +} + // RFQ 발송용 데이터 타입 export interface RfqSendData { diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx index 7eae48db..70d5569f 100644 --- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -44,7 +44,7 @@ import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { updateVendorConditionsBatch } from "../service"; +import { updateVendorConditionsBatch, getVendorConditions } from "../service"; import { Badge } from "@/components/ui/badge"; import { TAX_CONDITIONS } from "@/lib/tax-conditions/types"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -72,8 +72,8 @@ interface BatchUpdateConditionsDialogProps { } // 타입 정의 -interface SelectOption { - id: number; +type SelectOption = { + id?: number; code: string; description: string; } @@ -169,7 +169,7 @@ export function BatchUpdateConditionsDialog({ setIncotermsLoading(true); try { const data = await getIncotermsForSelection(); - setIncoterms(data); + setIncoterms(data as unknown as SelectOption[]); } catch (error) { console.error("Failed to load incoterms:", error); toast.error("Incoterms 목록을 불러오는데 실패했습니다."); @@ -182,7 +182,7 @@ export function BatchUpdateConditionsDialog({ setPaymentTermsLoading(true); try { const data = await getPaymentTermsForSelection(); - setPaymentTerms(data); + setPaymentTerms(data as unknown as SelectOption[]); } catch (error) { console.error("Failed to load payment terms:", error); toast.error("결제조건 목록을 불러오는데 실패했습니다."); @@ -195,7 +195,7 @@ export function BatchUpdateConditionsDialog({ setShippingLoading(true); try { const data = await getPlaceOfShippingForSelection(); - setShippingPlaces(data); + setShippingPlaces(data as unknown as SelectOption[]); } catch (error) { console.error("Failed to load shipping places:", error); toast.error("선적지 목록을 불러오는데 실패했습니다."); @@ -208,7 +208,7 @@ export function BatchUpdateConditionsDialog({ setDestinationLoading(true); try { const data = await getPlaceOfDestinationForSelection(); - setDestinationPlaces(data); + setDestinationPlaces(data as unknown as SelectOption[]); } catch (error) { console.error("Failed to load destination places:", error); toast.error("도착지 목록을 불러오는데 실패했습니다."); @@ -217,6 +217,33 @@ export function BatchUpdateConditionsDialog({ } }, []); + // 벤더별 조건 로드 함수 + const loadVendorConditions = React.useCallback(async (vendorId: number) => { + try { + const conditions = await getVendorConditions(rfqId, vendorId); + // 가져온 조건으로 폼 초기화 + form.reset({ + currency: conditions.currency || "", + paymentTermsCode: conditions.paymentTermsCode || "", + incotermsCode: conditions.incotermsCode || "", + incotermsDetail: conditions.incotermsDetail || "", + deliveryDate: conditions.deliveryDate || undefined, + contractDuration: conditions.contractDuration || "", + taxCode: conditions.taxCode || "", + placeOfShipping: conditions.placeOfShipping || "", + placeOfDestination: conditions.placeOfDestination || "", + materialPriceRelatedYn: conditions.materialPriceRelatedYn || false, + sparepartYn: conditions.sparepartYn || false, + firstYn: conditions.firstYn || false, + firstDescription: conditions.firstDescription || "", + sparepartDescription: conditions.sparepartDescription || "", + }); + } catch (error) { + console.error("Failed to load vendor conditions:", error); + toast.error("벤더 조건을 불러오는데 실패했습니다."); + } + }, [rfqId, form]); + // 초기 데이터 로드 React.useEffect(() => { if (open) { @@ -224,13 +251,35 @@ export function BatchUpdateConditionsDialog({ loadPaymentTerms(); loadShippingPlaces(); loadDestinationPlaces(); + + // 선택된 벤더가 1개일 때만 해당 벤더의 조건을 가져옴 + if (selectedVendors.length === 1) { + loadVendorConditions(selectedVendors[0].id); + } } - }, [open, loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]); + }, [open, loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces, selectedVendors, loadVendorConditions]); // 다이얼로그 닫힐 때 초기화 React.useEffect(() => { if (!open) { - form.reset(); + // 선택된 벤더가 2개 이상이거나 없다면 기본값으로 초기화 + if (selectedVendors.length !== 1) { + form.reset({ + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + contractDuration: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + sparepartYn: false, + firstYn: false, + firstDescription: "", + sparepartDescription: "", + }); + } setFieldsToUpdate({ currency: false, paymentTermsCode: false, @@ -244,7 +293,7 @@ export function BatchUpdateConditionsDialog({ first: false, }); } - }, [open, form]); + }, [open, form, selectedVendors]); // 제출 처리 const onSubmit = async (data: FormValues) => { diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index 074924eb..08288dd6 100644 --- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx @@ -36,6 +36,7 @@ import { Paperclip, Info, Edit, + X, } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -160,6 +161,15 @@ export function VendorResponseDetailDialog({ </DialogDescription> </div> <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => onOpenChange(false)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + <span className="sr-only">창 닫기</span> + </Button> {/* {onEdit && ( <Button variant="outline" size="sm" onClick={onEdit}> <Edit className="h-4 w-4 mr-2" /> diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts index 99373555..a02ef9bf 100644 --- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts @@ -209,11 +209,11 @@ async function generateBiddingCodes(eccHeaders: ECCBidHeader[]): Promise<Map<str maxBiddingNumber: max(biddings.biddingNumber) }) .from(biddings) - .where(sql`${biddings.biddingNumber} LIKE ${`BID${ekgrp}%`}`); + .where(sql`${biddings.biddingNumber} LIKE ${`B${ekgrp}%`}`); let nextSeq = 1; if (maxResult[0]?.maxBiddingNumber) { - const prefix = `BID${ekgrp}`; + const prefix = `B${ekgrp}`; const currentCode = maxResult[0].maxBiddingNumber; if (currentCode.startsWith(prefix)) { const seqPart = currentCode.substring(prefix.length); @@ -227,7 +227,7 @@ async function generateBiddingCodes(eccHeaders: ECCBidHeader[]): Promise<Map<str // 동일 EKGRP 내에서 순차적으로 새 코드 생성 for (const header of headers) { const seqString = nextSeq.toString().padStart(5, '0'); - const biddingCode = `BID${ekgrp}${seqString}`; + const biddingCode = `B${ekgrp}${seqString}`; biddingCodeMap.set(header.ANFNR || '', biddingCode); nextSeq++; // 다음 시퀀스로 증가 } @@ -247,7 +247,7 @@ async function generateBiddingCodes(eccHeaders: ECCBidHeader[]): Promise<Map<str eccHeaders.forEach((header, index) => { const ekgrp = header.EKGRP || 'UNKNOWN'; const seqString = (index + 1).toString().padStart(5, '0'); - fallbackMap.set(header.ANFNR, `BID${ekgrp}${seqString}`); + fallbackMap.set(header.ANFNR, `B${ekgrp}${seqString}`); }); return fallbackMap; } @@ -275,7 +275,7 @@ export async function mapECCBiddingHeaderToBidding( // 담당자 찾기 const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null); - + // 첫번째 PR Item 기반으로 projectId, projectName, itemName 설정 let projectId: number | null = null; let projectName: string | null = null; @@ -342,7 +342,7 @@ export async function mapECCBiddingHeaderToBidding( // 담당자 정보 - EKGRP 기반으로 설정 managerName: inChargeUserInfo?.userName || null, - managerEmail: null, + managerEmail: inChargeUserInfo?.userEmail || null, managerPhone: inChargeUserInfo?.userPhone || null, // 메타 정보 diff --git a/lib/soap/ecc/mapper/common-mapper-utils.ts b/lib/soap/ecc/mapper/common-mapper-utils.ts index 526decb5..8558f058 100644 --- a/lib/soap/ecc/mapper/common-mapper-utils.ts +++ b/lib/soap/ecc/mapper/common-mapper-utils.ts @@ -24,11 +24,12 @@ import { eq } from 'drizzle-orm'; export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{ userId: number; userName: string; + userEmail: string | null; userPhone: string | null; } | null> { try { debugLog('담당자 찾기 시작', { EKGRP }); - + if (!EKGRP) { debugError('EKGRP가 null 또는 undefined', { EKGRP }); return null; @@ -36,9 +37,10 @@ export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{ // users 테이블에서 userCode로 직접 조회 const userResult = await db - .select({ + .select({ id: users.id, name: users.name, + email: users.email, phone: users.phone }) .from(users) @@ -53,6 +55,7 @@ export async function findUserInfoByEKGRP(EKGRP: string | null): Promise<{ const userInfo = { userId: userResult[0].id, userName: userResult[0].name, + userEmail: userResult[0].email, userPhone: userResult[0].phone }; debugSuccess('담당자 찾음', { EKGRP, userInfo }); diff --git a/types/evaluation-form.ts b/types/evaluation-form.ts index a57287aa..449b1fa2 100644 --- a/types/evaluation-form.ts +++ b/types/evaluation-form.ts @@ -34,7 +34,7 @@ export interface AttachmentInfo { // 현재 응답 정보 responseId: number | null selectedDetailId: number | null - currentScore: number | null + currentScore: string | null currentComment: string | null // 📎 첨부파일 정보 추가 |
