diff options
Diffstat (limited to 'lib/bidding/failure')
| -rw-r--r-- | lib/bidding/failure/biddings-closure-dialog.tsx | 142 | ||||
| -rw-r--r-- | lib/bidding/failure/biddings-failure-columns.tsx | 130 | ||||
| -rw-r--r-- | lib/bidding/failure/biddings-failure-table.tsx | 266 |
3 files changed, 463 insertions, 75 deletions
diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx new file mode 100644 index 00000000..64aba42f --- /dev/null +++ b/lib/bidding/failure/biddings-closure-dialog.tsx @@ -0,0 +1,142 @@ +// 폐찰하기 다이얼로그 +"use client" + +import { useState } from "react" +import { toast } from "sonner" +import { bidClosureAction } from "@/lib/bidding/actions" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { FileXIcon } from "lucide-react" + +interface BiddingsClosureDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: { + id: number; + title: string; + biddingNumber: string; + } | null; + userId: string; + onSuccess?: () => void; + } + + export function BiddingsClosureDialog({ + open, + onOpenChange, + bidding, + userId, + onSuccess + }: BiddingsClosureDialogProps) { + const [description, setDescription] = useState('') + const [files, setFiles] = useState<File[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!bidding || !description.trim()) { + toast.error('폐찰 사유를 입력해주세요.') + return + } + + setIsSubmitting(true) + + try { + const result = await bidClosureAction(bidding.id, { + description: description.trim(), + files + }, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + onSuccess?.() + // 페이지 새로고침 또는 상태 업데이트 + window.location.reload() + } else { + toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.') + } + } catch (error) { + toast.error('폐찰 처리 중 오류가 발생했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + if (e.target.files) { + setFiles(Array.from(e.target.files)) + } + } + + if (!bidding) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileXIcon className="h-5 w-5 text-destructive" /> + 폐찰하기 + </DialogTitle> + <DialogDescription> + {bidding.title} ({bidding.biddingNumber})를 폐찰합니다. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label> + <Textarea + id="description" + placeholder="폐찰 사유를 입력해주세요..." + value={description} + onChange={(e) => setDescription(e.target.value)} + className="min-h-[100px]" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="files">첨부파일</Label> + <Input + id="files" + type="file" + multiple + onChange={handleFileChange} + className="cursor-pointer" + accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png" + /> + {files.length > 0 && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: {files.map(f => f.name).join(', ')} + </div> + )} + </div> + + <div className="flex justify-end gap-2 pt-4"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + variant="destructive" + disabled={isSubmitting || !description.trim()} + > + {isSubmitting ? '처리 중...' : '폐찰하기'} + </Button> + </div> + </form> + </DialogContent> + </Dialog> + ) + } +
\ No newline at end of file diff --git a/lib/bidding/failure/biddings-failure-columns.tsx b/lib/bidding/failure/biddings-failure-columns.tsx index 8a888079..3046dbc0 100644 --- a/lib/bidding/failure/biddings-failure-columns.tsx +++ b/lib/bidding/failure/biddings-failure-columns.tsx @@ -5,8 +5,9 @@ import { type ColumnDef } from "@tanstack/react-table" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
- Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw
+ Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw, FileText
} from "lucide-react"
+import { Checkbox } from "@/components/ui/checkbox"
import {
Tooltip,
TooltipContent,
@@ -27,6 +28,7 @@ import { } from "@/db/schema"
import { formatDate } from "@/lib/utils"
import { DataTableRowAction } from "@/types/table"
+import { downloadFile } from "@/lib/file-download"
type BiddingFailureItem = {
id: number
@@ -55,6 +57,15 @@ type BiddingFailureItem = { disposalUpdatedAt: Date | null // 폐찰수정일
disposalUpdatedBy: string | null // 폐찰수정자
+ // 폐찰 정보
+ closureReason: string | null // 폐찰사유
+ closureDocuments: {
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ }[] // 폐찰 첨부파일들
+
// 기타 정보
createdBy: string | null
createdAt: Date | null
@@ -94,6 +105,25 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => { export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingFailureItem>[] {
return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
// ░░░ 입찰번호 ░░░
{
accessorKey: "biddingNumber",
@@ -188,7 +218,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co accessorKey: "biddingRegistrationDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.biddingRegistrationDate, "KR")}</span>
+ <span className="text-sm">{row.original.biddingRegistrationDate ? formatDate(row.original.biddingRegistrationDate, "KR") : '-'}</span>
),
size: 100,
meta: { excelHeader: "입찰등록일" },
@@ -216,7 +246,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co cell: ({ row }) => (
<div className="flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-red-500" />
- <span className="text-sm">{formatDate(row.original.disposalDate, "KR")}</span>
+ <span className="text-sm">{row.original.disposalDate ? formatDate(row.original.disposalDate, "KR") : '-'}</span>
</div>
),
size: 100,
@@ -230,7 +260,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co cell: ({ row }) => (
<div className="flex items-center gap-1">
<FileX className="h-4 w-4 text-red-500" />
- <span className="text-sm">{formatDate(row.original.disposalUpdatedAt, "KR")}</span>
+ <span className="text-sm">{row.original.disposalUpdatedAt ? formatDate(row.original.disposalUpdatedAt, "KR") : '-'}</span>
</div>
),
size: 100,
@@ -248,6 +278,57 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co meta: { excelHeader: "폐찰수정자" },
},
+ // ░░░ 폐찰사유 ░░░
+ {
+ id: "closureReason",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰사유" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.closureReason || undefined}>
+ <span className="text-sm">{row.original.closureReason || '-'}</span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "폐찰사유" },
+ },
+
+ // ░░░ 폐찰첨부파일 ░░░
+ {
+ id: "closureDocuments",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰첨부파일" />,
+ cell: ({ row }) => {
+ const documents = row.original.closureDocuments || []
+
+ if (documents.length === 0) {
+ return <span className="text-sm text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1 max-w-[200px]">
+ {documents.map((doc) => (
+ <Button
+ key={doc.id}
+ variant="link"
+ size="sm"
+ className="p-0 h-auto text-xs underline"
+ onClick={async () => {
+ try {
+ await downloadFile(doc.filePath, doc.originalFileName)
+ } catch (error) {
+ console.error('파일 다운로드 실패:', error)
+ }
+ }}
+ >
+ <FileText className="mr-1 h-3 w-3" />
+ {doc.originalFileName}
+ </Button>
+ ))}
+ </div>
+ )
+ },
+ size: 200,
+ meta: { excelHeader: "폐찰첨부파일" },
+ },
+
// ░░░ P/R번호 ░░░
{
accessorKey: "prNumber",
@@ -267,7 +348,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co <span className="text-sm">{row.original.createdBy || '-'}</span>
),
size: 100,
- meta: { excelHeader: "등록자" },
+ meta: { excelHeader: "최종수정자" },
},
// ░░░ 등록일시 ░░░
@@ -275,46 +356,11 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
),
size: 100,
- meta: { excelHeader: "등록일시" },
+ meta: { excelHeader: "최종일시" },
},
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- {
- id: "actions",
- header: "액션",
- cell: ({ row }) => (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <FileX className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- <Eye className="mr-2 h-4 w-4" />
- 상세보기
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "history" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 이력보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "rebid" })}>
- <RefreshCw className="mr-2 h-4 w-4" />
- 재입찰
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
]
}
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx index 901648d2..c80021ea 100644 --- a/lib/bidding/failure/biddings-failure-table.tsx +++ b/lib/bidding/failure/biddings-failure-table.tsx @@ -18,7 +18,12 @@ import { biddingStatusLabels,
contractTypeLabels,
} from "@/db/schema"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { BiddingsClosureDialog } from "./biddings-closure-dialog"
+import { Button } from "@/components/ui/button"
+import { FileX, RefreshCw, Undo2 } from "lucide-react"
+import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
+import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { useToast } from "@/hooks/use-toast"
type BiddingFailureItem = {
id: number
@@ -30,7 +35,7 @@ type BiddingFailureItem = { prNumber: string | null
// 가격 정보
- targetPrice: number | null
+ targetPrice: string | number | null
currency: string | null
// 일정 정보
@@ -47,6 +52,15 @@ type BiddingFailureItem = { disposalUpdatedAt: Date | null // 폐찰수정일
disposalUpdatedBy: string | null // 폐찰수정자
+ // 폐찰 정보
+ closureReason: string | null // 폐찰사유
+ closureDocuments: {
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ }[] // 폐찰 첨부파일들
+
// 기타 정보
createdBy: string | null
createdAt: Date | null
@@ -69,9 +83,9 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { const { data, pageCount } = biddingsResult
const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+ const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -89,17 +103,18 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setSelectedBidding(rowAction.row.original)
switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ case "rebid":
+ // 재입찰
+ handleRebid(rowAction.row.original)
break
- case "history":
- // 이력보기 (추후 구현)
- console.log('이력보기:', rowAction.row.original)
+ case "closure":
+ // 폐찰
+ setSelectedBidding(rowAction.row.original)
+ setBiddingClosureDialogOpen(true)
break
- case "rebid":
- // 재입찰 (추후 구현)
- console.log('재입찰:', rowAction.row.original)
+ case "cancelDisposal":
+ // 유찰취소
+ handleCancelDisposal(rowAction.row.original)
break
default:
break
@@ -163,6 +178,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableRowSelection: true,
+ singleRowSelection: true,
initialState: {
sorting: [{ id: "disposalDate", desc: true }], // 유찰일 기준 최신순
columnPinning: { right: ["actions"] },
@@ -176,17 +193,85 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setIsCompact(compact)
}, [])
- const handleSpecMeetingDialogClose = React.useCallback(() => {
- setSpecMeetingDialogOpen(false)
+ const handleBiddingClosureDialogClose = React.useCallback(() => {
+ setBiddingClosureDialogOpen(false)
setRowAction(null)
setSelectedBidding(null)
}, [])
- const handlePrDocumentsDialogClose = React.useCallback(() => {
- setPrDocumentsDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ const handleRebid = React.useCallback(async (bidding: BiddingFailureItem) => {
+ if (!session?.user?.id) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ const result = await increaseRoundOrRebid(bidding.id, session.user.id, 'rebidding')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('재입찰 실패:', error)
+ toast({
+ title: "오류",
+ description: "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }, [session?.user?.id, toast, router])
+
+ const handleCancelDisposal = React.useCallback(async (bidding: BiddingFailureItem) => {
+ if (!session?.user?.id) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ const result = await cancelDisposalAction(bidding.id, session.user.id)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "유찰취소 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('유찰취소 실패:', error)
+ toast({
+ title: "오류",
+ description: "유찰취소 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }, [session?.user?.id, toast, router])
return (
<>
@@ -202,22 +287,137 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { compactStorageKey="biddingsFailureTableCompact"
onCompactChange={handleCompactChange}
>
+ {/* Toolbar 액션 버튼들 */}
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "폐찰할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "폐찰은 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ if (bidding.status !== 'bidding_disposal') {
+ toast({
+ title: "유찰 상태만 가능",
+ description: "유찰 상태인 입찰만 폐찰할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ setSelectedBidding(bidding)
+ setBiddingClosureDialogOpen(true)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <FileX className="mr-2 h-4 w-4" />
+ 폐찰
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "재입찰할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "재입찰은 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ handleRebid(bidding)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 재입찰
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "유찰취소할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "유찰취소는 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ if (bidding.status !== 'bidding_disposal' && bidding.status !== 'bid_closure') {
+ toast({
+ title: "유찰/폐찰 상태만 가능",
+ description: "유찰 또는 폐찰 상태인 입찰만 취소할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ handleCancelDisposal(bidding)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <Undo2 className="mr-2 h-4 w-4" />
+ 유찰취소
+ </Button>
+ </div>
</DataTableAdvancedToolbar>
</DataTable>
- {/* 사양설명회 다이얼로그 */}
- <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- />
-
- {/* PR 문서 다이얼로그 */}
- <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- />
+ {/* 폐찰 다이얼로그 */}
+ {selectedBidding && session?.user?.id && (
+ <BidClosureDialog
+ open={biddingClosureDialogOpen}
+ onOpenChange={handleBiddingClosureDialogClose}
+ bidding={selectedBidding}
+ userId={session.user.id}
+ onSuccess={() => {
+ router.refresh()
+ handleBiddingClosureDialogClose()
+ }}
+ />
+ )}
</>
)
}
|
