summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-18 20:50:39 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-18 20:50:39 +0900
commit8070b9428918d9ae5e03ad17eef6d403d2a7aaba (patch)
treeae61ea52ae3dae5f2d084a16986b9733a4140047 /components
parent8dba4d53d763bf9e1302a84a8fc65727425219ee (diff)
parentbe5d5ab488ae875e7c56306403aba923e1784021 (diff)
Merge branch 'dujinkim' of https://github.com/DTS-Development/SHI_EVCP into dujinkim
Diffstat (limited to 'components')
-rw-r--r--components/bidding/bidding-round-actions.tsx221
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx121
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx136
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx48
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx14
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx49
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx225
-rw-r--r--components/common/legal/sslvw-pur-inq-req-dialog.tsx333
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx6
-rw-r--r--components/drm/drmUtils.ts60
-rw-r--r--components/login/login-form.tsx19
-rw-r--r--components/login/partner-auth-form.tsx5
-rw-r--r--components/pq-input/pq-input-tabs.tsx136
-rw-r--r--components/vendor-regular-registrations/document-status-dialog.tsx852
-rw-r--r--components/vendor-regular-registrations/registration-request-dialog.tsx17
15 files changed, 1290 insertions, 952 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>
)
}
diff --git a/components/common/legal/sslvw-pur-inq-req-dialog.tsx b/components/common/legal/sslvw-pur-inq-req-dialog.tsx
new file mode 100644
index 00000000..438b6582
--- /dev/null
+++ b/components/common/legal/sslvw-pur-inq-req-dialog.tsx
@@ -0,0 +1,333 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Database, Check } from "lucide-react"
+import { toast } from "sonner"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getPaginationRowModel,
+ getFilteredRowModel,
+ ColumnDef,
+ flexRender,
+} from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { getSSLVWPurInqReqData } from "@/lib/basic-contract/sslvw-service"
+import { SSLVWPurInqReq } from "@/lib/basic-contract/sslvw-service"
+
+interface SSLVWPurInqReqDialogProps {
+ onConfirm?: (selectedRows: SSLVWPurInqReq[]) => void
+}
+
+export function SSLVWPurInqReqDialog({ onConfirm }: SSLVWPurInqReqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [data, setData] = React.useState<SSLVWPurInqReq[]>([])
+ const [error, setError] = React.useState<string | null>(null)
+ const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
+
+ const loadData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const result = await getSSLVWPurInqReqData()
+ if (result.success) {
+ setData(result.data)
+ if (result.isUsingFallback) {
+ toast.info("테스트 데이터를 표시합니다.")
+ }
+ } else {
+ setError(result.error || "데이터 로딩 실패")
+ toast.error(result.error || "데이터 로딩 실패")
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"
+ setError(errorMessage)
+ toast.error(errorMessage)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ React.useEffect(() => {
+ if (open) {
+ loadData()
+ } else {
+ // 다이얼로그 닫힐 때 데이터 초기화
+ setData([])
+ setError(null)
+ setRowSelection({})
+ }
+ }, [open])
+
+ // 테이블 컬럼 정의 (동적 생성)
+ const columns = React.useMemo<ColumnDef<SSLVWPurInqReq>[]>(() => {
+ if (data.length === 0) return []
+
+ const dataKeys = Object.keys(data[0])
+
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모든 행 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ...dataKeys.map((key) => ({
+ accessorKey: key,
+ header: key,
+ cell: ({ getValue }: any) => {
+ const value = getValue()
+ return value !== null && value !== undefined ? String(value) : ""
+ },
+ })),
+ ]
+ }, [data])
+
+ // 테이블 인스턴스 생성
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onRowSelectionChange: setRowSelection,
+ state: {
+ rowSelection,
+ },
+ })
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
+
+ // 확인 버튼 핸들러
+ const handleConfirm = () => {
+ if (selectedRows.length === 0) {
+ toast.error("행을 선택해주세요.")
+ return
+ }
+
+ if (onConfirm) {
+ onConfirm(selectedRows)
+ toast.success(`${selectedRows.length}개의 행을 선택했습니다.`)
+ } else {
+ // 임시로 선택된 데이터 콘솔 출력
+ console.log("선택된 행들:", selectedRows)
+ toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`)
+ }
+
+ setOpen(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Database className="mr-2 size-4" aria-hidden="true" />
+ 법무검토 요청 데이터 조회
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-7xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>법무검토 요청 데이터</DialogTitle>
+ <DialogDescription>
+ 법무검토 요청 데이터를 조회합니다.
+ {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden flex flex-col">
+ {isLoading ? (
+ <div className="flex items-center justify-center flex-1">
+ <Loader className="mr-2 size-6 animate-spin" />
+ <span>데이터 로딩 중...</span>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center flex-1 text-red-500">
+ <span>오류: {error}</span>
+ </div>
+ ) : data.length === 0 ? (
+ <div className="flex items-center justify-center flex-1 text-muted-foreground">
+ <span>데이터가 없습니다.</span>
+ </div>
+ ) : (
+ <>
+ <ScrollArea className="flex-1">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-medium">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id} className="text-sm">
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 데이터가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+
+ {/* 페이지네이션 컨트롤 */}
+ <div className="flex items-center justify-between px-2 py-4 border-t">
+ <div className="flex-1 text-sm text-muted-foreground">
+ {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨
+ </div>
+ <div className="flex items-center space-x-6 lg:space-x-8">
+ <div className="flex items-center space-x-2">
+ <p className="text-sm font-medium">페이지당 행 수</p>
+ <select
+ value={table.getState().pagination.pageSize}
+ onChange={(e) => {
+ table.setPageSize(Number(e.target.value))
+ }}
+ className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
+ >
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+ <option key={pageSize} value={pageSize}>
+ {pageSize}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="flex w-[100px] items-center justify-center text-sm font-medium">
+ {table.getState().pagination.pageIndex + 1} /{" "}
+ {table.getPageCount()}
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <span className="sr-only">첫 페이지로</span>
+ {"<<"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <span className="sr-only">이전 페이지</span>
+ {"<"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ <span className="sr-only">다음 페이지</span>
+ {">"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ <span className="sr-only">마지막 페이지로</span>
+ {">>"}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+
+ <DialogFooter className="gap-2">
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 닫기
+ </Button>
+ {/* <Button onClick={loadData} disabled={isLoading} variant="outline">
+ {isLoading ? (
+ <>
+ <Loader className="mr-2 size-4 animate-spin" />
+ 로딩 중...
+ </>
+ ) : (
+ "새로고침"
+ )}
+ </Button> */}
+ <Button onClick={handleConfirm} disabled={selectedRows.length === 0}>
+ <Check className="mr-2 size-4" />
+ 확인 ({selectedRows.length})
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
index dab65780..90d4975b 100644
--- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
+++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
@@ -70,13 +70,13 @@ export interface ProcurementItemSelectorDialogSingleProps {
* ```
*/
export function ProcurementItemSelectorDialogSingle({
- triggerLabel = "품목 선택",
+ triggerLabel = "1회성 품목 선택",
triggerVariant = "outline",
triggerSize = "default",
selectedProcurementItem = null,
onProcurementItemSelect,
- title = "품목 선택",
- description = "품목을 검색하고 선택해주세요.",
+ title = "1회성 품목 선택",
+ description = "1회성 품목을 검색하고 선택해주세요.",
showConfirmButtons = false,
}: ProcurementItemSelectorDialogSingleProps) {
const [open, setOpen] = useState(false);
diff --git a/components/drm/drmUtils.ts b/components/drm/drmUtils.ts
index 4ba63090..00cc83b2 100644
--- a/components/drm/drmUtils.ts
+++ b/components/drm/drmUtils.ts
@@ -4,6 +4,7 @@
* 2. 복호화 서버액션을 제공한다.
*
* decryptWithServerAction(file): 서버액션을 사용하여 복호화한 파일을 응답하는 함수
+ * isDRMFile(file): 파일이 DRM 암호화되어 있는지 검출하는 함수
*
*/
@@ -126,4 +127,63 @@ export async function decryptBufferWithServerAction(
return fileBuffer;
}
+}
+
+/**
+ * 파일이 DRM 암호화되어 있는지 검출하는 함수
+ * DRM 복호화 시도를 통해 DRM 여부를 판단합니다.
+ *
+ * @param file - 검출할 파일
+ * @returns DRM 파일이면 true, 일반 파일이면 false
+ */
+export async function isDRMFile(file: File): Promise<boolean> {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const backendUrl = "http://localhost:6543/api/drm-proxy/decrypt";
+
+ console.log(`[DRM Check] 파일 DRM 검출 시도: ${file.name} (크기: ${file.size} bytes)`);
+
+ // DRM 복호화 시도
+ const response = await fetch(backendUrl, {
+ method: "POST",
+ body: formData,
+ });
+
+ // 응답이 성공적이면 DRM 파일로 판단
+ if (response.ok) {
+ const decryptedData = await response.arrayBuffer();
+
+ // 복호화된 데이터 크기가 원본과 다르거나, 복호화 성공 로그가 있으면 DRM 파일
+ const isDrmFile = decryptedData.byteLength !== file.size ||
+ decryptedData.byteLength > 0;
+
+ console.log(`[DRM Check] DRM 파일로 판정: ${file.name} (원본: ${file.size} bytes, 복호화: ${decryptedData.byteLength} bytes)`);
+
+ return isDrmFile;
+ } else {
+ // 응답이 실패하면 일반 파일로 판단
+ const errorText = await response.text().catch(() => '응답 텍스트를 가져올 수 없음');
+ console.log(`[DRM Check] 일반 파일로 판정: ${file.name} (응답 상태: ${response.status}, 오류: ${errorText})`);
+ return false;
+ }
+ } catch (error) {
+ // 네트워크 오류나 서버 연결 실패 시 일반 파일로 판단
+ const errorMessage = error instanceof Error
+ ? `${error.name}: ${error.message}`
+ : String(error);
+
+ console.log(`[DRM Check] 일반 파일로 판정 (오류 발생): ${file.name}`, {
+ error: errorMessage,
+ remark: `
+ [DRM 검출 실패 케이스]
+ 1. DRM 백엔드 서버가 없는 경우 - 일반 파일로 판정
+ 2. DRM 중앙 서버와 통신 불가한 경우 - 일반 파일로 판정
+ 3. 네트워크 오류 - 일반 파일로 판정
+ `
+ });
+
+ return false;
+ }
} \ No newline at end of file
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
index 5fe6ab51..ee84add2 100644
--- a/components/login/login-form.tsx
+++ b/components/login/login-form.tsx
@@ -950,15 +950,16 @@ export function LoginForm() {
{/* Terms - MFA 화면에서는 숨김 */}
{!showMfaForm && (
- <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
- {t("agreement")}{" "}
- <Link
- href={`/${lng}/privacy`}
- className="underline underline-offset-4 hover:text-primary"
- >
- {t("privacyPolicy")}
- </Link>
- </div>
+ // 1118 구매 파워유저 요구사항에 따라 삭제
+ // <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
+ // {t("agreement")}{" "}
+ // <Link
+ // href={`/${lng}/privacy`}
+ // className="underline underline-offset-4 hover:text-primary"
+ // >
+ // {t("privacyPolicy")}
+ // </Link>
+ // </div>
)}
</div>
</div>
diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx
index 22917997..ebd2219c 100644
--- a/components/login/partner-auth-form.tsx
+++ b/components/login/partner-auth-form.tsx
@@ -300,13 +300,14 @@ export function CompanyAuthForm({ className, ...props }: React.HTMLAttributes<HT
</form>
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
- {t("agreement")}{" "}
+ {/* 1118 구매 파워유저 요구사항에 따라 삭제 */}
+ {/* {t("agreement")}{" "}
<Link
href={`/${lng}/privacy`} // 개인정보처리방침만 남김
className="underline underline-offset-4 hover:text-primary"
>
{t("privacyPolicy")}
- </Link>
+ </Link> */}
{/* {t("privacyAgreement")}. */}
</p>
</div>
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index f0d44d04..df911d5e 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -276,6 +276,55 @@ export function PQInputTabs({
setAllSaved(allItemsSaved)
}, [form.watch()])
+ // ----------------------------------------------------------------------
+ // C-1) Calculate item counts for display
+ // ----------------------------------------------------------------------
+
+ // ----------------------------------------------------------------------
+ // C-2) Tab color mapping for better visual distinction
+ // ----------------------------------------------------------------------
+ const getTabColorClasses = (groupName: string) => {
+ switch (groupName.toLowerCase()) {
+ case 'general':
+ return {
+ tab: 'data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 data-[state=active]:border-blue-200',
+ badge: 'bg-blue-100 text-blue-800 border-blue-200'
+ }
+ case 'hsg':
+ return {
+ tab: 'data-[state=active]:bg-green-50 data-[state=active]:text-green-700 data-[state=active]:border-green-200',
+ badge: 'bg-green-100 text-green-800 border-green-200'
+ }
+ case 'qms':
+ return {
+ tab: 'data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 data-[state=active]:border-orange-200',
+ badge: 'bg-orange-100 text-orange-800 border-orange-200'
+ }
+ case 'warranty':
+ return {
+ tab: 'data-[state=active]:bg-red-50 data-[state=active]:text-red-700 data-[state=active]:border-red-200',
+ badge: 'bg-red-100 text-red-800 border-red-200'
+ }
+ default:
+ return {
+ tab: 'data-[state=active]:bg-gray-50 data-[state=active]:text-gray-700 data-[state=active]:border-gray-200',
+ badge: 'bg-gray-100 text-gray-800 border-gray-200'
+ }
+ }
+ }
+ const getItemCounts = () => {
+ const values = form.getValues()
+ const totalItems = values.answers.length
+ const savedItems = values.answers.filter(
+ (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0)
+ ).length
+ const notSavedItems = totalItems - savedItems
+
+ return { totalItems, savedItems, notSavedItems }
+ }
+
+ const { totalItems, savedItems, notSavedItems } = getItemCounts()
+
// Helper to find the array index by criteriaId
const getAnswerIndex = (criteriaId: number): number => {
return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId)
@@ -677,6 +726,30 @@ export function PQInputTabs({
<Tabs defaultValue={data[0]?.groupName || ""} className="w-full">
{/* Top Controls - Sticky Header */}
<div className="sticky top-0 z-10 bg-background border-b border-border mb-4 pb-4">
+ {/* Item Count Display */}
+ <div className="mb-3 flex items-center gap-6 text-sm">
+ <div className="flex items-center gap-4">
+ <span className="font-medium">총 항목:</span>
+ <Badge variant="outline" className="text-xs">
+ {totalItems}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ <CheckCircle2 className="h-4 w-4 text-green-600" />
+ <span className="text-green-600 font-medium">Saved:</span>
+ <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">
+ {savedItems}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-amber-600" />
+ <span className="text-amber-600 font-medium">Not Saved:</span>
+ <Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 text-xs">
+ {notSavedItems}
+ </Badge>
+ </div>
+ </div>
+
{/* Filter Controls */}
<div className="mb-3 flex items-center gap-4">
<span className="text-sm font-medium">필터:</span>
@@ -702,8 +775,11 @@ export function PQInputTabs({
checked={filterOptions.showSaved}
onCheckedChange={(checked) => {
const newOptions = { ...filterOptions, showSaved: !!checked };
- if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) {
- // 최소 하나는 체크되어 있어야 함
+ // Save 항목이나 Not Save 항목을 선택하면 전체 항목 자동 해제
+ if (checked) {
+ newOptions.showAll = false;
+ } else if (!filterOptions.showNotSaved && !filterOptions.showAll) {
+ // 최소 하나는 체크되어 있어야 함 - 모두 해제되면 전체 항목 체크
newOptions.showAll = true;
}
setFilterOptions(newOptions);
@@ -717,8 +793,11 @@ export function PQInputTabs({
checked={filterOptions.showNotSaved}
onCheckedChange={(checked) => {
const newOptions = { ...filterOptions, showNotSaved: !!checked };
- if (!checked && !filterOptions.showAll && !filterOptions.showSaved) {
- // 최소 하나는 체크되어 있어야 함
+ // Save 항목이나 Not Save 항목을 선택하면 전체 항목 자동 해제
+ if (checked) {
+ newOptions.showAll = false;
+ } else if (!filterOptions.showSaved && !filterOptions.showAll) {
+ // 최소 하나는 체크되어 있어야 함 - 모두 해제되면 전체 항목 체크
newOptions.showAll = true;
}
setFilterOptions(newOptions);
@@ -731,27 +810,30 @@ export function PQInputTabs({
<div className="flex justify-between items-center">
<TabsList className="grid grid-cols-4">
- {data.map((group) => (
- <TabsTrigger
- key={group.groupName}
- value={group.groupName}
- className="truncate"
- >
- <div className="flex items-center gap-2">
- {/* Mobile: truncated version */}
- <span className="block sm:hidden">
- {group.groupName.length > 5
- ? group.groupName.slice(0, 5) + "..."
- : group.groupName}
- </span>
- {/* Desktop: full text */}
- <span className="hidden sm:block">{group.groupName}</span>
- <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
- {group.items.length}
- </span>
- </div>
- </TabsTrigger>
- ))}
+ {data.map((group) => {
+ const colorClasses = getTabColorClasses(group.groupName)
+ return (
+ <TabsTrigger
+ key={group.groupName}
+ value={group.groupName}
+ className={`truncate ${colorClasses.tab}`}
+ >
+ <div className="flex items-center gap-2">
+ {/* Mobile: truncated version */}
+ <span className="block sm:hidden">
+ {group.groupName.length > 5
+ ? group.groupName.slice(0, 5) + "..."
+ : group.groupName}
+ </span>
+ {/* Desktop: full text */}
+ <span className="hidden sm:block">{group.groupName}</span>
+ <span className={`inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full text-xs font-medium ${colorClasses.badge}`}>
+ {group.items.length}
+ </span>
+ </div>
+ </TabsTrigger>
+ )
+ })}
</TabsList>
<div className="flex gap-2">
@@ -849,13 +931,13 @@ export function PQInputTabs({
{/* Save Status & Button */}
<div className="flex items-center gap-2">
{!isSaved && canSave && (
- <span className="text-amber-600 text-xs flex items-center">
+ <span className="text-amber-600 text-sm font-medium flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" />
Not Saved
</span>
)}
{isSaved && (
- <span className="text-green-600 text-xs flex items-center">
+ <span className="text-green-600 text-sm font-medium flex items-center">
<CheckCircle2 className="h-4 w-4 mr-1" />
Saved
</span>
diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx
index 5efee64e..02da19bf 100644
--- a/components/vendor-regular-registrations/document-status-dialog.tsx
+++ b/components/vendor-regular-registrations/document-status-dialog.tsx
@@ -1,426 +1,426 @@
-"use client";
-
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
-import { toast } from "sonner";
-
-import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
-import {
- documentStatusColumns,
-} from "@/config/vendorRegularRegistrationsColumnsConfig";
-
-interface DocumentStatusDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- registration: VendorRegularRegistration | null;
- onRefresh?: () => void;
- isVendorUser?: boolean;
-}
-
-const StatusIcon = ({ status }: { status: string | boolean }) => {
- if (typeof status === "boolean") {
- return status ? (
- <CheckCircle className="w-4 h-4 text-green-600" />
- ) : (
- <XCircle className="w-4 h-4 text-red-500" />
- );
- }
-
- switch (status) {
- case "completed":
- return <CheckCircle className="w-4 h-4 text-green-600" />;
- case "reviewing":
- return <Clock className="w-4 h-4 text-yellow-600" />;
- case "not_submitted":
- default:
- return <XCircle className="w-4 h-4 text-red-500" />;
- }
-};
-
-const StatusBadge = ({ status }: { status: string | boolean }) => {
- if (typeof status === "boolean") {
- return (
- <Badge variant={status ? "default" : "destructive"}>
- {status ? "제출완료" : "미제출"}
- </Badge>
- );
- }
-
- const statusConfig = {
- completed: { label: "완료", variant: "default" as const },
- reviewing: { label: "검토중", variant: "secondary" as const },
- not_submitted: { label: "미제출", variant: "destructive" as const },
- };
-
- const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
-
- return <Badge variant={config.variant}>{config.label}</Badge>;
-};
-
-export function DocumentStatusDialog({
- open,
- onOpenChange,
- registration,
- onRefresh,
- isVendorUser = false,
-}: DocumentStatusDialogProps) {
- if (!registration) return null;
-
- // 파일 다운로드 핸들러
- const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
- try {
- console.log(`🔍 파일 다운로드 시도:`, {
- docKey,
- fileIndex,
- allDocumentFiles: registration.documentFiles,
- registrationId: registration.id,
- registrationKeys: Object.keys(registration),
- fullRegistration: registration
- });
- //isvendoruser인 경우는 실사 결과 파일 다운로드 불가능
- if (isVendorUser && docKey === "auditResult") {
- toast.error("실사 결과 파일은 다운로드할 수 없습니다.");
- return;
- }
-
- // documentFiles가 없는 경우 처리
- if (!registration.documentFiles) {
- console.error(`❌ documentFiles가 없음:`, {
- registration,
- hasDocumentFiles: !!registration.documentFiles,
- registrationKeys: Object.keys(registration)
- });
- toast.error("문서 파일 정보를 찾을 수 없습니다. 페이지를 새로고침 해주세요.");
- return;
- }
-
- const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
- console.log(`📂 ${docKey} 파일 목록:`, files);
-
- if (!files || files.length === 0) {
- console.warn(`❌ ${docKey}에 파일이 없음:`, { files, length: files?.length });
- toast.error("다운로드할 파일이 없습니다.");
- return;
- }
-
- const file = files[fileIndex];
- console.log(`📄 선택된 파일 (index ${fileIndex}):`, file);
-
- if (!file) {
- console.warn(`❌ 파일 인덱스 ${fileIndex}에 파일이 없음`);
- toast.error("파일을 찾을 수 없습니다.");
- return;
- }
-
- // 파일 객체의 모든 속성 확인
- console.log(`🔍 파일 객체 전체 속성:`, Object.keys(file));
- console.log(`🔍 파일 상세 정보:`, {
- filePath: file.filePath,
- path: file.path,
- originalFileName: file.originalFileName,
- fileName: file.fileName,
- name: file.name,
- fullObject: file
- });
-
- // filePath와 fileName 추출
- const filePath = file.filePath || file.path;
- const fileName = file.originalFileName || file.fileName || file.name;
-
- console.log(`📝 추출된 파일 정보:`, { filePath, fileName });
-
- if (!filePath || !fileName) {
- console.error(`❌ 파일 정보 누락:`, {
- filePath,
- fileName,
- fileObject: file,
- availableKeys: Object.keys(file)
- });
- toast.error("파일 정보가 올바르지 않습니다.");
- return;
- }
-
- console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
-
- // downloadFile 함수를 동적으로 import하여 파일 다운로드
- const { downloadFile } = await import('@/lib/file-download');
- const result = await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error: any) => {
- console.error("파일 다운로드 오류:", error);
- toast.error(`파일 다운로드 실패: ${error}`);
- },
- onSuccess: (fileName: string, fileSize?: number) => {
- console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
- }
- });
-
- if (!result.success) {
- console.error("파일 다운로드 실패:", result.error);
- }
- } catch (error) {
- console.error("파일 다운로드 중 오류 발생:", error);
- toast.error("파일 다운로드 중 오류가 발생했습니다.");
- }
- };
-
- // 기본계약 파일 다운로드 핸들러
- const handleContractDownload = async (contractIndex: number) => {
- try {
- if (!registration.basicContracts || registration.basicContracts.length === 0) {
- toast.error("다운로드할 계약이 없습니다.");
- return;
- }
-
- const contract = registration.basicContracts[contractIndex];
- if (!contract) {
- toast.error("계약을 찾을 수 없습니다.");
- return;
- }
-
- if (contract.status !== "VENDOR_SIGNED" && contract.status !== "COMPLETED") {
- toast.error("완료된 계약서만 다운로드할 수 있습니다.");
- return;
- }
-
- // 서명된 계약서 파일 정보 확인
- const filePath = contract.filePath;
- const fileName = contract.fileName || `${contract.templateName || '기본계약'}_${registration.companyName}.docx`;
-
- if (!filePath) {
- toast.error("계약서 파일을 찾을 수 없습니다.");
- return;
- }
-
- console.log(`📥 기본계약 다운로드 시작:`, {
- filePath,
- fileName,
- templateName: contract.templateName
- });
-
- // downloadFile 함수를 사용하여 서명된 계약서 다운로드
- const { downloadFile } = await import('@/lib/file-download');
- const result = await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error: any) => {
- console.error("기본계약 다운로드 오류:", error);
- toast.error(`기본계약 다운로드 실패: ${error}`);
- },
- onSuccess: (fileName: string, fileSize?: number) => {
- console.log(`✅ 기본계약 다운로드 성공:`, { fileName, fileSize });
- }
- });
-
- if (!result.success) {
- console.error("기본계약 다운로드 실패:", result.error);
- }
- } catch (error) {
- console.error("기본계약 다운로드 중 오류 발생:", error);
- toast.error("기본계약 다운로드 중 오류가 발생했습니다.");
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh]">
- <DialogHeader className="sticky top-0 z-10 border-b pr-4 pb-4 mb-4">
- <DialogTitle className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 문서/자료 접수 현황 - {registration.companyName}
- </div>
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="flex items-center gap-2"
- >
- <RefreshCw className="w-4 h-4" />
- 새로고침
- </Button>
- )}
- </DialogTitle>
- </DialogHeader>
-
- <div className="overflow-y-auto max-h-[calc(80vh-120px)] space-y-6">
- {/* 기본 정보 */}
- <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
- <div>
- <span className="text-sm font-medium text-gray-600">업체명:</span>
- <span className="ml-2">{registration.companyName}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">사업자번호:</span>
- <span className="ml-2">{registration.businessNumber}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">대표자:</span>
- <span className="ml-2">{registration.representative || "-"}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">현재상태:</span>
- <Badge className="ml-2">{registration.status}</Badge>
- </div>
- </div>
-
- {/* 문서 제출 현황 */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h3 className="text-lg font-semibold">문서 제출 현황</h3>
- </div>
- <div className="border rounded-lg">
- <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
- <div>문서유형</div>
- <div>상태</div>
- <div>제출일자</div>
- <div>액션</div>
- </div>
- {documentStatusColumns.map((doc) => {
- const isSubmitted = registration.documentSubmissions?.[
- doc.key as keyof typeof registration.documentSubmissions
- ] as boolean || false;
-
- // 내자인 경우 통장사본은 표시하지 않음
- const isForeign = registration.country !== 'KR';
- if (doc.key === 'bankCopy' && !isForeign) {
- return null;
- }
-
- return (
- <div
- key={doc.key}
- className="grid grid-cols-4 gap-4 p-4 border-t items-center"
- >
- <div className="flex items-center gap-2">
- <StatusIcon status={isSubmitted} />
- {doc.label}
- {doc.key === 'bankCopy' && isForeign && (
- <span className="text-xs text-blue-600">(외자 필수)</span>
- )}
- </div>
- <div>
- <StatusBadge status={isSubmitted} />
- </div>
- <div className="text-sm text-gray-600">
- {isSubmitted ? "2024.01.01" : "-"}
- </div>
- <div>
- {isSubmitted && (
- <Button
- size="sm"
- variant="outline"
- onClick={() => handleFileDownload(doc.key)}
- >
- <Download className="w-4 h-4 mr-1" />
- 다운로드
- </Button>
- )}
- </div>
- </div>
- );
- })}
- </div>
- </div>
-
- {/* 계약 동의 현황 */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h3 className="text-lg font-semibold">계약 동의 현황</h3>
- </div>
- <div className="border rounded-lg">
- <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
- <div>계약유형</div>
- <div>상태</div>
- <div>서약일자</div>
- <div>액션</div>
- </div>
- {!registration.basicContracts || registration.basicContracts.length === 0 ? (
- <div className="p-4 border-t text-center text-gray-500">
- 요청된 기본계약이 없습니다.
- </div>
- ) : (
- registration.basicContracts.map((contract, index) => {
- const isCompleted = contract.status === "VENDOR_SIGNED" || contract.status === "COMPLETED";
-
- return (
- <div
- key={`${contract.templateId}-${index}`}
- className="grid grid-cols-4 gap-4 p-4 border-t items-center"
- >
- <div className="flex items-center gap-2">
- <StatusIcon status={isCompleted} />
- {contract.templateName || "템플릿명 없음"}
- </div>
- <div>
- <StatusBadge status={isCompleted} />
- </div>
- <div className="text-sm text-gray-600">
- {isCompleted && contract.createdAt
- ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt))
- : "-"
- }
- </div>
- <div>
- {isCompleted && (
- <Button
- size="sm"
- variant="outline"
- onClick={() => handleContractDownload(index)}
- >
- <Download className="w-4 h-4 mr-1" />
- 다운로드
- </Button>
- )}
- </div>
- </div>
- );
- })
- )}
- </div>
- </div>
-
- {/* 안전적격성 평가 */}
- <div>
- <h3 className="text-lg font-semibold mb-4">안전적격성 평가</h3>
- <div className="p-4 border rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <StatusIcon status={!!registration.safetyQualificationContent} />
- <span>안전적격성 평가</span>
- </div>
- <StatusBadge status={!!registration.safetyQualificationContent} />
- </div>
- {registration.safetyQualificationContent && (
- <div className="mt-3 p-3 bg-gray-50 rounded">
- <p className="text-sm">{registration.safetyQualificationContent}</p>
- </div>
- )}
- </div>
- </div>
-
- {/* 추가 정보 */}
- <div>
- <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
- <div className="p-4 border rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <StatusIcon status={registration.additionalInfo} />
- <span>추가 정보 등록</span>
- </div>
- <StatusBadge status={registration.additionalInfo} />
- </div>
- </div>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- );
-}
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
+import { toast } from "sonner";
+
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
+import {
+ documentStatusColumns,
+} from "@/config/vendorRegularRegistrationsColumnsConfig";
+
+interface DocumentStatusDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ registration: VendorRegularRegistration | null;
+ onRefresh?: () => void;
+ isVendorUser?: boolean;
+}
+
+const StatusIcon = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return status ? (
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ ) : (
+ <XCircle className="w-4 h-4 text-red-500" />
+ );
+ }
+
+ switch (status) {
+ case "completed":
+ return <CheckCircle className="w-4 h-4 text-green-600" />;
+ case "reviewing":
+ return <Clock className="w-4 h-4 text-yellow-600" />;
+ case "not_submitted":
+ default:
+ return <XCircle className="w-4 h-4 text-red-500" />;
+ }
+};
+
+const StatusBadge = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return (
+ <Badge variant={status ? "default" : "destructive"}>
+ {status ? "제출완료" : "미제출"}
+ </Badge>
+ );
+ }
+
+ const statusConfig = {
+ completed: { label: "완료", variant: "default" as const },
+ reviewing: { label: "검토중", variant: "secondary" as const },
+ not_submitted: { label: "미제출", variant: "destructive" as const },
+ };
+
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
+
+ return <Badge variant={config.variant}>{config.label}</Badge>;
+};
+
+export function DocumentStatusDialog({
+ open,
+ onOpenChange,
+ registration,
+ onRefresh,
+ isVendorUser = false,
+}: DocumentStatusDialogProps) {
+ if (!registration) return null;
+
+ // 파일 다운로드 핸들러
+ const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
+ try {
+ console.log(`🔍 파일 다운로드 시도:`, {
+ docKey,
+ fileIndex,
+ allDocumentFiles: registration.documentFiles,
+ registrationId: registration.id,
+ registrationKeys: Object.keys(registration),
+ fullRegistration: registration
+ });
+ //isvendoruser인 경우는 실사 결과 파일 다운로드 불가능
+ if (isVendorUser && docKey === "auditResult") {
+ toast.error("실사 결과 파일은 다운로드할 수 없습니다.");
+ return;
+ }
+
+ // documentFiles가 없는 경우 처리
+ if (!registration.documentFiles) {
+ console.error(`❌ documentFiles가 없음:`, {
+ registration,
+ hasDocumentFiles: !!registration.documentFiles,
+ registrationKeys: Object.keys(registration)
+ });
+ toast.error("문서 파일 정보를 찾을 수 없습니다. 페이지를 새로고침 해주세요.");
+ return;
+ }
+
+ const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
+ console.log(`📂 ${docKey} 파일 목록:`, files);
+
+ if (!files || files.length === 0) {
+ console.warn(`❌ ${docKey}에 파일이 없음:`, { files, length: files?.length });
+ toast.error("다운로드할 파일이 없습니다.");
+ return;
+ }
+
+ const file = files[fileIndex];
+ console.log(`📄 선택된 파일 (index ${fileIndex}):`, file);
+
+ if (!file) {
+ console.warn(`❌ 파일 인덱스 ${fileIndex}에 파일이 없음`);
+ toast.error("파일을 찾을 수 없습니다.");
+ return;
+ }
+
+ // 파일 객체의 모든 속성 확인
+ console.log(`🔍 파일 객체 전체 속성:`, Object.keys(file));
+ console.log(`🔍 파일 상세 정보:`, {
+ filePath: file.filePath,
+ path: file.path,
+ originalFileName: file.originalFileName,
+ fileName: file.fileName,
+ name: file.name,
+ fullObject: file
+ });
+
+ // filePath와 fileName 추출
+ const filePath = file.filePath || file.path;
+ const fileName = file.originalFileName || file.fileName || file.name;
+
+ console.log(`📝 추출된 파일 정보:`, { filePath, fileName });
+
+ if (!filePath || !fileName) {
+ console.error(`❌ 파일 정보 누락:`, {
+ filePath,
+ fileName,
+ fileObject: file,
+ availableKeys: Object.keys(file)
+ });
+ toast.error("파일 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
+
+ // downloadFile 함수를 동적으로 import하여 파일 다운로드
+ const { downloadFile } = await import('@/lib/file-download');
+ const result = await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error: any) => {
+ console.error("파일 다운로드 오류:", error);
+ toast.error(`파일 다운로드 실패: ${error}`);
+ },
+ onSuccess: (fileName: string, fileSize?: number) => {
+ console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
+ }
+ });
+
+ if (!result.success) {
+ console.error("파일 다운로드 실패:", result.error);
+ }
+ } catch (error) {
+ console.error("파일 다운로드 중 오류 발생:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 기본계약 파일 다운로드 핸들러
+ const handleContractDownload = async (contractIndex: number) => {
+ try {
+ if (!registration.basicContracts || registration.basicContracts.length === 0) {
+ toast.error("다운로드할 계약이 없습니다.");
+ return;
+ }
+
+ const contract = registration.basicContracts[contractIndex];
+ if (!contract) {
+ toast.error("계약을 찾을 수 없습니다.");
+ return;
+ }
+
+ if (contract.status !== "VENDOR_SIGNED" && contract.status !== "COMPLETED") {
+ toast.error("완료된 계약서만 다운로드할 수 있습니다.");
+ return;
+ }
+
+ // 서명된 계약서 파일 정보 확인
+ const filePath = contract.filePath;
+ const fileName = contract.fileName || `${contract.templateName || '기본계약'}_${registration.companyName}.docx`;
+
+ if (!filePath) {
+ toast.error("계약서 파일을 찾을 수 없습니다.");
+ return;
+ }
+
+ console.log(`📥 기본계약 다운로드 시작:`, {
+ filePath,
+ fileName,
+ templateName: contract.templateName
+ });
+
+ // downloadFile 함수를 사용하여 서명된 계약서 다운로드
+ const { downloadFile } = await import('@/lib/file-download');
+ const result = await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error: any) => {
+ console.error("기본계약 다운로드 오류:", error);
+ toast.error(`기본계약 다운로드 실패: ${error}`);
+ },
+ onSuccess: (fileName: string, fileSize?: number) => {
+ console.log(`✅ 기본계약 다운로드 성공:`, { fileName, fileSize });
+ }
+ });
+
+ if (!result.success) {
+ console.error("기본계약 다운로드 실패:", result.error);
+ }
+ } catch (error) {
+ console.error("기본계약 다운로드 중 오류 발생:", error);
+ toast.error("기본계약 다운로드 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader className="sticky top-0 z-10 border-b pr-4 pb-4 mb-4">
+ <DialogTitle className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 문서/자료 접수 현황 - {registration.companyName}
+ </div>
+ {onRefresh && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="w-4 h-4" />
+ 새로고침
+ </Button>
+ )}
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="overflow-y-auto max-h-[calc(80vh-120px)] space-y-6">
+ {/* 기본 정보 */}
+ <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
+ <div>
+ <span className="text-sm font-medium text-gray-600">업체명:</span>
+ <span className="ml-2">{registration.companyName}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">사업자번호:</span>
+ <span className="ml-2">{registration.businessNumber}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">대표자:</span>
+ <span className="ml-2">{registration.representative || "-"}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">현재상태:</span>
+ <Badge className="ml-2">{registration.status}</Badge>
+ </div>
+ </div>
+
+ {/* 문서 제출 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">문서 제출 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>문서유형</div>
+ <div>상태</div>
+ <div>제출일자</div>
+ <div>액션</div>
+ </div>
+ {documentStatusColumns.map((doc) => {
+ const isSubmitted = registration.documentSubmissions?.[
+ doc.key as keyof typeof registration.documentSubmissions
+ ] as boolean || false;
+
+ // 내자인 경우 통장사본은 표시하지 않음
+ const isForeign = registration.country !== 'KR';
+ if (doc.key === 'bankCopy' && !isForeign) {
+ return null;
+ }
+
+ return (
+ <div
+ key={doc.key}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isSubmitted} />
+ {doc.label}
+ {doc.key === 'bankCopy' && isForeign && (
+ <span className="text-xs text-blue-600">(외자 필수)</span>
+ )}
+ </div>
+ <div>
+ <StatusBadge status={isSubmitted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isSubmitted ? "2024.01.01" : "-"}
+ </div>
+ <div>
+ {isSubmitted && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleFileDownload(doc.key)}
+ >
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 계약 동의 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">계약 동의 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>계약유형</div>
+ <div>상태</div>
+ <div>서약일자</div>
+ <div>액션</div>
+ </div>
+ {!registration.basicContracts || registration.basicContracts.length === 0 ? (
+ <div className="p-4 border-t text-center text-gray-500">
+ 요청된 기본계약이 없습니다.
+ </div>
+ ) : (
+ registration.basicContracts.map((contract, index) => {
+ const isCompleted = contract.status === "VENDOR_SIGNED" || contract.status === "COMPLETED";
+
+ return (
+ <div
+ key={`${contract.templateId}-${index}`}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isCompleted} />
+ {contract.templateName || "템플릿명 없음"}
+ </div>
+ <div>
+ <StatusBadge status={isCompleted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isCompleted && contract.createdAt
+ ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt))
+ : "-"
+ }
+ </div>
+ <div>
+ {isCompleted && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleContractDownload(index)}
+ >
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+
+ {/* 안전적격성 평가 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">안전적격성 평가</h3>
+ <div className="p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <StatusIcon status={!!registration.safetyQualificationContent} />
+ <span>안전적격성 평가</span>
+ </div>
+ <StatusBadge status={!!registration.safetyQualificationContent} />
+ </div>
+ {registration.safetyQualificationContent && (
+ <div className="mt-3 p-3 bg-gray-50 rounded">
+ <p className="text-sm">{registration.safetyQualificationContent}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 추가 정보 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
+ <div className="p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <StatusIcon status={registration.additionalInfo} />
+ <span>추가 정보 등록</span>
+ </div>
+ <StatusBadge status={registration.additionalInfo} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/vendor-regular-registrations/registration-request-dialog.tsx b/components/vendor-regular-registrations/registration-request-dialog.tsx
index 99599ce5..d3aeb812 100644
--- a/components/vendor-regular-registrations/registration-request-dialog.tsx
+++ b/components/vendor-regular-registrations/registration-request-dialog.tsx
@@ -313,6 +313,16 @@ export function RegistrationRequestDialog({
return;
}
+ // 업무담당자 검증 (최소 하나의 담당자라도 이름과 이메일이 있어야 함)
+ const hasValidBusinessContact = Object.values(formData.businessContacts).some(contact =>
+ contact.name?.trim() && contact.email?.trim()
+ );
+
+ if (!hasValidBusinessContact) {
+ toast.error("업무담당자 정보를 최소 하나 이상 입력해주세요. (담당자명과 이메일 필수)");
+ return;
+ }
+
if (onSubmit) {
await onSubmit(formData);
}
@@ -599,7 +609,8 @@ export function RegistrationRequestDialog({
{/* 업무담당자 */}
<div>
- <h4 className="font-semibold mb-3">업무담당자</h4>
+ <h4 className="font-semibold mb-3">업무담당자 <span className="text-red-500">*</span></h4>
+ <p className="text-sm text-muted-foreground mb-4">최소 하나의 업무담당자 정보를 입력해주세요.</p>
<div className="space-y-4">
{Object.entries(formData.businessContacts).map(([type, contact]) => {
const labels = {
@@ -615,7 +626,7 @@ export function RegistrationRequestDialog({
<h5 className="font-medium text-sm">{labels[type as keyof typeof labels]} 담당자</h5>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
- <Label>담당자명</Label>
+ <Label>담당자명 <span className="text-red-500">*</span></Label>
<Input
value={contact.name}
onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'name', e.target.value)}
@@ -646,7 +657,7 @@ export function RegistrationRequestDialog({
/>
</div>
<div>
- <Label>이메일</Label>
+ <Label>이메일 <span className="text-red-500">*</span></Label>
<Input
type="email"
value={contact.email}