diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-28 03:12:57 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-28 03:12:57 +0000 |
| commit | 9cda8482660a87fd98c9ee43f507d75ff75b4e23 (patch) | |
| tree | 67eb1fc24eec7c4e61d3154f7b09fc5349454672 /lib | |
| parent | f57898bd240d068301ce3ef477f52cff1234e4ee (diff) | |
(최겸) 구매 입찰 피드백 반영(90%)
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/bidding/detail/service.ts | 76 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 20 | ||||
| -rw-r--r-- | lib/bidding/list/edit-bidding-sheet.tsx | 14 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-columns.tsx | 76 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-table.tsx | 53 | ||||
| -rw-r--r-- | lib/bidding/selection/biddings-selection-columns.tsx | 10 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 1 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 11 |
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> ) }, |
