summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-28 03:12:57 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-28 03:12:57 +0000
commit9cda8482660a87fd98c9ee43f507d75ff75b4e23 (patch)
tree67eb1fc24eec7c4e61d3154f7b09fc5349454672 /lib
parentf57898bd240d068301ce3ef477f52cff1234e4ee (diff)
(최겸) 구매 입찰 피드백 반영(90%)
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/service.ts76
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx20
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx14
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx76
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx53
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx10
-rw-r--r--lib/bidding/service.ts1
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx11
8 files changed, 204 insertions, 57 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 0b68eaa7..e425959c 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -207,6 +207,80 @@ export async function getBiddingCompaniesData(biddingId: number) {
}
}
+// 입찰 접수 화면용: 모든 초대된 협력사 조회 (필터링 없음, contact 정보 포함)
+export async function getAllBiddingCompanies(biddingId: number) {
+ try {
+ // 1. 기본 협력사 정보 조회
+ const companies = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ notes: biddingCompanies.notes,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(biddingCompanies.invitedAt)
+
+ // 2. 각 협력사의 첫 번째 contact 정보 조회
+ const companiesWithContacts = await Promise.all(
+ companies.map(async (company) => {
+ if (!company.companyId) {
+ return {
+ ...company,
+ contactPerson: null,
+ contactEmail: null,
+ contactPhone: null
+ }
+ }
+
+ // biddingCompaniesContacts에서 첫 번째 contact 조회
+ const [firstContact] = await db
+ .select({
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, company.companyId)
+ )
+ )
+ .orderBy(asc(biddingCompaniesContacts.id))
+ .limit(1)
+
+ return {
+ ...company,
+ contactPerson: firstContact?.contactName || null,
+ contactEmail: firstContact?.contactEmail || null,
+ contactPhone: firstContact?.contactNumber || null
+ }
+ })
+ )
+
+ return companiesWithContacts
+ } catch (error) {
+ console.error('Failed to get all bidding companies:', error)
+ return []
+ }
+}
+
// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh)
export async function getPRItemsForBidding(biddingId: number) {
try {
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 907115b1..9b8c19c5 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -256,23 +256,17 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date().toString()
- console.log(now, "now")
- const startIso = new Date(startDate).toISOString()
- const endIso = new Date(endDate).toISOString()
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- const isActive = new Date(now) >= new Date(startIso) && new Date(now) <= new Date(endIso)
- console.log(isActive, "isActive")
- const isPast = new Date(now) > new Date(endIso)
- console.log(isPast, "isPast")
return (
<div className="text-xs">
- <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}
+ <div>
+ {formatKst(startObj)} ~ {formatKst(endObj)}
</div>
- {isActive && (
- <Badge variant="default" className="text-xs mt-1">진행중</Badge>
- )}
</div>
)
},
diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx
index ed3d3f41..23f76f4a 100644
--- a/lib/bidding/list/edit-bidding-sheet.tsx
+++ b/lib/bidding/list/edit-bidding-sheet.tsx
@@ -367,7 +367,12 @@ export function EditBiddingSheet({
<FormItem>
<FormLabel>계약 시작일</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -381,7 +386,12 @@ export function EditBiddingSheet({
<FormItem>
<FormLabel>계약 종료일</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 4bde849c..9650574a 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -58,6 +58,7 @@ type BiddingReceiveItem = {
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+ onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
}
// 상태별 배지 색상
@@ -89,7 +90,7 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
}).format(numAmount)
}
-export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
return [
// ░░░ 선택 ░░░
@@ -195,24 +196,17 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date()
const startObj = new Date(startDate)
const endObj = new Date(endDate)
-
- const isActive = now >= startObj && now <= endObj
- const isPast = now > endObj
// UI 표시용 KST 변환
const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
- <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
+ <div>
{formatKst(startObj)} ~ {formatKst(endObj)}
</div>
- {isActive && (
- <Badge variant="default" className="text-xs mt-1">진행중</Badge>
- )}
</div>
)
},
@@ -251,10 +245,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantExpected",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-blue-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'expected')}
+ disabled={row.original.participantExpected === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "참여예정협력사" },
@@ -265,10 +267,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantParticipated",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-green-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'participated')}
+ disabled={row.original.participantParticipated === 0}
+ >
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "참여협력사" },
@@ -279,10 +289,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantDeclined",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-red-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'declined')}
+ disabled={row.original.participantDeclined === 0}
+ >
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "포기협력사" },
@@ -293,10 +311,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantPending",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-yellow-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'pending')}
+ disabled={row.original.participantPending === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "미제출협력사" },
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 5bda921e..2b141d5e 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -22,7 +22,9 @@ import {
contractTypeLabels,
} from "@/db/schema"
// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction, earlyOpenBiddingAction } from "@/lib/bidding/actions"
+import { openBiddingAction } from "@/lib/bidding/actions"
+import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
+import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
type BiddingReceiveItem = {
id: number
@@ -69,17 +71,49 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
// const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
// const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- // const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+ // 협력사 다이얼로그 관련 상태
+ const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
+ const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
+ const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
+ const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
+ const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
+
const router = useRouter()
const { data: session } = useSession()
+ // 협력사 클릭 핸들러
+ const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
+ setSelectedBiddingId(biddingId)
+ setSelectedParticipantType(participantType)
+ setIsLoadingParticipants(true)
+ setParticipantsDialogOpen(true)
+
+ try {
+ // 협력사 데이터 로드 (모든 초대된 협력사)
+ const companies = await getAllBiddingCompanies(biddingId)
+
+ console.log('Loaded companies:', companies)
+
+ // 필터링 없이 모든 데이터 그대로 표시
+ // invitationStatus가 그대로 다이얼로그에 표시됨
+ setParticipantCompanies(companies)
+ } catch (error) {
+ console.error('Failed to load participant companies:', error)
+ toast.error('협력사 목록을 불러오는데 실패했습니다.')
+ setParticipantCompanies([])
+ } finally {
+ setIsLoadingParticipants(false)
+ }
+ }, [])
+
const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction }),
- [setRowAction]
+ () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
+ [setRowAction, handleParticipantClick]
)
// rowAction 변경 감지하여 해당 다이얼로그 열기
@@ -96,7 +130,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
break
}
}
- }, [rowAction])
+ }, [rowAction, router])
const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
{
@@ -248,6 +282,15 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
/> */}
+
+ {/* 참여 협력사 다이얼로그 */}
+ <BiddingParticipantsDialog
+ open={participantsDialogOpen}
+ onOpenChange={setParticipantsDialogOpen}
+ biddingId={selectedBiddingId}
+ participantType={selectedParticipantType}
+ companies={participantCompanies}
+ />
</>
)
}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 355d5aaa..87c489e3 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -175,23 +175,17 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date()
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- const isPast = now > endObj
- const isClosed = isPast
-
+ // 비교로직만 유지, 색상표기/마감뱃지 제거
// UI 표시용 KST 변환
const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
- <div className={`${isClosed ? 'text-red-600' : 'text-gray-600'}`}>
+ <div>
{formatKst(startObj)} ~ {formatKst(endObj)}
</div>
- {isClosed && (
- <Badge variant="destructive" className="text-xs mt-1">마감</Badge>
- )}
</div>
)
},
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 8fd1d368..1ae23e81 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3635,7 +3635,6 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) {
// 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
basicConditions.push(
or(
- eq(biddings.status, 'bidding_opened'),
eq(biddings.status, 'bidding_closed'),
eq(biddings.status, 'evaluation_of_bidding'),
eq(biddings.status, 'vendor_selected')
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 64b4bebf..a122e87b 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -348,11 +348,18 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
if (!startDate || !endDate) {
return <div className="text-muted-foreground">-</div>
}
+
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+
return (
<div className="text-sm">
- <div>{new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')}</div>
+ <div>{formatKst(startObj)}</div>
<div className="text-muted-foreground">~</div>
- <div>{new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}</div>
+ <div>{formatKst(endObj)}</div>
</div>
)
},