summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/additional-info/join-form.tsx60
-rw-r--r--components/pq-input/pq-input-tabs.tsx90
-rw-r--r--components/pq-input/pq-review-wrapper.tsx404
-rw-r--r--components/signup/join-form.tsx201
-rw-r--r--components/vendor-regular-registrations/document-status-dialog.tsx19
5 files changed, 523 insertions, 251 deletions
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx
index afe38841..1642962f 100644
--- a/components/additional-info/join-form.tsx
+++ b/components/additional-info/join-form.tsx
@@ -223,6 +223,8 @@ export function InfoForm() {
})
const isFormValid = form.formState.isValid
+ const watchedCountry = form.watch("country")
+ const isDomesticVendor = watchedCountry === "KR"
// Field array for contacts
const { fields: contactFields, append: addContact, remove: removeContact, replace: replaceContacts } =
@@ -369,6 +371,29 @@ export function InfoForm() {
fetchVendorData()
}, [companyId, form, replaceContacts])
+ // 도로명주소 검색 결과 수신 (내자 벤더만)
+ React.useEffect(() => {
+ if (!isDomesticVendor) return
+
+ const handleMessage = (event: MessageEvent) => {
+ if (!event.data || event.data.type !== "JUSO_SELECTED") return
+ const { zipNo, roadAddrPart1, roadAddrPart2, addrDetail } = event.data.payload || {}
+ const road = [roadAddrPart1, roadAddrPart2].filter(Boolean).join(" ").trim()
+
+ form.setValue("postalCode", zipNo || form.getValues("postalCode") || "", { shouldDirty: true })
+ form.setValue("address", road || form.getValues("address") || "", { shouldDirty: true })
+ form.setValue("addressDetail", addrDetail || form.getValues("addressDetail") || "", { shouldDirty: true })
+ }
+
+ window.addEventListener("message", handleMessage)
+ return () => window.removeEventListener("message", handleMessage)
+ }, [isDomesticVendor, form])
+
+ const handleJusoSearch = () => {
+ if (!isDomesticVendor) return
+ window.open("/api/juso", "jusoSearch", "width=570,height=420,scrollbars=yes,resizable=yes")
+ }
+
// 컴포넌트 언마운트 시 미리보기 URL 정리 (blob URL만)
React.useEffect(() => {
return () => {
@@ -1224,11 +1249,29 @@ export function InfoForm() {
name="address"
render={({ field }) => (
<FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 주소
- </FormLabel>
+ <div className="flex items-center justify-between gap-2">
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 주소
+ </FormLabel>
+ {isDomesticVendor && (
+ <Button
+ type="button"
+ variant="secondary"
+ size="sm"
+ onClick={handleJusoSearch}
+ disabled={isSubmitting}
+ >
+ 주소 검색
+ </Button>
+ )}
+ </div>
<FormControl>
- <Input {...field} disabled={isSubmitting} />
+ <Input
+ {...field}
+ disabled={isSubmitting}
+ readOnly={isDomesticVendor}
+ className={cn(isDomesticVendor && "bg-muted text-muted-foreground")}
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -1258,7 +1301,13 @@ export function InfoForm() {
<FormItem>
<FormLabel>우편번호</FormLabel>
<FormControl>
- <Input {...field} disabled={isSubmitting} placeholder="우편번호를 입력해주세요" />
+ <Input
+ {...field}
+ disabled={isSubmitting}
+ readOnly={isDomesticVendor}
+ className={cn(isDomesticVendor && "bg-muted text-muted-foreground")}
+ placeholder="우편번호를 입력해주세요"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -1960,7 +2009,6 @@ export function InfoForm() {
assignedDepartment: registrationData.registration?.assignedDepartment,
assignedUser: registrationData.registration?.assignedUser,
remarks: registrationData.registration?.remarks,
- safetyQualificationContent: registrationData.registration?.safetyQualificationContent || null,
gtcSkipped: registrationData.registration?.gtcSkipped || false,
additionalInfo: registrationData.additionalInfo,
documentSubmissions: registrationData.documentStatus,
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index df911d5e..6c9a1254 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -152,6 +152,7 @@ export function PQInputTabs({
projectData,
isReadOnly = false,
currentPQ, // 추가: 현재 PQ Submission 정보
+ vendorCountry,
}: {
data: PQGroupData[]
vendorId: number
@@ -163,6 +164,7 @@ export function PQInputTabs({
status: string;
type: string;
} | null
+ vendorCountry?: string | null
}) {
const [isSaving, setIsSaving] = React.useState(false)
@@ -208,6 +210,33 @@ export function PQInputTabs({
})
}
+ // 벤더 내자/외자 판별 (국가 코드 기반)
+ const isDomesticVendor = React.useMemo(() => {
+ if (!vendorCountry) return null; // null 이면 필터 미적용
+ return vendorCountry === "KR" || vendorCountry === "한국";
+ }, [vendorCountry]);
+
+ // 벤더 유형에 따라 PQ 항목 필터링
+ const filteredData: PQGroupData[] = React.useMemo(() => {
+ // 벤더 타입 정보가 없으면 전체 노출
+ if (isDomesticVendor === null) return data;
+
+ const filterItemByType = (item: any) => {
+ const itemType = item.type || "내외자";
+ if (itemType === "내외자") return true;
+ if (itemType === "내자") return isDomesticVendor === true;
+ if (itemType === "외자") return isDomesticVendor === false;
+ return true;
+ };
+
+ return data
+ .map((group) => ({
+ ...group,
+ items: group.items.filter(filterItemByType),
+ }))
+ .filter((group) => group.items.length > 0);
+ }, [data, isDomesticVendor]);
+
// 필터링 함수
const shouldShowItem = (isSaved: boolean) => {
if (filterOptions.showAll) return true;
@@ -223,7 +252,7 @@ export function PQInputTabs({
function createInitialFormValues(): PQFormValues {
const answers: PQFormValues["answers"] = []
- data.forEach((group) => {
+ filteredData.forEach((group) => {
// 그룹 내 아이템들을 코드 순서로 정렬
const sortedItems = sortByCode(group.items)
@@ -383,7 +412,7 @@ export function PQInputTabs({
try {
const answerData = form.getValues(`answers.${answerIndex}`)
const criteriaId = answerData.criteriaId
- const item = data.flatMap(group => group.items).find(item => item.criteriaId === criteriaId)
+ const item = filteredData.flatMap(group => group.items).find(item => item.criteriaId === criteriaId)
const inputFormat = item?.inputFormat || "TEXT"
// Validation
// 모든 항목은 필수로 처리 (isRequired 제거됨)
@@ -723,7 +752,14 @@ export function PQInputTabs({
{/* 프로젝트 정보 섹션 */}
{renderProjectInfo()}
- <Tabs defaultValue={data[0]?.groupName || ""} className="w-full">
+ {filteredData.length === 0 ? (
+ <div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
+ 표시할 PQ 항목이 없습니다. (벤더 내/외자 구분 필터 적용)
+ </div>
+ ) : (
+ <>
+
+ <Tabs defaultValue={filteredData[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 */}
@@ -810,7 +846,7 @@ export function PQInputTabs({
<div className="flex justify-between items-center">
<TabsList className="grid grid-cols-4">
- {data.map((group) => {
+ {filteredData.map((group) => {
const colorClasses = getTabColorClasses(group.groupName)
return (
<TabsTrigger
@@ -880,7 +916,7 @@ export function PQInputTabs({
</div>
{/* Render each group */}
- {data.map((group) => (
+ {filteredData.map((group) => (
<TabsContent key={group.groupName} value={group.groupName}>
{/* 2-column grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 pb-4">
@@ -958,6 +994,46 @@ export function PQInputTabs({
</CardHeader>
<CardContent className="pt-3 space-y-3 h-full flex flex-col">
+ {/* 기준 첨부 파일 */}
+ {item.criteriaAttachments && item.criteriaAttachments.length > 0 && (
+ <div className="space-y-2">
+ <FormLabel>기준 첨부파일</FormLabel>
+ <FileList>
+ {item.criteriaAttachments.map((file, idx) => (
+ <FileListItem key={idx}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.fileName}</FileListName>
+ {file.fileSize && (
+ <FileListDescription>{prettyBytes(file.fileSize)}</FileListDescription>
+ )}
+ </FileListInfo>
+ <FileListAction
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(file.filePath, file.fileName, { showToast: true })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast({
+ title: "다운로드 실패",
+ description: "파일 다운로드 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ }
+ }}
+ >
+ <Download className="h-4 w-4" />
+ <span className="sr-only">Download</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
{/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */}
{projectId && contractInfo && (
<div className="space-y-1">
@@ -1379,6 +1455,8 @@ export function PQInputTabs({
</TabsContent>
))}
</Tabs>
+ </>
+ )}
</form>
{/* Confirmation Dialog */}
@@ -1395,7 +1473,7 @@ export function PQInputTabs({
</DialogHeader>
<div className="space-y-4 max-h-[600px] overflow-y-auto ">
- {data.map((group, groupIndex) => (
+ {filteredData.map((group, groupIndex) => (
<div key={groupIndex}>
{group.items.map((item) => {
const answerObj = form
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx
index 1e172744..ac9629cb 100644
--- a/components/pq-input/pq-review-wrapper.tsx
+++ b/components/pq-input/pq-review-wrapper.tsx
@@ -21,9 +21,10 @@ import {
DialogTitle
} from "@/components/ui/dialog"
import { useToast } from "@/hooks/use-toast"
-import { CheckCircle, AlertCircle, Paperclip, Square } from "lucide-react"
+import { CheckCircle, AlertCircle, Paperclip, Square, Download } from "lucide-react"
import { PQGroupData } from "@/lib/pq/service"
-import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction, requestPqSupplementAction } from "@/lib/pq/service"
+import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction, requestPqSupplementAction, approveSafetyPQAction, rejectSafetyPQAction } from "@/lib/pq/service"
+import { FileList, FileListHeader, FileListInfo, FileListItem, FileListName, FileListDescription, FileListAction, FileListIcon } from "@/components/ui/file-list"
// import * as ExcelJS from 'exceljs';
// import { saveAs } from "file-saver";
@@ -49,25 +50,32 @@ interface PQReviewWrapperProps {
vendorId: number
pqSubmission: PQSubmission
vendorInfo?: any // 협력업체 정보 (선택사항)
+ vendorCountry?: string | null
}
export function PQReviewWrapper({
pqData,
vendorId,
pqSubmission,
- vendorInfo
+ vendorInfo,
+ vendorCountry,
}: PQReviewWrapperProps) {
const router = useRouter()
const { toast } = useToast()
+ const [isSafetyApproving, setIsSafetyApproving] = React.useState(false)
+ const [isSafetyRejecting, setIsSafetyRejecting] = React.useState(false)
const [isApproving, setIsApproving] = React.useState(false)
const [isRejecting, setIsRejecting] = React.useState(false)
const [isQMApproving, setIsQMApproving] = React.useState(false)
const [isQMRejecting, setIsQMRejecting] = React.useState(false)
+ const [showSafetyApproveDialog, setShowSafetyApproveDialog] = React.useState(false)
+ const [showSafetyRejectDialog, setShowSafetyRejectDialog] = React.useState(false)
const [showApproveDialog, setShowApproveDialog] = React.useState(false)
const [showRejectDialog, setShowRejectDialog] = React.useState(false)
const [showQMApproveDialog, setShowQMApproveDialog] = React.useState(false)
const [showQMRejectDialog, setShowQMRejectDialog] = React.useState(false)
const [showSupplementDialog, setShowSupplementDialog] = React.useState(false)
+ const [safetyRejectReason, setSafetyRejectReason] = React.useState("")
const [rejectReason, setRejectReason] = React.useState("")
const [qmRejectReason, setQmRejectReason] = React.useState("")
const [supplementComment, setSupplementComment] = React.useState("")
@@ -96,6 +104,32 @@ export function PQReviewWrapper({
return 0
})
}
+
+ // 벤더 내자/외자 판별 (국가 코드 기반)
+ const isDomesticVendor = React.useMemo(() => {
+ if (!vendorCountry) return null; // 정보 없으면 필터 미적용
+ return vendorCountry === "KR" || vendorCountry === "한국";
+ }, [vendorCountry]);
+
+ // 벤더 유형에 따라 PQ 항목 필터링
+ const filteredData: PQGroupData[] = React.useMemo(() => {
+ if (isDomesticVendor === null) return pqData;
+
+ const filterItemByType = (item: any) => {
+ const itemType = item.type || "내외자";
+ if (itemType === "내외자") return true;
+ if (itemType === "내자") return isDomesticVendor === true;
+ if (itemType === "외자") return isDomesticVendor === false;
+ return true;
+ };
+
+ return pqData
+ .map((group) => ({
+ ...group,
+ items: group.items.filter(filterItemByType),
+ }))
+ .filter((group) => group.items.length > 0);
+ }, [pqData, isDomesticVendor]);
// 기존 SHI 코멘트를 로컬 상태에 초기화
@@ -111,6 +145,87 @@ export function PQReviewWrapper({
setShiComments(initialComments)
}, [pqData])
+ // 안전 PQ 승인 처리
+ const handleSafetyApprove = async () => {
+ try {
+ setIsSafetyApproving(true)
+ const result = await approveSafetyPQAction({
+ pqSubmissionId: pqSubmission.id,
+ vendorId: vendorId,
+ })
+
+ if (result.ok) {
+ toast({
+ title: "안전 PQ 승인 완료",
+ description: "안전 검토가 승인되었습니다.",
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: "안전 승인 실패",
+ description: result.error || "안전 PQ 승인 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("안전 PQ 승인 오류:", error)
+ toast({
+ title: "안전 승인 실패",
+ description: "안전 PQ 승인 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSafetyApproving(false)
+ setShowSafetyApproveDialog(false)
+ }
+ }
+
+ // 안전 PQ 거절 처리
+ const handleSafetyReject = async () => {
+ if (!safetyRejectReason.trim()) {
+ toast({
+ title: "거절 사유 필요",
+ description: "안전 거절 사유를 입력해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ setIsSafetyRejecting(true)
+ const result = await rejectSafetyPQAction({
+ pqSubmissionId: pqSubmission.id,
+ vendorId: vendorId,
+ rejectReason: safetyRejectReason,
+ })
+
+ if (result.ok) {
+ toast({
+ title: "안전 PQ 거절 완료",
+ description: "안전 검토에서 거절되었습니다.",
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: "안전 거절 실패",
+ description: result.error || "안전 PQ 거절 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("안전 PQ 거절 오류:", error)
+ toast({
+ title: "안전 거절 실패",
+ description: "안전 PQ 거절 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setSafetyRejectReason("")
+ setIsSafetyRejecting(false)
+ setShowSafetyRejectDialog(false)
+ }
+ }
+
// PQ 승인 처리
const handleApprove = async () => {
try {
@@ -298,141 +413,6 @@ export function PQReviewWrapper({
}
}
- // // Excel export 처리
- // const handleExportToExcel = async () => {
- // try {
- // setIsExporting(true)
-
- // // 워크북 생성
- // const workbook = new ExcelJS.Workbook()
- // workbook.creator = 'PQ Management System'
- // workbook.created = new Date()
-
- // // 메인 시트 생성
- // const worksheet = workbook.addWorksheet("PQ 항목")
-
- // // 헤더 정의
- // const headers = [
- // "그룹명",
- // "코드",
- // "체크포인트",
- // "설명",
- // "입력형식",
- // "필수여부",
- // "벤더답변",
- // "SHI 코멘트",
- // "벤더 답변",
- // ]
-
- // // 헤더 추가
- // worksheet.addRow(headers)
-
- // // 헤더 스타일 적용
- // const headerRow = worksheet.getRow(1)
- // headerRow.font = { bold: true }
- // headerRow.fill = {
- // type: 'pattern',
- // pattern: 'solid',
- // fgColor: { argb: 'FFE0E0E0' }
- // }
- // headerRow.alignment = { vertical: 'middle', horizontal: 'center' }
-
- // // 컬럼 너비 설정
- // worksheet.columns = [
- // { header: "그룹명", key: "groupName", width: 15 },
- // { header: "코드", key: "code", width: 12 },
- // { header: "체크포인트", key: "checkPoint", width: 30 },
- // { header: "설명", key: "description", width: 40 },
- // { header: "입력형식", key: "inputFormat", width: 12 },
-
- // { header: "벤더답변", key: "answer", width: 30 },
- // { header: "SHI 코멘트", key: "shiComment", width: 30 },
- // { header: "벤더 답변", key: "vendorReply", width: 30 },
- // ]
-
- // // 데이터 추가
- // pqData.forEach(group => {
- // group.items.forEach(item => {
- // const rowData = [
- // group.groupName,
- // item.code,
- // item.checkPoint,
- // item.description || "",
- // item.inputFormat || "",
-
- // item.answer || "",
- // item.shiComment || "",
- // item.vendorReply || "",
- // ]
- // worksheet.addRow(rowData)
- // })
- // })
-
- // // 전체 셀에 테두리 추가
- // worksheet.eachRow((row, rowNumber) => {
- // row.eachCell((cell) => {
- // cell.border = {
- // top: { style: 'thin' },
- // left: { style: 'thin' },
- // bottom: { style: 'thin' },
- // right: { style: 'thin' }
- // }
- // // 긴 텍스트는 자동 줄바꿈
- // cell.alignment = {
- // vertical: 'top',
- // horizontal: 'left',
- // wrapText: true
- // }
- // })
- // })
-
- // // 정보 시트 생성
- // const infoSheet = workbook.addWorksheet("정보")
- // infoSheet.addRow(["벤더명", pqSubmission.vendorName])
- // if (pqSubmission.projectName) {
- // infoSheet.addRow(["프로젝트명", pqSubmission.projectName])
- // }
- // infoSheet.addRow(["생성일", new Date().toLocaleDateString('ko-KR')])
- // infoSheet.addRow(["총 항목 수", pqData.reduce((total, group) => total + group.items.length, 0)])
-
- // // 정보 시트 스타일링
- // infoSheet.columns = [
- // { header: "항목", key: "item", width: 20 },
- // { header: "값", key: "value", width: 40 }
- // ]
-
- // const infoHeaderRow = infoSheet.getRow(1)
- // infoHeaderRow.font = { bold: true }
- // infoHeaderRow.fill = {
- // type: 'pattern',
- // pattern: 'solid',
- // fgColor: { argb: 'FFE6F3FF' }
- // }
-
- // // 파일명 생성
- // const defaultFilename = pqSubmission.projectName
- // ? `${pqSubmission.vendorName}_${pqSubmission.projectName}_PQ_${new Date().toISOString().slice(0, 10)}`
- // : `${pqSubmission.vendorName}_PQ_${new Date().toISOString().slice(0, 10)}`
- // const finalFilename = defaultFilename
-
- // // 파일 다운로드
- // const buffer = await workbook.xlsx.writeBuffer()
- // const blob = new Blob([buffer], {
- // type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
- // })
- // saveAs(blob, `${finalFilename}.xlsx`)
- // } catch (error) {
- // console.error("Excel export 오류:", error)
- // toast({
- // title: "내보내기 실패",
- // description: "Excel 내보내기 중 오류가 발생했습니다.",
- // variant: "destructive"
- // })
- // } finally {
- // setIsExporting(false)
- // }
- // }
-
// PQ 거부 처리
const handleReject = async () => {
if (!rejectReason.trim()) {
@@ -482,8 +462,14 @@ export function PQReviewWrapper({
return (
<div className="space-y-6">
+ {filteredData.length === 0 && (
+ <div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
+ 표시할 PQ 항목이 없습니다. (벤더 내/외자 구분 필터 적용)
+ </div>
+ )}
+
{/* 그룹별 PQ 항목 표시 */}
- {pqData.map((group) => (
+ {filteredData.map((group) => (
<div key={group.groupName} className="space-y-4">
<h3 className="text-lg font-medium">{group.groupName}</h3>
@@ -530,6 +516,43 @@ export function PQReviewWrapper({
</div>
</CardHeader>
<CardContent className="space-y-4">
+ {item.criteriaAttachments && item.criteriaAttachments.length > 0 && (
+ <div className="space-y-2">
+ <p className="text-sm font-medium">기준 첨부파일</p>
+ <FileList>
+ {item.criteriaAttachments.map((file) => (
+ <FileListItem key={file.attachId}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.fileName}</FileListName>
+ {file.fileSize && (
+ <FileListDescription>{file.fileSize} bytes</FileListDescription>
+ )}
+ </FileListInfo>
+ <FileListAction
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(file.filePath, file.fileName, { showToast: true })
+ } catch (error) {
+ toast({
+ title: "다운로드 실패",
+ description: "파일 다운로드 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ }
+ }}
+ >
+ <Download className="h-4 w-4" />
+ <span className="sr-only">Download</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
{/* 프로젝트별 추가 정보 */}
{pqSubmission.projectId && item.contractInfo && (
<div className="space-y-1">
@@ -767,15 +790,42 @@ export function PQReviewWrapper({
{/* 검토 버튼 - 상태에 따라 다른 버튼 표시 */}
<div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border">
<div className="flex gap-2">
- {/* SUBMITTED 상태: 구매 담당자 승인/거절 */}
+ {/* SUBMITTED 상태: 안전 담당자 승인/거절 */}
{pqSubmission.status === "SUBMITTED" && (
<>
<Button
variant="outline"
+ onClick={() => setShowSafetyRejectDialog(true)}
+ disabled={isSafetyRejecting}
+ >
+ {isSafetyRejecting ? "안전 거절 중..." : "안전 거절"}
+ </Button>
+ <Button
+ variant="secondary"
+ onClick={() => setShowSupplementDialog(true)}
+ disabled={isSendingSupplement}
+ >
+ 보완요청
+ </Button>
+ <Button
+ variant="default"
+ onClick={() => setShowSafetyApproveDialog(true)}
+ disabled={isSafetyApproving}
+ >
+ {isSafetyApproving ? "안전 승인 중..." : "안전 승인"}
+ </Button>
+ </>
+ )}
+
+ {/* SAFETY_APPROVED 상태: 구매 담당자 승인/거절 */}
+ {pqSubmission.status === "SAFETY_APPROVED" && (
+ <>
+ <Button
+ variant="outline"
onClick={() => setShowRejectDialog(true)}
disabled={isRejecting}
>
- {isRejecting ? "거부 중..." : "거부"}
+ {isRejecting ? "거부 중..." : "구매 거부"}
</Button>
<Button
variant="secondary"
@@ -844,10 +894,82 @@ export function PQReviewWrapper({
<span>거절됨</span>
</div>
)}
+
+ {/* SAFETY_REJECTED 상태: 안전 거절 표시 */}
+ {pqSubmission.status === "SAFETY_REJECTED" && (
+ <div className="flex items-center gap-2 text-red-600">
+ <AlertCircle className="h-4 w-4" />
+ <span>안전 거절됨</span>
+ </div>
+ )}
</div>
</div>
+ {/* 안전 승인 확인 다이얼로그 */}
+ <Dialog open={showSafetyApproveDialog} onOpenChange={setShowSafetyApproveDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>안전 PQ 승인 확인</DialogTitle>
+ <DialogDescription>
+ {pqSubmission.vendorName || "알 수 없는 업체"}의 {
+ pqSubmission.type === "GENERAL" ? "일반" :
+ pqSubmission.type === "PROJECT" ? "프로젝트" :
+ pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반"
+ } PQ를 안전 검토에서 승인하시겠습니까?
+ {pqSubmission.projectId && (
+ <span> 프로젝트: {pqSubmission.projectName}</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShowSafetyApproveDialog(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleSafetyApprove} disabled={isSafetyApproving}>
+ {isSafetyApproving ? "안전 승인 중..." : "안전 승인"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 안전 거부 확인 다이얼로그 */}
+ <Dialog open={showSafetyRejectDialog} onOpenChange={setShowSafetyRejectDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>안전 PQ 거부</DialogTitle>
+ <DialogDescription>
+ {pqSubmission.vendorName || "알 수 없는 업체"}의 {
+ pqSubmission.type === "GENERAL" ? "일반" :
+ pqSubmission.type === "PROJECT" ? "프로젝트" :
+ pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반"
+ } PQ를 안전 검토에서 거부하는 이유를 입력해주세요.
+ {pqSubmission.projectId && (
+ <span> 프로젝트: {pqSubmission.projectName}</span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <Textarea
+ value={safetyRejectReason}
+ onChange={(e) => setSafetyRejectReason(e.target.value)}
+ placeholder="안전 거부 사유를 입력하세요"
+ className="min-h-24"
+ />
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShowSafetyRejectDialog(false)}>
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleSafetyReject}
+ disabled={isSafetyRejecting || !safetyRejectReason.trim()}
+ >
+ {isSafetyRejecting ? "안전 거절 중..." : "안전 거절"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
{/* 승인 확인 다이얼로그 */}
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
<DialogContent>
diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx
index 6885279a..973fb00c 100644
--- a/components/signup/join-form.tsx
+++ b/components/signup/join-form.tsx
@@ -910,6 +910,32 @@ function CompleteVendorForm({
}: VendorStepProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
+ const effectiveCountry = data.country || accountData.country || "";
+ const isKR = effectiveCountry === "KR";
+
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ if (!event.data || event.data.type !== "JUSO_SELECTED") return;
+ const { zipNo, roadAddrPart1, roadAddrPart2, addrDetail } = event.data.payload || {};
+ const combinedAddress = [roadAddrPart1, roadAddrPart2].filter(Boolean).join(" ").trim();
+ onChange(prev => ({
+ ...prev,
+ postalCode: zipNo || prev.postalCode,
+ address: combinedAddress || prev.address,
+ addressDetail: addrDetail || prev.addressDetail,
+ }));
+ };
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, [onChange]);
+
+ const handleJusoSearch = () => {
+ window.open(
+ "/api/juso",
+ "jusoSearch",
+ "width=570,height=420,scrollbars=yes,resizable=yes"
+ );
+ };
// 담당자 관리 함수들
const addContact = () => {
@@ -1259,13 +1285,28 @@ function CompleteVendorForm({
{/* 주소 */}
<div>
- <label className="block text-sm font-medium mb-1">
- {t('address')} <span className="text-red-500">*</span>
- </label>
+ <div className="flex items-center justify-between gap-2 mb-1">
+ <label className="block text-sm font-medium">
+ {t('address')} <span className="text-red-500">*</span>
+ </label>
+ {isKR && (
+ <Button
+ type="button"
+ variant="secondary"
+ size="sm"
+ onClick={handleJusoSearch}
+ disabled={isSubmitting}
+ >
+ 주소 검색
+ </Button>
+ )}
+ </div>
<Input
value={data.address}
onChange={(e) => handleInputChange('address', e.target.value)}
disabled={isSubmitting}
+ readOnly={isKR}
+ className={cn(isKR && "bg-muted text-muted-foreground")}
/>
</div>
@@ -1291,6 +1332,8 @@ function CompleteVendorForm({
value={data.postalCode}
onChange={(e) => handleInputChange('postalCode', e.target.value)}
disabled={isSubmitting}
+ readOnly={isKR}
+ className={cn(isKR && "bg-muted text-muted-foreground")}
placeholder="우편번호를 입력해주세요"
/>
</div>
@@ -1388,6 +1431,82 @@ function CompleteVendorForm({
</div>
</div>
+ {/* 한국 사업자 정보 */}
+ {data.country === "KR" && (
+ <div className="rounded-md border p-6 space-y-4">
+ <h4 className="text-md font-semibold">{t('koreanBusinessInfo')}</h4>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ {t('name')} <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.representativeName}
+ onChange={(e) => handleInputChange('representativeName', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ {t('representativeBirth')} <span className="text-red-500">*</span>
+ </label>
+ <Input
+ placeholder="YYYY-MM-DD"
+ value={data.representativeBirth}
+ onChange={(e) => handleInputChange('representativeBirth', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ {t('representativeEmail')} <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.representativeEmail}
+ onChange={(e) => handleInputChange('representativeEmail', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ {t('')} <span className="text-red-500">*</span>
+ </label>
+ <PhoneInput
+ value={data.representativePhone}
+ onChange={(value) => handleInputChange('representativePhone', value)}
+ countryCode="KR"
+ disabled={isSubmitting}
+ showValidation={true}
+ t={t}
+ />
+ </div>
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ {t('corporateRegistrationNumber')}
+ </label>
+ <Input
+ value={data.corporateRegistrationNumber}
+ onChange={(e) => handleInputChange('corporateRegistrationNumber', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="work-experience"
+ checked={data.representativeWorkExpirence}
+ onChange={(e) => handleInputChange('representativeWorkExpirence', e.target.checked)}
+ disabled={isSubmitting}
+ className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
+ />
+ <label htmlFor="work-experience" className="text-sm">
+ {t('samsungWorkExperience')}
+ </label>
+ </div>
+ </div>
+ </div>
+ )}
+
{/* 담당자 정보 */}
<div className="rounded-md border p-6 space-y-4">
<div className="flex items-center justify-between">
@@ -1508,82 +1627,6 @@ function CompleteVendorForm({
</div>
</div>
- {/* 한국 사업자 정보 */}
- {data.country === "KR" && (
- <div className="rounded-md border p-6 space-y-4">
- <h4 className="text-md font-semibold">{t('koreanBusinessInfo')}</h4>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div>
- <label className="block text-sm font-medium mb-1">
- {t('representativeName')} <span className="text-red-500">*</span>
- </label>
- <Input
- value={data.representativeName}
- onChange={(e) => handleInputChange('representativeName', e.target.value)}
- disabled={isSubmitting}
- />
- </div>
- <div>
- <label className="block text-sm font-medium mb-1">
- {t('representativeBirth')} <span className="text-red-500">*</span>
- </label>
- <Input
- placeholder="YYYY-MM-DD"
- value={data.representativeBirth}
- onChange={(e) => handleInputChange('representativeBirth', e.target.value)}
- disabled={isSubmitting}
- />
- </div>
- <div>
- <label className="block text-sm font-medium mb-1">
- {t('representativeEmail')} <span className="text-red-500">*</span>
- </label>
- <Input
- value={data.representativeEmail}
- onChange={(e) => handleInputChange('representativeEmail', e.target.value)}
- disabled={isSubmitting}
- />
- </div>
- <div>
- <label className="block text-sm font-medium mb-1">
- {t('representativePhone')} <span className="text-red-500">*</span>
- </label>
- <PhoneInput
- value={data.representativePhone}
- onChange={(value) => handleInputChange('representativePhone', value)}
- countryCode="KR"
- disabled={isSubmitting}
- showValidation={true}
- t={t}
- />
- </div>
- <div>
- <label className="block text-sm font-medium mb-1">
- {t('corporateRegistrationNumber')}
- </label>
- <Input
- value={data.corporateRegistrationNumber}
- onChange={(e) => handleInputChange('corporateRegistrationNumber', e.target.value)}
- disabled={isSubmitting}
- />
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="work-experience"
- checked={data.representativeWorkExpirence}
- onChange={(e) => handleInputChange('representativeWorkExpirence', e.target.checked)}
- disabled={isSubmitting}
- className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
- />
- <label htmlFor="work-experience" className="text-sm">
- {t('samsungWorkExperience')}
- </label>
- </div>
- </div>
- </div>
- )}
-
{/* 필수 첨부 서류 */}
<div className="rounded-md border p-6 space-y-6">
<h4 className="text-md font-semibold">{t('requiredDocuments')}</h4>
diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx
index 02da19bf..1bd96ee8 100644
--- a/components/vendor-regular-registrations/document-status-dialog.tsx
+++ b/components/vendor-regular-registrations/document-status-dialog.tsx
@@ -387,25 +387,6 @@ export function DocumentStatusDialog({
</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>