summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-13 08:56:27 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-13 08:56:27 +0000
commitb9a2081a76e669688d5884f20482b37cc8acca22 (patch)
tree385e78c05d193a54daaced836f1e1152696153a8
parente84cf02a1cb4959a9d3bb5bbf37885c13a447f78 (diff)
(최겸, 임수민) 구매 입찰, 견적(그룹코드, tbe에러) 수정, data-room 수정
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx23
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/vendor/page.tsx3
-rw-r--r--app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx2
-rw-r--r--app/api/files/[...path]/route.ts3
-rw-r--r--components/file-manager/FileManager.tsx100
-rw-r--r--components/layout/Footer.tsx13
-rw-r--r--components/project/ProjectDashboard.tsx103
-rw-r--r--components/project/ProjectList.tsx37
-rw-r--r--components/project/ProjectNav.tsx2
-rw-r--r--db/schema/bidding.ts8
-rw-r--r--lib/bidding/list/biddings-stats-cards.tsx4
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx34
-rw-r--r--lib/bidding/list/biddings-table.tsx5
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx650
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx4
-rw-r--r--lib/bidding/service.ts16
-rw-r--r--lib/bidding/validation.ts17
-rw-r--r--lib/rfq-last/attachment/rfq-attachments-table.tsx6
-rw-r--r--lib/rfq-last/service.ts29
-rw-r--r--lib/rfq-last/vendor/batch-update-conditions-dialog.tsx69
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx10
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts12
-rw-r--r--lib/soap/ecc/mapper/common-mapper-utils.ts7
-rw-r--r--types/evaluation-form.ts2
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
// 📎 첨부파일 정보 추가