diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/bidding/bidding-round-actions.tsx | 221 | ||||
| -rw-r--r-- | components/bidding/create/bidding-create-dialog.tsx | 121 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-basic-info-editor.tsx | 136 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-companies-editor.tsx | 48 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-detail-vendor-create-dialog.tsx | 14 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-items-editor.tsx | 49 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-schedule-editor.tsx | 225 |
7 files changed, 332 insertions, 482 deletions
diff --git a/components/bidding/bidding-round-actions.tsx b/components/bidding/bidding-round-actions.tsx deleted file mode 100644 index 86fea72a..00000000 --- a/components/bidding/bidding-round-actions.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { RefreshCw, RotateCw } from "lucide-react"
-import { increaseRoundOrRebid } from "@/lib/bidding/service"
-import { useToast } from "@/hooks/use-toast"
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import { useSession } from "next-auth/react"
-
-interface BiddingRoundActionsProps {
- biddingId: number
- biddingStatus?: string
-}
-
-export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundActionsProps) {
- const router = useRouter()
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [showRoundDialog, setShowRoundDialog] = React.useState(false)
- const [showRebidDialog, setShowRebidDialog] = React.useState(false)
- const { data: session } = useSession()
- const userId = session?.user?.id
-
- // 차수증가는 입찰공고 또는 입찰 진행중 상태에서 가능
- const canIncreaseRound = biddingStatus === 'bidding_generated' || biddingStatus === 'bidding_opened'
-
- // 유찰 및 낙찰은 입찰 진행중 상태에서 가능 (이 컴포넌트에서는 사용하지 않음)
-
- // 재입찰은 유찰 상태에서만 가능
- const canRebid = biddingStatus === 'bidding_disposal'
-
- const handleRoundIncrease = () => {
- if (!userId) {
- toast({
- title: "오류",
- description: "사용자 정보를 찾을 수 없습니다.",
- variant: "destructive",
- })
- return
- }
- const userIdStr = userId as string
- startTransition(async () => {
- try {
- const result = await increaseRoundOrRebid(biddingId, userIdStr, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- variant: "default",
- })
- setShowRoundDialog(false)
- // 새로 생성된 입찰 페이지로 이동
- if (result.biddingId) {
- router.push(`/evcp/bid/${result.biddingId}`)
- router.refresh()
- }
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error('차수증가 실패:', error)
- toast({
- title: "오류",
- description: "차수증가 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- })
- }
-
- const handleRebid = () => {
- if (!userId) {
- toast({
- title: "오류",
- description: "사용자 정보를 찾을 수 없습니다.",
- variant: "destructive",
- })
- return
- }
- const userIdStr = userId as string
- startTransition(async () => {
- try {
- const result = await increaseRoundOrRebid(biddingId, userIdStr, 'rebidding')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- variant: "default",
- })
- setShowRebidDialog(false)
- // 새로 생성된 입찰 페이지로 이동
- if (result.biddingId) {
- router.push(`/evcp/bid/${result.biddingId}`)
- router.refresh()
- }
- } else {
- toast({
- title: "오류",
- description: result.error || "재입찰 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error('재입찰 실패:', error)
- toast({
- title: "오류",
- description: "재입찰 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- })
- }
-
- // 유찰 상태가 아니면 컴포넌트를 렌더링하지 않음
- if (!canIncreaseRound && !canRebid) {
- return null
- }
-
- return (
- <>
- <Card className="mt-6">
- <CardHeader>
- <CardTitle>입찰 차수 관리</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="flex gap-4">
- <Button
- variant="outline"
- onClick={() => setShowRoundDialog(true)}
- disabled={!canIncreaseRound || isPending}
- className="flex items-center gap-2"
- >
- <RotateCw className="w-4 h-4" />
- 차수증가
- </Button>
- <Button
- variant="outline"
- onClick={() => setShowRebidDialog(true)}
- disabled={!canRebid || isPending}
- className="flex items-center gap-2"
- >
- <RefreshCw className="w-4 h-4" />
- 재입찰
- </Button>
- </div>
- <p className="text-sm text-muted-foreground mt-2">
- 유찰 상태에서 차수증가 또는 재입찰을 진행할 수 있습니다.
- </p>
- </CardContent>
- </Card>
-
- {/* 차수증가 확인 다이얼로그 */}
- <AlertDialog open={showRoundDialog} onOpenChange={setShowRoundDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>차수증가</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입찰의 정보를 복제하여 새로운 차수의 입찰을 생성합니다.
- <br />
- 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
- <br />
- <br />
- 계속하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
- <AlertDialogAction onClick={handleRoundIncrease} disabled={isPending}>
- {isPending ? "처리중..." : "확인"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
-
- {/* 재입찰 확인 다이얼로그 */}
- <AlertDialog open={showRebidDialog} onOpenChange={setShowRebidDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>재입찰</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입찰의 정보를 복제하여 재입찰을 생성합니다.
- <br />
- 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
- <br />
- <br />
- 계속하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
- <AlertDialogAction onClick={handleRebid} disabled={isPending}>
- {isPending ? "처리중..." : "확인"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
-}
-
-
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index bdb00a01..c7d79435 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -38,12 +38,23 @@ import { import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
import TiptapEditor from '@/components/qna/tiptap-editor'
+
+// Dropzone components
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
import { createBidding } from '@/lib/bidding/service'
import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
interface BiddingCreateDialogProps {
form: UseFormReturn<CreateBiddingSchema>
@@ -52,6 +63,7 @@ interface BiddingCreateDialogProps { export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProps) {
const { data: session } = useSession()
+ const router = useRouter()
const userId = session?.user?.id ? Number(session.user.id) : null;
const [isOpen, setIsOpen] = React.useState(false)
@@ -145,6 +157,13 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp React.useEffect(() => {
if (isOpen) {
+ if (userId && session?.user?.name) {
+ // 현재 사용자의 정보를 임시로 입찰담당자로 설정
+ form.setValue('bidPicName', session.user.name)
+ form.setValue('bidPicId', userId)
+ // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함)
+ // form.setValue('bidPicCode', session.user.name)
+ }
loadPaymentTerms()
loadIncoterms()
loadShippingPlaces()
@@ -280,7 +299,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }))
// sparePartOptions가 undefined인 경우 빈 문자열로 설정
- const biddingData: CreateBiddingInput = {
+ const biddingData = {
...data,
attachments,
vendorAttachments,
@@ -298,6 +317,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp if (result.success) {
toast.success("입찰이 성공적으로 생성되었습니다.")
+
+ // 생성된 입찰의 상세 페이지로 이동
+ if ('data' in result && result.data?.id) {
+ router.push(`/evcp/bid/${result.data.id}`)
+ }
+
setIsOpen(false)
form.reset()
setShiAttachmentFiles([])
@@ -1130,30 +1155,35 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp SHI용 첨부파일
</CardTitle>
<p className="text-sm text-muted-foreground">
- SHI에서 제공하는 문서나 파일을 업로드하세요
+ 내부 보관를 위해 필요한 문서나 파일을 업로드 하세요.
</p>
</CardHeader>
<CardContent className="space-y-4">
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
- <div className="text-center">
- <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <div className="space-y-2">
- <p className="text-sm text-gray-600">
- 파일을 드래그 앤 드롭하거나{' '}
- <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
- <input
- type="file"
- multiple
- className="hidden"
- onChange={handleShiFileUpload}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
- />
- 찾아보세요
- </label>
- </p>
- </div>
- </div>
- </div>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setShiAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
{shiAttachmentFiles.length > 0 && (
<div className="space-y-2">
@@ -1197,30 +1227,35 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp 협력업체용 첨부파일
</CardTitle>
<p className="text-sm text-muted-foreground">
- 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ 협력사로 제공하는 문서나 파일을 업로드 하세요.
</p>
</CardHeader>
<CardContent className="space-y-4">
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
- <div className="text-center">
- <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <div className="space-y-2">
- <p className="text-sm text-gray-600">
- 파일을 드래그 앤 드롭하거나{' '}
- <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
- <input
- type="file"
- multiple
- className="hidden"
- onChange={handleVendorFileUpload}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
- />
- 찾아보세요
- </label>
- </p>
- </div>
- </div>
- </div>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setVendorAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
{vendorAttachmentFiles.length > 0 && (
<div className="space-y-2">
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index e92c39a5..f0d56689 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -45,6 +45,16 @@ import type { ProcurementManagerWithUser } from '@/components/common/selectors/p import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service' import { downloadFile } from '@/lib/file-download' +// Dropzone components +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" + // 입찰 기본 정보 에디터 컴포넌트 interface BiddingBasicInfo { title?: string @@ -78,6 +88,7 @@ interface BiddingBasicInfo { interface BiddingBasicInfoEditorProps { biddingId: number + readonly?: boolean } interface UploadedDocument { @@ -95,7 +106,7 @@ interface UploadedDocument { uploadedBy: string } -export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProps) { +export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingBasicInfoEditorProps) { const [isLoading, setIsLoading] = React.useState(true) const [isSubmitting, setIsSubmitting] = React.useState(false) const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false) @@ -535,7 +546,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp <FormItem> <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel> <FormControl> - <Input placeholder="입찰명을 입력하세요" {...field} /> + <Input placeholder="입찰명을 입력하세요" {...field} disabled={readonly} /> </FormControl> <FormMessage /> </FormItem> @@ -883,7 +894,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp <FormItem> <FormLabel>입찰개요</FormLabel> <FormControl> - <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} /> + <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} readOnly={readonly} /> </FormControl> <FormMessage /> </FormItem> @@ -896,7 +907,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp <FormItem> <FormLabel>비고</FormLabel> <FormControl> - <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} /> + <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} readOnly={readonly} /> </FormControl> <FormMessage /> </FormItem> @@ -1123,6 +1134,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp })) }} rows={3} + readOnly={readonly} /> </div> </div> @@ -1138,7 +1150,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp <TiptapEditor content={field.value || noticeTemplate} setContent={field.onChange} - disabled={isLoadingTemplate} + disabled={isLoadingTemplate || readonly} height="300px" /> </div> @@ -1156,12 +1168,14 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp </div> {/* 액션 버튼 */} - <div className="flex justify-end gap-4 pt-4"> - <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2"> - {isSubmitting ? '저장 중...' : '저장'} - <ChevronRight className="h-4 w-4" /> - </Button> - </div> + {!readonly && ( + <div className="flex justify-end gap-4 pt-4"> + <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2"> + {isSubmitting ? '저장 중...' : '저장'} + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + )} </form> </Form> </CardContent> @@ -1175,33 +1189,35 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp SHI용 첨부파일 </CardTitle> <p className="text-sm text-muted-foreground"> - SHI에서 제공하는 문서나 파일을 업로드하세요 + 내부 보관를 위해 필요한 문서나 파일을 업로드 하세요. </p> </CardHeader> <CardContent className="space-y-4"> - <div className="border-2 border-dashed border-gray-300 rounded-lg p-6"> - <div className="text-center"> - <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" /> - <div className="space-y-2"> - <p className="text-sm text-gray-600"> - 파일을 드래그 앤 드롭하거나{' '} - <label className="text-blue-600 hover:text-blue-500 cursor-pointer"> - <input - type="file" - multiple - className="hidden" - onChange={(e) => { - const files = Array.from(e.target.files || []) - setShiAttachmentFiles(prev => [...prev, ...files]) - }} - accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg" - /> - 찾아보세요 - </label> - </p> - </div> - </div> - </div> + <Dropzone + maxSize={6e8} // 600MB + onDropAccepted={(files) => { + const newFiles = Array.from(files) + setShiAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "File upload rejected", + description: "Please check file size and type.", + variant: "destructive", + }) + }} + > + {() => ( + <DropzoneZone className="flex justify-center h-32"> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> {shiAttachmentFiles.length > 0 && ( <div className="space-y-2"> @@ -1307,33 +1323,35 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp 협력업체용 첨부파일 </CardTitle> <p className="text-sm text-muted-foreground"> - 협력업체에서 제공하는 문서나 파일을 업로드하세요 + 협력사로 제공하는 문서나 파일을 업로드 하세요. </p> </CardHeader> <CardContent className="space-y-4"> - <div className="border-2 border-dashed border-gray-300 rounded-lg p-6"> - <div className="text-center"> - <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" /> - <div className="space-y-2"> - <p className="text-sm text-gray-600"> - 파일을 드래그 앤 드롭하거나{' '} - <label className="text-blue-600 hover:text-blue-500 cursor-pointer"> - <input - type="file" - multiple - className="hidden" - onChange={(e) => { - const files = Array.from(e.target.files || []) - setVendorAttachmentFiles(prev => [...prev, ...files]) - }} - accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg" - /> - 찾아보세요 - </label> - </p> - </div> - </div> - </div> + <Dropzone + maxSize={6e8} // 600MB + onDropAccepted={(files) => { + const newFiles = Array.from(files) + setVendorAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "File upload rejected", + description: "Please check file size and type.", + variant: "destructive", + }) + }} + > + {() => ( + <DropzoneZone className="flex justify-center h-32"> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> {vendorAttachmentFiles.length > 0 && ( <div className="space-y-2"> diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 4992c2ab..a81f0063 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -11,8 +11,7 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion, - getBiddingById + updateBiddingCompanyPriceAdjustmentQuestion } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -54,6 +53,7 @@ interface QuotationVendor { interface BiddingCompaniesEditorProps { biddingId: number + readonly?: boolean } interface VendorContact { @@ -79,7 +79,7 @@ interface BiddingCompanyContact { updatedAt: Date } -export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProps) { +export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingCompaniesEditorProps) { const [vendors, setVendors] = React.useState<QuotationVendor[]>([]) const [isLoading, setIsLoading] = React.useState(false) const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false) @@ -89,10 +89,6 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp // 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자) const [vendorFirstContacts, setVendorFirstContacts] = React.useState<Map<number, BiddingCompanyContact>>(new Map()) - // 입찰 정보 (단수/복수 낙찰 확인용) - const [biddingInfo, setBiddingInfo] = React.useState<any>(null) - const [isLoadingBiddingInfo, setIsLoadingBiddingInfo] = React.useState(false) - // 담당자 추가 다이얼로그 const [addContactDialogOpen, setAddContactDialogOpen] = React.useState(false) const [newContact, setNewContact] = React.useState({ @@ -146,29 +142,6 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp } }, [biddingId]) - // 입찰 정보 로딩 - const loadBiddingInfo = React.useCallback(async () => { - setIsLoadingBiddingInfo(true) - try { - const result = await getBiddingById(biddingId) - if (result) { - setBiddingInfo(result) - } else { - console.error('Failed to load bidding info') - setBiddingInfo(null) - } - } catch (error) { - console.error('Failed to load bidding info:', error) - setBiddingInfo(null) - } finally { - setIsLoadingBiddingInfo(false) - } - }, [biddingId]) - - // 단수 입찰 여부 확인 및 업체 추가 제한 - const isSingleAwardBidding = biddingInfo?.awardCount === 'single' - const canAddVendor = !isSingleAwardBidding || vendors.length === 0 - // 데이터 로딩 React.useEffect(() => { const loadVendors = async () => { @@ -222,8 +195,7 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp } loadVendors() - loadBiddingInfo() - }, [biddingId, loadBiddingInfo]) + }, [biddingId]) // 업체 선택 핸들러 (단일 선택) const handleVendorSelect = async (vendor: QuotationVendor) => { @@ -513,10 +485,12 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp </p> <p className="text-sm text-muted-foreground mt-1"> 단수 입찰의 경우 1개 업체만 등록 가능합니다. </p> </div> - <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2"> - <Plus className="h-4 w-4" /> - 업체 추가 - </Button> + {!readonly && ( + <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 업체 추가 + </Button> + )} </CardHeader> <CardContent> {vendors.length === 0 ? ( @@ -687,8 +661,6 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp open={addVendorDialogOpen} onOpenChange={setAddVendorDialogOpen} onSuccess={reloadVendors} - isSingleAwardBidding={isSingleAwardBidding} - currentVendorCount={vendors.length} /> {/* 담당자 추가 다이얼로그 (직접 입력) */} diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx index 205224b9..de813121 100644 --- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -39,8 +39,6 @@ interface BiddingDetailVendorCreateDialogProps { open: boolean onOpenChange: (open: boolean) => void onSuccess: () => void - isSingleAwardBidding?: boolean - currentVendorCount?: number } interface Vendor { @@ -60,8 +58,6 @@ export function BiddingDetailVendorCreateDialog({ open, onOpenChange, onSuccess, - isSingleAwardBidding = false, - currentVendorCount = 0 }: BiddingDetailVendorCreateDialogProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() @@ -104,16 +100,6 @@ export function BiddingDetailVendorCreateDialog({ // 벤더 추가 const handleAddVendor = (vendor: Vendor) => { - // 단수 입찰이고 이미 업체가 선택되었거나 기존 업체가 있는 경우 제한 - if (isSingleAwardBidding && (selectedVendorsWithQuestion.length > 0 || currentVendorCount > 0)) { - toast({ - title: '제한 사항', - description: '단수 입찰의 경우 1개 업체만 등록 가능합니다.', - variant: 'destructive', - }) - return - } - if (!selectedVendorsWithQuestion.find(v => v.vendor.id === vendor.id)) { setSelectedVendorsWithQuestion([ ...selectedVendorsWithQuestion, diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index dc0aaeec..38113dfa 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -76,6 +76,7 @@ interface PRItemInfo { interface BiddingItemsEditorProps { biddingId: number + readonly?: boolean } import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service' @@ -84,7 +85,7 @@ import { ProcurementItemSelectorDialogSingle } from '@/components/common/selecto import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' -export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { +export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() const [items, setItems] = React.useState<PRItemInfo[]>([]) const [isLoading, setIsLoading] = React.useState(false) @@ -620,7 +621,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { /> </td> <td className="border-r px-3 py-2"> - {biddingType === 'equipment' ? ( + {biddingType !== 'equipment' ? ( <ProcurementItemSelectorDialogSingle triggerLabel={item.materialGroupNumber || "품목 선택"} triggerVariant="outline" @@ -642,8 +643,8 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { }) } }} - title="품목 선택" - description="품목을 검색하고 선택해주세요." + title="1회성 품목 선택" + description="1회성 품목을 검색하고 선택해주세요." /> ) : ( <MaterialGroupSelectorDialogSingle @@ -1149,25 +1150,27 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { </Card> {/* 액션 버튼 */} - <div className="flex justify-end gap-4"> - <Button - onClick={handleSave} - disabled={isSubmitting} - className="min-w-[120px]" - > - {isSubmitting ? ( - <> - <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> - 저장 중... - </> - ) : ( - <> - <Save className="w-4 h-4 mr-2" /> - 저장 - </> - )} - </Button> - </div> + {!readonly && ( + <div className="flex justify-end gap-4"> + <Button + onClick={handleSave} + disabled={isSubmitting} + className="min-w-[120px]" + > + {isSubmitting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="w-4 h-4 mr-2" /> + 저장 + </> + )} + </Button> + </div> + )} {/* 사전견적용 일반견적 생성 다이얼로그 */} <CreatePreQuoteRfqDialog diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index ce03c742..f2978f95 100644 --- a/components/bidding/manage/bidding-schedule-editor.tsx +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -12,6 +12,9 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' +import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' +import { requestBiddingInvitationWithApproval } from '@/lib/bidding/approval-actions' +import { prepareBiddingApprovalData } from '@/lib/bidding/approval-actions' import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog' import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service' import { registerBidding } from '@/lib/bidding/detail/service' @@ -41,16 +44,17 @@ interface SpecificationMeetingInfo { interface BiddingScheduleEditorProps { biddingId: number + readonly?: boolean } interface VendorContractRequirement { vendorId: number vendorName: string - vendorCode?: string | null + vendorCode?: string vendorCountry?: string - vendorEmail?: string | null - contactPerson?: string | null - contactEmail?: string | null + vendorEmail?: string + contactPerson?: string + contactEmail?: string ndaYn?: boolean generalGtcYn?: boolean projectGtcYn?: boolean @@ -88,7 +92,7 @@ interface BiddingInvitationData { message?: string } -export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) { +export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingScheduleEditorProps) { const { data: session } = useSession() const router = useRouter() const { toast } = useToast() @@ -110,7 +114,11 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) const [isSubmitting, setIsSubmitting] = React.useState(false) const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string; status: string } | null>(null) const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) + const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false) const [selectedVendors, setSelectedVendors] = React.useState<VendorContractRequirement[]>([]) + const [approvalVariables, setApprovalVariables] = React.useState<Record<string, string>>({}) + const [approvalTitle, setApprovalTitle] = React.useState('') + const [invitationData, setInvitationData] = React.useState<BiddingInvitationData | null>(null) // 데이터 로딩 React.useEffect(() => { @@ -197,11 +205,11 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) return result.vendors.map((vendor): VendorContractRequirement => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, - vendorCode: vendor.vendorCode ?? undefined, + vendorCode: vendor.vendorCode || undefined, vendorCountry: vendor.vendorCountry, - vendorEmail: vendor.vendorEmail ?? undefined, - contactPerson: vendor.contactPerson ?? undefined, - contactEmail: vendor.contactEmail ?? undefined, + vendorEmail: vendor.vendorEmail || undefined, + contactPerson: vendor.contactPerson || undefined, + contactEmail: vendor.contactEmail || undefined, ndaYn: vendor.ndaYn, generalGtcYn: vendor.generalGtcYn, projectGtcYn: vendor.projectGtcYn, @@ -219,7 +227,7 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) } }, [biddingId]) - // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 + // 입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { if (isBiddingInvitationDialogOpen) { getSelectedVendors().then(vendors => { @@ -228,75 +236,96 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) } }, [isBiddingInvitationDialogOpen, getSelectedVendors]) - // 입찰 초대 발송 핸들러 - const handleBiddingInvitationSend = async (data: BiddingInvitationData) => { - try { - const userId = session?.user?.id?.toString() || '1' - - // 1. 기본계약 발송 - // sendBiddingBasicContracts에 필요한 형식으로 변환 - const vendorDataForContract = data.vendors.map(vendor => ({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - vendorCode: vendor.vendorCode || undefined, - vendorCountry: vendor.vendorCountry, - selectedMainEmail: vendor.selectedMainEmail, - additionalEmails: vendor.additionalEmails, - customEmails: vendor.customEmails, - contractRequirements: { - ndaYn: vendor.ndaYn || false, - generalGtcYn: vendor.generalGtcYn || false, - projectGtcYn: vendor.projectGtcYn || false, - agreementYn: vendor.agreementYn || false, - }, - biddingCompanyId: vendor.biddingCompanyId, - biddingId: vendor.biddingId, - hasExistingContracts: vendor.hasExistingContracts, - })) - - const contractResult = await sendBiddingBasicContracts( - biddingId, - vendorDataForContract, - data.generatedPdfs, - data.message - ) + // 입찰공고 버튼 클릭 핸들러 - 입찰 초대 다이얼로그 열기 + const handleBiddingInvitationClick = () => { + setIsBiddingInvitationDialogOpen(true) + } - if (!contractResult.success) { - const errorMessage = 'message' in contractResult - ? contractResult.message - : 'error' in contractResult - ? contractResult.error - : '기본계약 발송에 실패했습니다.' + // 결재 상신 핸들러 - 결재 완료 시 실제 입찰 등록 실행 + const handleApprovalSubmit = async ({ approvers, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => { + try { + if (!session?.user?.id || !session.user.epId || !invitationData) { toast({ - title: '기본계약 발송 실패', - description: errorMessage, + title: '오류', + description: '필요한 정보가 없습니다.', variant: 'destructive', }) return } - // 2. 입찰 등록 진행 - const registerResult = await registerBidding(biddingId, userId) + // 결재 상신 + const result = await requestBiddingInvitationWithApproval({ + biddingId, + vendors: selectedVendors, + message: invitationData.message || '', + currentUser: { + id: session.user.id, + epId: session.user.epId, + email: session.user.email || undefined, + }, + approvers, + }) - if (registerResult.success) { + if (result.status === 'pending_approval') { toast({ - title: '본입찰 초대 완료', - description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', + title: '입찰초대 결재 상신 완료', + description: `결재가 상신되었습니다. (ID: ${result.approvalId})`, }) + setIsApprovalDialogOpen(false) setIsBiddingInvitationDialogOpen(false) + setInvitationData(null) router.refresh() - } else { + } + } catch (error) { + console.error('결재 상신 중 오류 발생:', error) + toast({ + title: '오류', + description: '결재 상신 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + } + + // 입찰 초대 발송 핸들러 - 결재 준비 및 결재 다이얼로그 열기 + const handleBiddingInvitationSend = async (data: BiddingInvitationData) => { + try { + if (!session?.user?.id || !session.user.epId) { + toast({ + title: '오류', + description: '사용자 정보가 없습니다.', + variant: 'destructive', + }) + return + } + + // 선정된 업체들 조회 + const vendors = await getSelectedVendors() + if (vendors.length === 0) { toast({ title: '오류', - description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.', + description: '선정된 업체가 없습니다.', variant: 'destructive', }) + return } + + // 결재 데이터 준비 (템플릿 변수, 제목 등) + const approvalData = await prepareBiddingApprovalData({ + biddingId, + vendors, + message: data.message || '', + }) + + // 결재 준비 완료 - invitationData와 결재 데이터 저장 및 결재 다이얼로그 열기 + setInvitationData(data) + setApprovalVariables(approvalData.variables) + setApprovalTitle(`입찰초대 - ${approvalData.bidding.title}`) + setIsApprovalDialogOpen(true) } catch (error) { - console.error('본입찰 초대 실패:', error) + console.error('결재 준비 중 오류 발생:', error) toast({ title: '오류', - description: '본입찰 초대에 실패했습니다.', + description: '결재 준비 중 오류가 발생했습니다.', variant: 'destructive', }) } @@ -614,36 +643,38 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) </Card> {/* 액션 버튼 */} - <div className="flex justify-between gap-4"> - <Button - variant="default" - onClick={() => setIsBiddingInvitationDialogOpen(true)} - disabled={!biddingInfo || biddingInfo.status !== 'bidding_generated'} - className="min-w-[120px]" - > - <Send className="w-4 h-4 mr-2" /> - 입찰공고 - </Button> - <div className="flex gap-4"> + {!readonly && ( + <div className="flex justify-between gap-4"> <Button - onClick={handleSave} - disabled={isSubmitting || !biddingInfo || biddingInfo.status !== 'bidding_generated'} + variant="default" + onClick={handleBiddingInvitationClick} + disabled={!biddingInfo || biddingInfo.status !== 'bidding_generated'} className="min-w-[120px]" > - {isSubmitting ? ( - <> - <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> - 저장 중... - </> - ) : ( - <> - <Save className="w-4 h-4 mr-2" /> - 저장 - </> - )} + <Send className="w-4 h-4 mr-2" /> + 입찰공고 </Button> + <div className="flex gap-4"> + <Button + onClick={handleSave} + disabled={isSubmitting || !biddingInfo || biddingInfo.status !== 'bidding_generated'} + className="min-w-[120px]" + > + {isSubmitting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="w-4 h-4 mr-2" /> + 저장 + </> + )} + </Button> + </div> </div> - </div> + )} {/* 입찰 초대 다이얼로그 */} {biddingInfo && ( @@ -656,6 +687,32 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) onSend={handleBiddingInvitationSend} /> )} + + {/* 입찰초대 결재 다이얼로그 */} + {session?.user && session.user.epId && biddingInfo && invitationData && Object.keys(approvalVariables).length > 0 && ( + <ApprovalPreviewDialog + open={isApprovalDialogOpen} + onOpenChange={(open) => { + setIsApprovalDialogOpen(open) + if (!open) { + // 다이얼로그가 닫히면 결재 변수 초기화 + setApprovalVariables({}) + setApprovalTitle('') + setInvitationData(null) + } + }} + templateName="입찰초대 결재" + variables={approvalVariables} + title={approvalTitle} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined + }} + onConfirm={handleApprovalSubmit} + /> + )} </div> ) } |
