summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-06 04:23:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-06 04:23:40 +0000
commitde2ac5a2860bc25180971e7a11f852d9d44675b7 (patch)
treeb931c363f2cb19e177a0a7b17190d5de2a82d709 /components
parent6c549b0f264e9be4d60af38f9efc05b189d6849f (diff)
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'components')
-rw-r--r--components/data-table/data-table-filter-list.tsx44
-rw-r--r--components/data-table/data-table-grobal-filter.tsx4
-rw-r--r--components/data-table/data-table-view-options.tsx12
-rw-r--r--components/data-table/data-table.tsx7
-rw-r--r--components/layout/Header.tsx13
-rw-r--r--components/login/login-form.tsx4
-rw-r--r--components/polices/policy-editor.tsx262
-rw-r--r--components/polices/policy-history.tsx250
-rw-r--r--components/polices/policy-management-client.tsx429
-rw-r--r--components/polices/policy-preview.tsx191
-rw-r--r--components/qna/tiptap-editor.tsx98
-rw-r--r--components/signup/conset-step.tsx415
-rw-r--r--components/signup/join-form.tsx2021
13 files changed, 2752 insertions, 998 deletions
diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx
index 6088e912..ea4b1f90 100644
--- a/components/data-table/data-table-filter-list.tsx
+++ b/components/data-table/data-table-filter-list.tsx
@@ -66,6 +66,7 @@ import {
} from "@/components/ui/sortable"
import { useParams } from 'next/navigation';
import { useTranslation } from '@/i18n/client'
+import deepEqual from "fast-deep-equal"
interface DataTableFilterListProps<TData> {
table: Table<TData>
@@ -78,6 +79,10 @@ interface DataTableFilterListProps<TData> {
onFiltersChange?: (filters: Filter<TData>[], joinOperator: JoinOperator) => void
}
+export function isSame(a: unknown, b: unknown) {
+ return JSON.stringify(a) === JSON.stringify(b)
+}
+
export function DataTableFilterList<TData>({
table,
filterFields,
@@ -88,6 +93,11 @@ export function DataTableFilterList<TData>({
onFiltersChange,
}: DataTableFilterListProps<TData>) {
+ const prevRef = React.useRef<{
+ filters: Filter<TData>[]
+ join: JoinOperator
+ } | null>(null)
+
const params = useParams();
const lng = params ? (params.lng as string) : 'en';
@@ -114,13 +124,25 @@ export function DataTableFilterList<TData>({
})
)
+ const safeSetFilters = React.useCallback(
+ (next: Filter<TData>[] | ((p: Filter<TData>[]) => Filter<TData>[])) => {
+ setFilters((prev) => {
+ const value = typeof next === "function" ? next(prev) : next
+ return deepEqual(prev, value) ? prev : value // <─ 달라진 게 없으면 그대로
+ })
+ },
+ [setFilters]
+ )
+
+
+
// ✅ 외부 필터가 전달되면 URL 상태를 업데이트
React.useEffect(() => {
- if (externalFilters && externalFilters.length > 0) {
+ if (externalFilters && !deepEqual(externalFilters, filters)) {
console.log("=== 외부 필터 적용 ===", externalFilters);
- setFilters(externalFilters);
+ safeSetFilters(externalFilters);
}
- }, [externalFilters, setFilters]);
+ }, [externalFilters, setFilters, safeSetFilters]);
React.useEffect(() => {
if (externalJoinOperator) {
@@ -130,12 +152,20 @@ export function DataTableFilterList<TData>({
}, [externalJoinOperator, setJoinOperator]);
// ✅ 필터 변경 시 부모에게 알림
+
React.useEffect(() => {
- if (onFiltersChange) {
- onFiltersChange(filters, joinOperator);
+ const prev = prevRef.current
+ const changed =
+ !prev ||
+ !deepEqual(prev.filters, filters) ||
+ prev.join !== joinOperator
+
+ if (changed) {
+ prevRef.current = { filters, join: joinOperator }
+ onFiltersChange?.(filters, joinOperator)
}
- }, [filters, joinOperator, onFiltersChange]);
-
+ }, [filters, joinOperator, onFiltersChange])
+
const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs)
function addFilter() {
diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx
index a1f0a6f3..ca60bf02 100644
--- a/components/data-table/data-table-grobal-filter.tsx
+++ b/components/data-table/data-table-grobal-filter.tsx
@@ -24,8 +24,8 @@ export function DataTableGlobalFilter() {
// Debounced callback that sets the URL param after `delay` ms
const debouncedSetSearch = useDebouncedCallback((value: string) => {
- setSearchValue(value)
- }, 300) // 300ms or chosen delay
+ if (value !== searchValue) setSearchValue(value.trim() === "" ? undefined : value);
+ }, 300)
// When user types, update local `tempValue` immediately,
// then call the debounced function to update the query param
diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx
index 422e3065..b689adab 100644
--- a/components/data-table/data-table-view-options.tsx
+++ b/components/data-table/data-table-view-options.tsx
@@ -39,6 +39,7 @@ import {
} from "@/components/ui/sortable"
import { useTranslation } from '@/i18n/client'
import { useParams, usePathname } from "next/navigation";
+import deepEqual from "fast-deep-equal"
/**
@@ -70,6 +71,7 @@ export function DataTableViewOptions<TData>({
}: DataTableViewOptionsProps<TData>) {
const triggerRef = React.useRef<HTMLButtonElement>(null)
+
const params = useParams();
const lng = params?.lng as string;
const { t } = useTranslation(lng);
@@ -115,11 +117,11 @@ export function DataTableViewOptions<TData>({
const finalOrder = [...nonHideable, ...columnOrder]
// Now we set the table's official column order
- table.setColumnOrder(finalOrder)
-
- // Reset auto-size when column order changes
- resetAutoSize?.()
- }, [columnOrder, hideableCols, table, resetAutoSize])
+ if (!deepEqual(table.getState().columnOrder, finalOrder)) {
+ table.setColumnOrder(finalOrder)
+ resetAutoSize?.()
+ }
+ }, [columnOrder, hideableCols.join("|"), table, resetAutoSize])
return (
diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx
index 33fca5b8..b898c2ea 100644
--- a/components/data-table/data-table.tsx
+++ b/components/data-table/data-table.tsx
@@ -66,9 +66,14 @@ export function DataTable<TData>({
[compact]
);
+ const stableChildren = React.useMemo(() => {
+ console.log("📦 DataTable children 메모이제이션됨");
+ return children;
+ }, [children]);
+
return (
<div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}>
- {children}
+ {stableChildren}
<div className="max-w-[100vw] overflow-auto" style={{ maxHeight: maxHeight || '35rem' }} >
<Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
{/* 테이블 헤더 */}
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx
index 0e9e2abe..68db1426 100644
--- a/components/layout/Header.tsx
+++ b/components/layout/Header.tsx
@@ -23,7 +23,7 @@ import {
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { SearchIcon, BellIcon, Menu } from "lucide-react";
-import { useParams, usePathname } from "next/navigation";
+import { useParams, usePathname, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
import Image from "next/image";
import {
@@ -311,4 +311,13 @@ const ListItem = React.forwardRef<
</li>
);
});
-ListItem.displayName = "ListItem"; \ No newline at end of file
+ListItem.displayName = "ListItem";
+
+export function RouteLogger() {
+ const path = usePathname();
+ const qs = useSearchParams().toString();
+ React.useEffect(() => {
+ console.log("[URL]", path + (qs ? "?" + qs : ""));
+ }, [path, qs]);
+ return null;
+ } \ No newline at end of file
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
index 99708dd6..b850a3d3 100644
--- a/components/login/login-form.tsx
+++ b/components/login/login-form.tsx
@@ -784,4 +784,6 @@ export function LoginForm({
</div>
</div>
)
-} \ No newline at end of file
+}
+
+
diff --git a/components/polices/policy-editor.tsx b/components/polices/policy-editor.tsx
new file mode 100644
index 00000000..d58831e0
--- /dev/null
+++ b/components/polices/policy-editor.tsx
@@ -0,0 +1,262 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Separator } from '@/components/ui/separator'
+import { Save, Eye, X, Info, AlertTriangle } from 'lucide-react'
+import TiptapEditor from '../qna/tiptap-editor'
+
+interface PolicyEditorProps {
+ policyType: string
+ currentPolicy?: any
+ onSave: (policyType: string, version: string, content: string) => void
+ onCancel: () => void
+ onPreview: (policyType: string, content: string, version: string) => void
+ isLoading?: boolean
+}
+
+export function PolicyEditor({
+ policyType,
+ currentPolicy,
+ onSave,
+ onCancel,
+ onPreview,
+ isLoading = false
+}: PolicyEditorProps) {
+ const [version, setVersion] = useState('')
+ const [content, setContent] = useState('')
+ const [validationErrors, setValidationErrors] = useState<string[]>([])
+
+ console.log(content)
+
+ const policyLabels = {
+ privacy_policy: '개인정보 처리방침',
+ terms_of_service: '이용약관'
+ }
+
+ // 현재 정책 기반으로 다음 버전 생성
+ useEffect(() => {
+ if (currentPolicy) {
+ const currentVersion = currentPolicy.version
+ const versionParts = currentVersion.split('.')
+ const majorVersion = parseInt(versionParts[0]) || 1
+ const minorVersion = parseInt(versionParts[1]) || 0
+
+ // 마이너 버전 업
+ const nextVersion = `${majorVersion}.${minorVersion + 1}`
+ setVersion(nextVersion)
+ setContent(currentPolicy.content || '')
+ } else {
+ setVersion('1.0')
+ setContent(getDefaultPolicyContent(policyType))
+ }
+ }, [currentPolicy, policyType])
+
+ const getDefaultPolicyContent = (type: string) => {
+ if (type === 'privacy_policy') {
+ return `<h1>개인정보 처리방침</h1>
+
+<h2>제1조 (목적)</h2>
+<p>본 개인정보 처리방침은 eVCP(이하 "회사")가 개인정보 보호법 등 관련 법령에 따라 정보주체의 개인정보를 보호하고 이와 관련된 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같은 처리방침을 수립·공개합니다.</p>
+
+<h2>제2조 (개인정보의 수집 및 이용목적)</h2>
+<p>회사는 다음의 목적을 위하여 개인정보를 처리합니다:</p>
+<ul>
+ <li>회원 가입 및 관리</li>
+ <li>서비스 제공 및 계약 이행</li>
+ <li>고객 상담 및 불만 처리</li>
+</ul>
+
+<h2>제3조 (개인정보의 수집항목)</h2>
+<p><strong>필수항목:</strong></p>
+<ul>
+ <li>이메일 주소</li>
+ <li>전화번호</li>
+ <li>회사명</li>
+</ul>
+
+<h2>제4조 (개인정보의 보유 및 이용기간)</h2>
+<p>회사는 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.</p>
+
+<h2>제5조 (정보주체의 권리)</h2>
+<p>정보주체는 회사에 대해 언제든지 다음 각 호의 개인정보 보호 관련 권리를 행사할 수 있습니다:</p>
+<ul>
+ <li>개인정보 처리현황 통지요구</li>
+ <li>개인정보 열람요구</li>
+ <li>개인정보 정정·삭제요구</li>
+ <li>개인정보 처리정지요구</li>
+</ul>`
+ } else {
+ return `<h1>이용약관</h1>
+
+<h2>제1조 (목적)</h2>
+<p>본 약관은 eVCP(이하 "회사")가 제공하는 서비스의 이용조건 및 절차, 회사와 회원 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
+
+<h2>제2조 (정의)</h2>
+<ul>
+ <li><strong>"서비스"</strong>란 회사가 제공하는 모든 서비스를 의미합니다.</li>
+ <li><strong>"회원"</strong>이란 본 약관에 동의하고 회사와 서비스 이용계약을 체결한 자를 의미합니다.</li>
+ <li><strong>"업체"</strong>란 회사의 파트너로 등록된 법인 또는 개인사업자를 의미합니다.</li>
+</ul>
+
+<h2>제3조 (약관의 효력 및 변경)</h2>
+<p>본 약관은 서비스를 이용하고자 하는 모든 회원에 대하여 그 효력을 발생합니다.</p>
+
+<h2>제4조 (회원가입)</h2>
+<p>회원가입은 신청자가 본 약관의 내용에 대하여 동의를 한 다음 회원가입신청을 하고 회사가 이러한 신청에 대하여 승낙함으로써 체결됩니다.</p>
+
+<h2>제5조 (서비스의 제공)</h2>
+<p>회사는 회원에게 다음과 같은 서비스를 제공합니다:</p>
+<ul>
+ <li>업체 등록 및 관리 서비스</li>
+ <li>문서 관리 서비스</li>
+ <li>견적 제출 서비스</li>
+</ul>`
+ }
+ }
+
+ const validateForm = () => {
+ const errors: string[] = []
+
+ if (!version.trim()) {
+ errors.push('버전을 입력해주세요.')
+ } else if (!/^\d+\.\d+$/.test(version.trim())) {
+ errors.push('버전은 "1.0" 형식으로 입력해주세요.')
+ }
+
+ if (!content.trim()) {
+ errors.push('정책 내용을 입력해주세요.')
+ } else if (content.trim().length < 100) {
+ errors.push('정책 내용이 너무 짧습니다. (최소 100자)')
+ }
+
+ // 버전 중복 체크 (현재 정책이 있는 경우)
+ if (currentPolicy && version === currentPolicy.version) {
+ errors.push('이미 존재하는 버전입니다. 다른 버전을 입력해주세요.')
+ }
+
+ setValidationErrors(errors)
+ return errors.length === 0
+ }
+
+ const handleSave = () => {
+ if (validateForm()) {
+ onSave(policyType, version.trim(), content)
+ }
+ }
+
+ const handlePreview = () => {
+ if (validateForm()) {
+ onPreview(policyType, content, version.trim())
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>
+ {currentPolicy ? '정책 편집' : '새 정책 생성'} - {policyLabels[policyType]}
+ </CardTitle>
+ <CardDescription>
+ {currentPolicy
+ ? `현재 버전 v${currentPolicy.version}을 기반으로 새 버전을 생성합니다.`
+ : `${policyLabels[policyType]}의 첫 번째 버전을 생성합니다.`
+ }
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 경고 메시지 */}
+ <Alert>
+ <Info className="h-4 w-4" />
+ <AlertDescription>
+ 새 버전을 저장하면 즉시 활성화되어 모든 사용자에게 적용됩니다.
+ 저장하기 전에 미리보기로 내용을 확인해주세요.
+ </AlertDescription>
+ </Alert>
+
+ {/* 유효성 검사 오류 */}
+ {validationErrors.length > 0 && (
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ <ul className="list-disc list-inside space-y-1">
+ {validationErrors.map((error, index) => (
+ <li key={index}>{error}</li>
+ ))}
+ </ul>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 버전 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="version">버전</Label>
+ <Input
+ id="version"
+ placeholder="예: 1.1"
+ value={version}
+ onChange={(e) => setVersion(e.target.value)}
+ disabled={isLoading}
+ className="w-32"
+ />
+ <p className="text-xs text-muted-foreground">
+ 형식: 주.부 (예: 1.0, 1.1, 2.0)
+ </p>
+ </div>
+
+ <Separator />
+
+ {/* 정책 내용 편집기 */}
+ <div className="space-y-2">
+ <Label>정책 내용</Label>
+ <div className="border rounded-md">
+ <TiptapEditor
+ content={content}
+ setContent={setContent}
+ disabled={isLoading}
+ height="500px"
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">
+ 리치 텍스트 편집기를 사용하여 정책 내용을 작성하세요.
+ 이미지, 표, 목록 등을 추가할 수 있습니다.
+ </p>
+ </div>
+
+ {/* 액션 버튼들 */}
+ <div className="flex justify-between pt-4">
+ <Button
+ variant="outline"
+ onClick={onCancel}
+ disabled={isLoading}
+ >
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={handlePreview}
+ disabled={isLoading || !content.trim() || !version.trim()}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ <Button
+ onClick={handleSave}
+ disabled={isLoading || !content.trim() || !version.trim()}
+ >
+ <Save className="h-4 w-4 mr-2" />
+ {isLoading ? '저장 중...' : '저장'}
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+} \ No newline at end of file
diff --git a/components/polices/policy-history.tsx b/components/polices/policy-history.tsx
new file mode 100644
index 00000000..af6a68f2
--- /dev/null
+++ b/components/polices/policy-history.tsx
@@ -0,0 +1,250 @@
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Separator } from '@/components/ui/separator'
+import { Save, Eye, X, Info, AlertTriangle, Calendar, Clock } from 'lucide-react'
+import { Badge } from '../ui/badge'
+import { PolicyPreview } from './policy-preview'
+
+// ✅ 타입 정의
+interface PolicyData {
+ id: number
+ policyType: 'privacy_policy' | 'terms_of_service'
+ version: string
+ content: string
+ effectiveDate: string
+ isCurrent: boolean
+ createdAt: string
+}
+
+interface PolicyHistoryProps {
+ policyType: 'privacy_policy' | 'terms_of_service'
+ policies: PolicyData[] | null | undefined // ✅ null/undefined 허용
+ currentPolicy?: PolicyData | null
+ onActivate: (policyId: number, policyType: string) => void
+ onEdit: () => void
+ onClose: () => void
+ isLoading?: boolean
+}
+
+export function PolicyHistory({
+ policyType,
+ policies,
+ currentPolicy,
+ onActivate,
+ onEdit,
+ onClose,
+ isLoading = false
+}: PolicyHistoryProps) {
+ const [viewingPolicy, setViewingPolicy] = useState<PolicyData | null>(null) // ✅ 상세보기 상태
+
+ const policyLabels: Record<string, string> = {
+ privacy_policy: '개인정보 처리방침',
+ terms_of_service: '이용약관'
+ }
+
+ // ✅ 디버깅 로그
+ console.log('PolicyHistory - policies:', policies, 'type:', typeof policies, 'isArray:', Array.isArray(policies))
+
+ // ✅ 안전한 배열 변환
+ const safePolicies = Array.isArray(policies) ? policies :Array.isArray(policies.data) ? policies.data : []
+
+ // ✅ 내용 미리보기 함수
+ const getContentPreview = (content: string): string => {
+ if (!content) return '내용 없음'
+
+ // HTML 태그 제거 및 텍스트 추출
+ const textContent = content.replace(/<[^>]*>/g, '').trim()
+ return textContent.length > 200
+ ? textContent.substring(0, 200) + '...'
+ : textContent
+ }
+
+ // ✅ 상세보기 핸들러
+ const handleViewDetail = (policy: PolicyData) => {
+ setViewingPolicy(policy)
+ }
+
+ // ✅ 상세보기 닫기 핸들러
+ const handleCloseDetail = () => {
+ setViewingPolicy(null)
+ }
+
+ // ✅ 상세보기 모드일 때 PolicyPreview 렌더링
+ if (viewingPolicy) {
+ return (
+ <PolicyPreview
+ data={{
+ policyType: viewingPolicy.policyType,
+ content: viewingPolicy.content,
+ version: viewingPolicy.version,
+ effectiveDate: viewingPolicy.effectiveDate,
+ id: viewingPolicy.id,
+ isCurrent: viewingPolicy.isCurrent,
+ createdAt: viewingPolicy.createdAt
+ }}
+ onClose={handleCloseDetail}
+ mode="view"
+ />
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Clock className="h-5 w-5" />
+ 정책 히스토리
+ </CardTitle>
+ <CardDescription>
+ {policyLabels[policyType]}의 모든 버전을 확인하고 관리합니다
+ </CardDescription>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onEdit}
+ disabled={isLoading}
+ >
+ 새 버전 생성
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onClose}
+ disabled={isLoading}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {/* ✅ 로딩 상태 */}
+ {isLoading && (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-muted-foreground">히스토리를 불러오는 중...</div>
+ </div>
+ )}
+
+ {/* ✅ 에러 상태 체크 */}
+ {!isLoading && safePolicies && !Array.isArray(safePolicies) && (
+ <Alert variant="destructive" className="mb-4">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ 정책 데이터 형식이 올바르지 않습니다. (받은 데이터: {typeof safePolicies})
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* ✅ 정책 목록 */}
+ {!isLoading && (
+ <div className="space-y-4">
+ {safePolicies.length === 0 ? (
+ <div className="text-center py-8">
+ <div className="text-muted-foreground mb-2">
+ <Info className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <p>등록된 정책 버전이 없습니다.</p>
+ </div>
+ <Button onClick={onEdit} size="sm">
+ 첫 번째 버전 생성하기
+ </Button>
+ </div>
+ ) : (
+ <>
+ {/* ✅ 정책 개수 표시 */}
+ <div className="flex items-center justify-between mb-4">
+ <div className="text-sm text-muted-foreground">
+ 총 {safePolicies.length}개 버전
+ </div>
+ {currentPolicy && (
+ <Badge variant="outline" className="text-xs">
+ 현재: v{currentPolicy.version}
+ </Badge>
+ )}
+ </div>
+
+ {/* ✅ 정책 목록 렌더링 */}
+ {safePolicies.map((policy: PolicyData) => (
+ <div
+ key={policy.id}
+ className={`p-4 border rounded-lg transition-colors ${
+ policy.isCurrent
+ ? 'border-green-200 bg-green-50'
+ : 'border-border hover:bg-muted/30'
+ }`}
+ >
+ <div className="flex items-start justify-between">
+ <div className="flex-1 min-w-0">
+ {/* ✅ 헤더 */}
+ <div className="flex items-center gap-2 mb-2">
+ <h4 className="font-medium text-base">
+ 버전 {policy.version}
+ </h4>
+ {policy.isCurrent && (
+ <Badge className="bg-green-100 text-green-800 text-xs">
+ 현재 활성
+ </Badge>
+ )}
+ </div>
+
+ {/* ✅ 메타 정보 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3 text-sm text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')}
+ </div>
+ <div className="flex items-center gap-1">
+ <Clock className="h-3 w-3" />
+ 생성일: {new Date(policy.createdAt).toLocaleDateString('ko-KR')}
+ </div>
+ </div>
+
+ {/* ✅ 내용 미리보기 */}
+ <div className="mt-2">
+ <div className="text-sm bg-muted/50 p-3 rounded border max-h-20 overflow-hidden">
+ {getContentPreview(policy.content)}
+ </div>
+ </div>
+ </div>
+
+ {/* ✅ 액션 버튼들 */}
+ <div className="flex flex-col gap-2 ml-4 flex-shrink-0">
+ {!policy.isCurrent && (
+ <Button
+ size="sm"
+ onClick={() => onActivate(policy.id, policyType)}
+ disabled={isLoading}
+ className="whitespace-nowrap"
+ >
+ 활성화
+ </Button>
+ )}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleViewDetail(policy)}
+ disabled={isLoading}
+ className="whitespace-nowrap"
+ >
+ <Eye className="h-3 w-3 mr-1" />
+ 상세보기
+ </Button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </>
+ )}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+} \ No newline at end of file
diff --git a/components/polices/policy-management-client.tsx b/components/polices/policy-management-client.tsx
new file mode 100644
index 00000000..eecb82ff
--- /dev/null
+++ b/components/polices/policy-management-client.tsx
@@ -0,0 +1,429 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+ Plus,
+ Edit,
+ Eye,
+ History,
+ Save,
+ X,
+ Shield,
+ FileText,
+ Calendar,
+ Clock,
+ CheckCircle2,
+ AlertCircle,
+ Loader2
+} from 'lucide-react'
+import { PolicyEditor } from './policy-editor'
+import { PolicyPreview } from './policy-preview'
+import { PolicyHistory } from './policy-history'
+import { useToast } from "@/hooks/use-toast";
+import { activatePolicyVersion, createPolicyVersion, getPolicyHistory } from '@/lib/polices/service'
+import { useRouter } from "next/navigation"
+
+interface PolicyManagementClientProps {
+ initialData: {
+ currentPolicies: Record<string, any>
+ allPolicies: Record<string, any[]>
+ stats: any
+ }
+}
+
+export function PolicyManagementClient({ initialData }: PolicyManagementClientProps) {
+ const [currentTab, setCurrentTab] = useState('privacy_policy')
+ const [editingPolicy, setEditingPolicy] = useState<string | null>(null)
+ const [viewingHistory, setViewingHistory] = useState<string | null>(null)
+ const [previewData, setPreviewData] = useState<any>(null)
+
+ // ✅ 초기 데이터를 안전하게 설정
+ const [policies, setPolicies] = useState(() => {
+ const safePolicies = { ...initialData.allPolicies }
+ // 각 정책 타입에 대해 빈 배열로 초기화
+ if (!safePolicies.privacy_policy) safePolicies.privacy_policy = []
+ if (!safePolicies.terms_of_service) safePolicies.terms_of_service = []
+ return safePolicies
+ })
+
+ const [currentPolicies, setCurrentPolicies] = useState(initialData.currentPolicies || {})
+ const [isPending, startTransition] = useTransition()
+ const { toast } = useToast();
+ const router = useRouter()
+
+ const policyTypes = [
+ {
+ key: 'privacy_policy',
+ label: '개인정보 처리방침',
+ icon: <Shield className="h-4 w-4" />,
+ description: '개인정보 수집, 이용, 보관 및 파기에 관한 정책'
+ },
+ {
+ key: 'terms_of_service',
+ label: '이용약관',
+ icon: <FileText className="h-4 w-4" />,
+ description: '서비스 이용 시 준수해야 할 규칙과 조건'
+ }
+ ]
+
+ const handleCreateNew = (policyType: string) => {
+ setEditingPolicy(policyType)
+ setViewingHistory(null)
+ setPreviewData(null)
+ }
+
+ const handleEdit = (policyType: string) => {
+ setEditingPolicy(policyType)
+ setViewingHistory(null)
+ setPreviewData(null)
+ }
+
+ const handleViewHistory = async (policyType: string) => {
+ setViewingHistory(policyType)
+ setEditingPolicy(null)
+ setPreviewData(null)
+
+ startTransition(async () => {
+ try {
+ const history = await getPolicyHistory(policyType)
+ setPolicies(prev => ({
+ ...prev,
+ [policyType]: history || [] // ✅ null/undefined 방지
+ }))
+ } catch (error) {
+ console.error('Policy history error:', error)
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: "정책 히스토리를 불러오는데 실패했습니다.",
+ })
+ }
+ })
+ }
+
+ const handlePreview = (policyType: string, content: string, version: string) => {
+ setPreviewData({
+ policyType,
+ content,
+ version,
+ effectiveDate: new Date().toISOString()
+ })
+ setEditingPolicy(null)
+ setViewingHistory(null)
+ }
+
+ const handleSavePolicy = async (policyType: string, version: string, content: string) => {
+ if (!content.trim()) {
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: "정책 내용을 입력해주세요.",
+ })
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ console.log('Saving policy:', { policyType, version }) // ✅ 디버깅 로그
+
+ const result = await createPolicyVersion({
+ policyType: policyType as 'privacy_policy' | 'terms_of_service',
+ version,
+ content,
+ effectiveDate: new Date()
+ })
+
+ console.log('Save result:', result) // ✅ 디버깅 로그
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: "새 정책 버전이 생성되었습니다.",
+ })
+
+ // ✅ 상태 업데이트 - 안전하게 처리
+ const newPolicy = result.policy
+
+ setPolicies(prev => {
+ console.log('Updating policies state:', { prev, policyType, newPolicy }) // 디버깅 로그
+ return {
+ ...prev,
+ [policyType]: [newPolicy, ...(prev[policyType] || [])] // ✅ 안전한 스프레드
+ }
+ })
+
+ setCurrentPolicies(prev => {
+ console.log('Updating current policies state:', { prev, policyType, newPolicy }) // 디버깅 로그
+ return {
+ ...prev,
+ [policyType]: newPolicy
+ }
+ })
+
+ setEditingPolicy(null)
+
+ // ✅ Router refresh를 상태 업데이트 후에 호출
+ router.refresh()
+ } else {
+ throw new Error(result.error)
+ }
+ } catch (error) {
+ console.error('Save policy error:', error) // ✅ 에러 로그
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: error?.message || "정책 저장에 실패했습니다.",
+ })
+ }
+ })
+ }
+
+ const handleActivatePolicy = async (policyId: number, policyType: string) => {
+ startTransition(async () => {
+ try {
+ const result = await activatePolicyVersion(policyId)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: "정책이 활성화되었습니다.",
+ })
+
+ // ✅ 현재 정책 업데이트 - 안전하게 처리
+ const activatedPolicy = (policies[policyType] || []).find(p => p.id === policyId)
+ if (activatedPolicy) {
+ setCurrentPolicies(prev => ({
+ ...prev,
+ [policyType]: activatedPolicy
+ }))
+
+ // ✅ 정책 목록의 isCurrent 상태 업데이트
+ setPolicies(prev => ({
+ ...prev,
+ [policyType]: (prev[policyType] || []).map(p => ({
+ ...p,
+ isCurrent: p.id === policyId
+ }))
+ }))
+ }
+
+ router.refresh()
+ } else {
+ throw new Error(result.error)
+ }
+ } catch (error) {
+ console.error('Activate policy error:', error)
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: error?.message || "정책 활성화에 실패했습니다.",
+ })
+ }
+ })
+ }
+
+ const renderMainContent = () => {
+ const currentPolicy = currentPolicies[currentTab]
+ const policyInfo = policyTypes.find(p => p.key === currentTab)
+
+ // ✅ 디버깅 정보
+ console.log('Render main content:', {
+ currentTab,
+ currentPolicy,
+ editingPolicy,
+ policiesForTab: policies[currentTab]?.length || 0
+ })
+
+ if (editingPolicy === currentTab) {
+ return (
+ <PolicyEditor
+ policyType={currentTab}
+ currentPolicy={currentPolicy}
+ onSave={handleSavePolicy}
+ onCancel={() => setEditingPolicy(null)}
+ onPreview={handlePreview}
+ isLoading={isPending}
+ />
+ )
+ }
+
+ if (viewingHistory === currentTab) {
+ return (
+ <PolicyHistory
+ policyType={currentTab}
+ policies={policies[currentTab] || []} // ✅ 안전한 기본값
+ currentPolicy={currentPolicy}
+ onActivate={handleActivatePolicy}
+ onEdit={() => setEditingPolicy(currentTab)}
+ onClose={() => setViewingHistory(null)}
+ isLoading={isPending}
+ />
+ )
+ }
+
+ if (previewData && previewData.policyType === currentTab) {
+ return (
+ <PolicyPreview
+ data={previewData}
+ onSave={() => handleSavePolicy(previewData.policyType, previewData.version, previewData.content)}
+ onEdit={() => setEditingPolicy(currentTab)}
+ onClose={() => setPreviewData(null)}
+ isLoading={isPending}
+ />
+ )
+ }
+
+ // 기본 정책 관리 화면
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ {policyInfo?.icon}
+ {policyInfo?.label}
+ </CardTitle>
+ <CardDescription>
+ {policyInfo?.description}
+ </CardDescription>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleCreateNew(currentTab)}
+ disabled={isPending}
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 새 버전
+ </Button>
+ {currentPolicy && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleEdit(currentTab)}
+ disabled={isPending}
+ >
+ <Edit className="h-4 w-4 mr-1" />
+ 편집
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleViewHistory(currentTab)}
+ disabled={isPending}
+ >
+ <History className="h-4 w-4 mr-1" />
+ 히스토리
+ </Button>
+ </>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {currentPolicy ? (
+ <div className="space-y-4">
+ {/* 현재 정책 정보 */}
+ <div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-lg">
+ <div className="flex items-center gap-3">
+ <CheckCircle2 className="h-5 w-5 text-green-600" />
+ <div>
+ <p className="font-medium text-green-900">
+ 현재 활성 버전: v{currentPolicy.version}
+ </p>
+ <p className="text-sm text-green-700">
+ 시행일: {new Date(currentPolicy.effectiveDate).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <Badge variant="secondary" className="bg-green-100 text-green-800">
+ 활성
+ </Badge>
+ </div>
+
+ {/* 정책 내용 미리보기 */}
+ <div className="space-y-2">
+ <h4 className="font-medium">정책 내용 미리보기</h4>
+ <div className="bg-muted/50 p-4 rounded-md max-h-64 overflow-y-auto">
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: currentPolicy.content.substring(0, 1000) + (currentPolicy.content.length > 1000 ? '...' : '')
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 메타 정보 */}
+ <div className="grid grid-cols-2 gap-4 pt-4 border-t">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Calendar className="h-4 w-4" />
+ 생성일: {new Date(currentPolicy.createdAt).toLocaleString('ko-KR')}
+ </div>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Clock className="h-4 w-4" />
+ 버전: {currentPolicy.version}
+ </div>
+ </div>
+ </div>
+ ) : (
+ <div className="text-center py-12">
+ <AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
+ <h3 className="text-lg font-medium mb-2">정책이 등록되지 않았습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ {policyInfo?.label}의 첫 번째 버전을 생성해주세요.
+ </p>
+ <Button onClick={() => handleCreateNew(currentTab)}>
+ <Plus className="h-4 w-4 mr-2" />
+ 첫 번째 버전 생성
+ </Button>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h2 className="text-2xl font-semibold">정책 편집</h2>
+ {isPending && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ 처리 중...
+ </div>
+ )}
+ </div>
+
+ <Tabs value={currentTab} onValueChange={setCurrentTab}>
+ <TabsList>
+ {policyTypes.map(policy => (
+ <TabsTrigger
+ key={policy.key}
+ value={policy.key}
+ className="flex items-center gap-2"
+ >
+ {policy.icon}
+ {policy.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {policyTypes.map(policy => (
+ <TabsContent key={policy.key} value={policy.key}>
+ {renderMainContent()}
+ </TabsContent>
+ ))}
+ </Tabs>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/polices/policy-preview.tsx b/components/polices/policy-preview.tsx
new file mode 100644
index 00000000..059b2d72
--- /dev/null
+++ b/components/polices/policy-preview.tsx
@@ -0,0 +1,191 @@
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Separator } from '@/components/ui/separator'
+import { Save, Eye, X, Info, AlertTriangle, Calendar, Clock, FileText, Shield } from 'lucide-react'
+import { Badge } from '../ui/badge'
+
+// ✅ 타입 정의
+interface PolicyPreviewData {
+ policyType: 'privacy_policy' | 'terms_of_service'
+ content: string
+ version: string
+ effectiveDate: string
+ id?: number
+ isCurrent?: boolean
+ createdAt?: string
+}
+
+interface PolicyPreviewProps {
+ data: PolicyPreviewData
+ onSave?: () => void
+ onEdit?: () => void
+ onClose: () => void
+ isLoading?: boolean
+ mode?: 'preview' | 'view' // ✅ 미리보기 모드 vs 상세보기 모드
+}
+
+export function PolicyPreview({
+ data,
+ onSave,
+ onEdit,
+ onClose,
+ isLoading = false,
+ mode = 'preview'
+}: PolicyPreviewProps) {
+ const policyLabels: Record<string, string> = {
+ privacy_policy: '개인정보 처리방침',
+ terms_of_service: '이용약관'
+ }
+
+ const policyIcons = {
+ privacy_policy: <Shield className="h-5 w-5" />,
+ terms_of_service: <FileText className="h-5 w-5" />
+ }
+
+ // ✅ 모드에 따른 제목과 설명
+ const getTitle = () => {
+ return mode === 'preview' ? '정책 미리보기' : '정책 상세보기'
+ }
+
+ const getDescription = () => {
+ if (mode === 'preview') {
+ return `${policyLabels[data.policyType]} v${data.version} - 저장하면 즉시 활성화됩니다`
+ } else {
+ return `${policyLabels[data.policyType]} v${data.version} 상세 내용`
+ }
+ }
+
+ // ✅ 상태 텍스트 결정
+ const getStatusText = () => {
+ if (mode === 'preview') return '저장 대기'
+ if (data.isCurrent) return '현재 활성'
+ return '비활성'
+ }
+
+ const getStatusBadge = () => {
+ if (mode === 'preview') {
+ return <Badge variant="outline" className="text-orange-600">저장 대기</Badge>
+ }
+ if (data.isCurrent) {
+ return <Badge className="bg-green-100 text-green-800">현재 활성</Badge>
+ }
+ return <Badge variant="secondary">비활성</Badge>
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ {policyIcons[data.policyType]}
+ <div>
+ <CardTitle>{getTitle()}</CardTitle>
+ <CardDescription>
+ {getDescription()}
+ </CardDescription>
+ </div>
+ </div>
+ <Button variant="outline" size="sm" onClick={onClose}>
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* ✅ 정책 메타 정보 */}
+ <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
+ <div>
+ <p className="text-sm font-medium">정책 유형</p>
+ <p className="text-sm text-muted-foreground">{policyLabels[data.policyType]}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium">버전</p>
+ <p className="text-sm text-muted-foreground">v{data.version}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium">시행일</p>
+ <p className="text-sm text-muted-foreground">
+ {new Date(data.effectiveDate).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium">상태</p>
+ <div className="flex items-center gap-2">
+ {getStatusBadge()}
+ </div>
+ </div>
+
+ {/* ✅ 상세보기 모드일 때 추가 정보 */}
+ {mode === 'view' && data.createdAt && (
+ <>
+ <div>
+ <p className="text-sm font-medium">생성일</p>
+ <p className="text-sm text-muted-foreground">
+ {new Date(data.createdAt).toLocaleString('ko-KR')}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium">문서 ID</p>
+ <p className="text-sm text-muted-foreground">#{data.id}</p>
+ </div>
+ </>
+ )}
+ </div>
+
+ <Separator />
+
+ {/* ✅ 정책 내용 미리보기 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h4 className="font-medium">정책 내용</h4>
+ <div className="text-xs text-muted-foreground">
+ {data.content.replace(/<[^>]*>/g, '').length}자
+ </div>
+ </div>
+ <div className="bg-white border rounded-md p-6 max-h-96 overflow-y-auto">
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{ __html: data.content }}
+ />
+ </div>
+ </div>
+
+ {/* ✅ 액션 버튼들 */}
+ <div className="flex justify-between pt-4">
+ {mode === 'preview' ? (
+ // 미리보기 모드 버튼들
+ <>
+ <Button
+ variant="outline"
+ onClick={onEdit}
+ disabled={isLoading}
+ >
+ 편집으로 돌아가기
+ </Button>
+
+ <Button
+ onClick={onSave}
+ disabled={isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Save className="h-4 w-4 mr-2" />
+ {isLoading ? '저장 중...' : '저장 및 활성화'}
+ </Button>
+ </>
+ ) : (
+ // 상세보기 모드 버튼들
+ <div className="w-full flex justify-end">
+ <Button
+ variant="outline"
+ onClick={onClose}
+ disabled={isLoading}
+ >
+ 닫기
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+} \ No newline at end of file
diff --git a/components/qna/tiptap-editor.tsx b/components/qna/tiptap-editor.tsx
index 5d0a84e9..b1cebf5a 100644
--- a/components/qna/tiptap-editor.tsx
+++ b/components/qna/tiptap-editor.tsx
@@ -1,4 +1,5 @@
import { useEditor, EditorContent } from '@tiptap/react'
+import { useEffect } from 'react' // useEffect 추가
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import { Image as TiptapImage } from '@tiptap/extension-image'
@@ -26,6 +27,18 @@ interface TiptapEditorProps {
height?: string; // 높이 prop 추가
}
+// 이미지 크기 가져오기 헬퍼 함수
+function getImageDimensions(url: string): Promise<{ width: number; height: number }> {
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ img.onload = () => {
+ resolve({ width: img.width, height: img.height })
+ }
+ img.onerror = reject
+ img.src = url
+ })
+}
+
export default function TiptapEditor({ content, setContent, disabled, height = "300px" }: TiptapEditorProps) {
const editor = useEditor({
extensions: [
@@ -135,6 +148,21 @@ export default function TiptapEditor({ content, setContent, disabled, height = "
},
})
+ // ✅ 핵심 추가: content prop이 변경될 때 에디터 내용 동기화
+ useEffect(() => {
+ if (editor && content !== editor.getHTML()) {
+ console.log('Updating editor content:', content) // 디버깅용
+ editor.commands.setContent(content, false) // false: focus 유지하지 않음
+ }
+ }, [content, editor])
+
+ // ✅ editable 상태 변경 처리
+ useEffect(() => {
+ if (editor) {
+ editor.setEditable(!disabled)
+ }
+ }, [disabled, editor])
+
async function uploadImageToServer(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
@@ -153,39 +181,51 @@ export default function TiptapEditor({ content, setContent, disabled, height = "
return url;
}
-// Base64 → 서버 업로드 방식으로 교체
-const handleImageUpload = async (file: File) => {
- try {
- if (file.size > 3 * 1024 * 1024) {
- alert('이미지 크기는 3 MB 이하만 지원됩니다.');
- return;
- }
- if (!file.type.startsWith('image/')) {
- alert('이미지 파일만 업로드 가능합니다.');
- return;
- }
+ // Base64 → 서버 업로드 방식으로 교체
+ const handleImageUpload = async (file: File) => {
+ try {
+ if (file.size > 3 * 1024 * 1024) {
+ alert('이미지 크기는 3 MB 이하만 지원됩니다.');
+ return;
+ }
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.');
+ return;
+ }
- const url = await uploadImageToServer(file); // ← 업로드 & URL 획득
+ const url = await uploadImageToServer(file); // ← 업로드 & URL 획득
- // 이미지 크기(너비)에 따라 style 조정
- const { width, height } = await getImageDimensions(url);
- const maxWidth = 600;
- const newW = width > maxWidth ? maxWidth : width;
- const newH = width > maxWidth ? (height * maxWidth) / width : height;
+ // 이미지 크기(너비)에 따라 style 조정
+ const { width, height } = await getImageDimensions(url);
+ const maxWidth = 600;
+ const newW = width > maxWidth ? maxWidth : width;
+ const newH = width > maxWidth ? (height * maxWidth) / width : height;
+
+ editor
+ ?.chain()
+ .focus()
+ .setImage({
+ src: url,
+ style: `width:${newW}px;height:${newH}px;max-width:100%;`,
+ })
+ .run();
+ } catch (e) {
+ console.error(e);
+ alert('이미지 업로드에 실패했습니다.');
+ }
+ };
- editor
- ?.chain()
- .focus()
- .setImage({
- src: url,
- style: `width:${newW}px;height:${newH}px;max-width:100%;`,
- })
- .run();
- } catch (e) {
- console.error(e);
- alert('이미지 업로드에 실패했습니다.');
+ // ✅ 에디터가 준비되지 않았을 때 로딩 표시
+ if (!editor) {
+ return (
+ <div
+ className="border rounded-md bg-background flex items-center justify-center"
+ style={{ height }}
+ >
+ <div className="text-muted-foreground text-sm">에디터를 로딩 중...</div>
+ </div>
+ )
}
-};
// 높이 계산 (100%인 경우 flex 사용, 아니면 구체적 높이)
const containerStyle = height === "100%"
diff --git a/components/signup/conset-step.tsx b/components/signup/conset-step.tsx
new file mode 100644
index 00000000..3260a7b7
--- /dev/null
+++ b/components/signup/conset-step.tsx
@@ -0,0 +1,415 @@
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Separator } from '@/components/ui/separator';
+import { ChevronDown, ChevronUp, FileText, Shield, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { getCurrentPolicies } from '@/lib/polices/service';
+
+// ✅ 정책 데이터 타입 개선
+interface PolicyData {
+ id: number;
+ policyType: 'privacy_policy' | 'terms_of_service';
+ version: string;
+ content: string; // HTML 형태의 리치텍스트
+ effectiveDate: string;
+ isCurrent: boolean;
+ createdAt: string;
+}
+
+interface PolicyVersions {
+ privacy_policy?: PolicyData;
+ terms_of_service?: PolicyData;
+}
+
+interface ConsentStepProps {
+ data: {
+ privacy: boolean;
+ terms: boolean;
+ marketing: boolean;
+ };
+ onChange: (updater: (prev: any) => any) => void;
+ onNext: () => void;
+}
+
+export default function ConsentStep({ data, onChange, onNext }: ConsentStepProps) {
+ const [policyData, setPolicyData] = useState<PolicyVersions | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [showPrivacyModal, setShowPrivacyModal] = useState(false);
+ const [showTermsModal, setShowTermsModal] = useState(false);
+ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
+
+ const isValid = data.privacy && data.terms;
+
+ // 정책 데이터 로드
+ useEffect(() => {
+ fetchPolicyData();
+ }, []);
+
+ const fetchPolicyData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const result = await getCurrentPolicies();
+
+ if (result.success) {
+ console.log('Policy data loaded:', result.data);
+ setPolicyData(result.data);
+ } else {
+ setError(result.error || '정책 데이터를 불러올 수 없습니다.');
+ console.error('Failed to fetch policy data:', result.error);
+ }
+ } catch (error) {
+ const errorMessage = '정책 데이터를 불러오는 중 오류가 발생했습니다.';
+ setError(errorMessage);
+ console.error('Failed to fetch policy data:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConsentChange = (type: string, checked: boolean) => {
+ onChange(prev => ({ ...prev, [type]: checked }));
+ };
+
+ const toggleSection = (section: string) => {
+ setExpandedSections(prev => ({
+ ...prev,
+ [section]: !prev[section]
+ }));
+ };
+
+ // ✅ HTML에서 텍스트만 추출하는 함수
+ const stripHtmlTags = (html: string): string => {
+ if (!html) return '';
+
+ // HTML 태그 제거
+ return html
+ .replace(/<[^>]*>/g, '') // 모든 HTML 태그 제거
+ .replace(/&nbsp;/g, ' ') // non-breaking space 처리
+ .replace(/&amp;/g, '&') // HTML entities 처리
+ .replace(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/g, '"')
+ .replace(/&#39;/g, "'")
+ .replace(/\s+/g, ' ') // 연속된 공백을 하나로
+ .trim();
+ };
+
+ // ✅ 정책 미리보기 텍스트 생성
+ const renderPolicyPreview = (policy: PolicyData, maxLength = 200): string => {
+ if (!policy?.content) return '내용 없음';
+
+ const textContent = stripHtmlTags(policy.content);
+ return textContent.length > maxLength
+ ? textContent.substring(0, maxLength) + '...'
+ : textContent;
+ };
+
+ // ✅ 로딩 상태
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="flex items-center gap-2 text-gray-600">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ 정책 내용을 불러오는 중...
+ </div>
+ </div>
+ );
+ }
+
+ // ✅ 에러 상태
+ if (error || !policyData) {
+ return (
+ <div className="text-center p-8">
+ <div className="text-red-600 mb-4">
+ {error || '정책 내용을 불러올 수 없습니다.'}
+ </div>
+ <Button onClick={fetchPolicyData} variant="outline">
+ 다시 시도
+ </Button>
+ </div>
+ );
+ }
+
+ // ✅ 필수 정책이 없는 경우
+ if (!policyData.privacy_policy || !policyData.terms_of_service) {
+ return (
+ <div className="text-center p-8">
+ <div className="text-amber-600 mb-4">
+ 일부 정책이 설정되지 않았습니다. 관리자에게 문의해주세요.
+ </div>
+ <div className="text-sm text-gray-500">
+ {!policyData.privacy_policy && '개인정보 처리방침이 없습니다.'}<br />
+ {!policyData.terms_of_service && '이용약관이 없습니다.'}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <h2 className="text-xl font-semibold mb-2">서비스 이용 약관 동의</h2>
+ <p className="text-gray-600 text-sm">
+ 서비스 이용을 위해 다음 약관에 동의해주세요. 각 항목을 클릭하여 상세 내용을 확인할 수 있습니다.
+ </p>
+ </div>
+
+ <div className="space-y-4">
+ {/* ✅ 개인정보 처리방침 */}
+ {policyData.privacy_policy && (
+ <PolicyConsentSection
+ id="privacy-consent"
+ type="privacy"
+ checked={data.privacy}
+ onChange={handleConsentChange}
+ policy={policyData.privacy_policy}
+ isRequired={true}
+ icon={<Shield className="w-4 h-4" />}
+ title="개인정보 처리방침"
+ description="개인정보 수집, 이용, 보관 및 파기에 관한 정책입니다."
+ expanded={expandedSections.privacy}
+ onToggleExpand={() => toggleSection('privacy')}
+ onShowModal={() => setShowPrivacyModal(true)}
+ />
+ )}
+
+ <Separator />
+
+ {/* ✅ 이용약관 */}
+ {policyData.terms_of_service && (
+ <PolicyConsentSection
+ id="terms-consent"
+ type="terms"
+ checked={data.terms}
+ onChange={handleConsentChange}
+ policy={policyData.terms_of_service}
+ isRequired={true}
+ icon={<FileText className="w-4 h-4" />}
+ title="이용약관"
+ description="서비스 이용 시 준수해야 할 규칙과 조건입니다."
+ expanded={expandedSections.terms}
+ onToggleExpand={() => toggleSection('terms')}
+ onShowModal={() => setShowTermsModal(true)}
+ />
+ )}
+
+
+ {/* ✅ 전체 동의 */}
+ <div className="pt-4 border-t bg-gray-50 p-4 rounded-lg">
+ <div className="flex items-center space-x-3">
+ <input
+ type="checkbox"
+ id="all-consent"
+ checked={data.privacy && data.terms && data.marketing}
+ onChange={(e) => {
+ const checked = e.target.checked;
+ onChange(() => ({
+ privacy: checked,
+ terms: checked,
+ marketing: checked
+ }));
+ }}
+ className="h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
+ />
+ <label htmlFor="all-consent" className="text-base font-medium cursor-pointer">
+ 위 내용에 모두 동의합니다
+ </label>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end">
+ <Button onClick={onNext} disabled={!isValid} size="lg">
+ 다음 단계로
+ </Button>
+ </div>
+
+ {/* ✅ 개인정보 처리방침 상세 모달 */}
+ {showPrivacyModal && policyData.privacy_policy && (
+ <PolicyModal
+ policy={policyData.privacy_policy}
+ onClose={() => setShowPrivacyModal(false)}
+ onAgree={() => {
+ onChange(prev => ({ ...prev, privacy: true }));
+ setShowPrivacyModal(false);
+ }}
+ />
+ )}
+
+ {/* ✅ 이용약관 상세 모달 */}
+ {showTermsModal && policyData.terms_of_service && (
+ <PolicyModal
+ policy={policyData.terms_of_service}
+ onClose={() => setShowTermsModal(false)}
+ onAgree={() => {
+ onChange(prev => ({ ...prev, terms: true }));
+ setShowTermsModal(false);
+ }}
+ />
+ )}
+ </div>
+ );
+}
+
+// ✅ 개별 정책 동의 섹션 컴포넌트
+interface PolicyConsentSectionProps {
+ id: string;
+ type: string;
+ checked: boolean;
+ onChange: (type: string, checked: boolean) => void;
+ policy: PolicyData;
+ isRequired: boolean;
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ expanded: boolean;
+ onToggleExpand: () => void;
+ onShowModal: () => void;
+}
+
+function PolicyConsentSection({
+ id, type, checked, onChange, policy, isRequired, icon, title, description,
+ expanded, onToggleExpand, onShowModal
+}: PolicyConsentSectionProps) {
+ // ✅ HTML에서 텍스트 추출
+ const renderPolicyPreview = (content: string, maxLength = 300): string => {
+ if (!content) return '내용 없음';
+
+ const textContent = content
+ .replace(/<[^>]*>/g, '') // HTML 태그 제거
+ .replace(/&nbsp;/g, ' ')
+ .replace(/&amp;/g, '&')
+ .replace(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/g, '"')
+ .replace(/&#39;/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ return textContent.length > maxLength
+ ? textContent.substring(0, maxLength) + '...'
+ : textContent;
+ };
+
+ return (
+ <div className="border rounded-lg p-4 hover:bg-gray-50 transition-colors">
+ {/* 체크박스와 기본 정보 */}
+ <div className="flex items-start space-x-3">
+ <input
+ type="checkbox"
+ id={id}
+ checked={checked}
+ onChange={(e) => onChange(type, e.target.checked)}
+ className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
+ />
+ <div className="flex-1">
+ <div className="flex items-center space-x-2 mb-1">
+ {icon}
+ <label htmlFor={id} className="text-sm font-medium cursor-pointer">
+ <span className={isRequired ? "text-red-500" : "text-blue-500"}>
+ [{isRequired ? "필수" : "선택"}]
+ </span> {title} (v{policy.version})
+ </label>
+ </div>
+
+ <p className="text-xs text-gray-600 mb-2">{description}</p>
+
+ {/* ✅ 정책 미리보기 - HTML 내용 표시 */}
+ <div className="bg-gray-50 p-3 rounded text-xs text-gray-700 mb-2">
+ {renderPolicyPreview(policy.content, expanded ? 1000 : 200)}
+ </div>
+
+ {/* 액션 버튼들 */}
+ <div className="flex items-center space-x-3 text-xs">
+ <button
+ type="button"
+ onClick={onToggleExpand}
+ className="flex items-center space-x-1 text-blue-600 hover:underline"
+ >
+ {expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
+ <span>{expanded ? '간략히' : '더보기'}</span>
+ </button>
+ <span className="text-gray-400">|</span>
+ <button
+ type="button"
+ onClick={onShowModal}
+ className="text-blue-600 hover:underline"
+ >
+ 전문보기
+ </button>
+ <span className="text-gray-400">|</span>
+ <span className="text-gray-500">
+ 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// ✅ 정책 상세 모달 컴포넌트
+interface PolicyModalProps {
+ policy: PolicyData;
+ onClose: () => void;
+ onAgree: () => void;
+}
+
+function PolicyModal({ policy, onClose, onAgree }: PolicyModalProps) {
+ const getPolicyTitle = (policyType: string): string => {
+ return policyType === 'privacy_policy' ? '개인정보 처리방침' : '이용약관';
+ };
+
+ return (
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
+ <div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between p-6 border-b">
+ <div>
+ <h3 className="text-xl font-semibold">
+ {getPolicyTitle(policy.policyType)}
+ </h3>
+ <p className="text-sm text-gray-600 mt-1">
+ 버전 {policy.version} | 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ <button
+ onClick={onClose}
+ className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
+ >
+ ×
+ </button>
+ </div>
+
+ {/* ✅ 내용 - HTML 직접 렌더링 */}
+ <ScrollArea className="flex-1 p-6">
+ <div
+ className="prose prose-sm max-w-none text-gray-700 leading-relaxed"
+ dangerouslySetInnerHTML={{ __html: policy.content }}
+ />
+ </ScrollArea>
+
+ {/* 푸터 */}
+ <div className="flex gap-3 p-6 border-t bg-gray-50">
+ <Button
+ variant="outline"
+ onClick={onClose}
+ className="flex-1"
+ >
+ 닫기
+ </Button>
+ <Button
+ onClick={onAgree}
+ className="flex-1"
+ >
+ 동의하고 닫기
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx
index 60f600b9..e9773d28 100644
--- a/components/signup/join-form.tsx
+++ b/components/signup/join-form.tsx
@@ -1,32 +1,19 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { useRouter, useSearchParams, useParams } from "next/navigation"
-
-import i18nIsoCountries from "i18n-iso-countries"
-import enLocale from "i18n-iso-countries/langs/en.json"
-import koLocale from "i18n-iso-countries/langs/ko.json"
-
-import { Button } from "@/components/ui/button"
-import { Separator } from "@/components/ui/separator"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { toast } from "@/hooks/use-toast"
+'use client'
+
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Progress } from '@/components/ui/progress';
+import { Check, ChevronRight, User, Building, FileText, Plus, X, ChevronsUpDown, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useRouter, useParams, useSearchParams } from 'next/navigation';
+import { useTranslation } from '@/i18n/client';
+import { toast } from '@/hooks/use-toast';
import {
Popover,
PopoverTrigger,
PopoverContent,
-} from "@/components/ui/popover"
+} from '@/components/ui/popover';
import {
Command,
CommandList,
@@ -34,21 +21,14 @@ import {
CommandEmpty,
CommandGroup,
CommandItem,
-} from "@/components/ui/command"
-import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react"
-import { cn } from "@/lib/utils"
-import { useTranslation } from "@/i18n/client"
-
-import { getVendorTypes } from "@/lib/vendors/service"
-import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations"
+} from '@/components/ui/command';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
-} from "@/components/ui/select"
-
+} from '@/components/ui/select';
import {
Dropzone,
DropzoneZone,
@@ -56,7 +36,7 @@ import {
DropzoneUploadIcon,
DropzoneTitle,
DropzoneDescription,
-} from "@/components/ui/dropzone"
+} from '@/components/ui/dropzone';
import {
FileList,
FileListItem,
@@ -66,33 +46,33 @@ import {
FileListName,
FileListDescription,
FileListAction,
-} from "@/components/ui/file-list"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import prettyBytes from "pretty-bytes"
-import { Checkbox } from "../ui/checkbox"
-
-i18nIsoCountries.registerLocale(enLocale)
-i18nIsoCountries.registerLocale(koLocale)
-
-const locale = "ko"
-const countryMap = i18nIsoCountries.getNames(locale, { select: "official" })
+} from '@/components/ui/file-list';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import prettyBytes from 'pretty-bytes';
+
+// 기존 JoinForm에서 가져온 데이터들
+import i18nIsoCountries from "i18n-iso-countries";
+import enLocale from "i18n-iso-countries/langs/en.json";
+import koLocale from "i18n-iso-countries/langs/ko.json";
+import { getVendorTypes } from '@/lib/vendors/service';
+import ConsentStep from './conset-step';
+
+i18nIsoCountries.registerLocale(enLocale);
+i18nIsoCountries.registerLocale(koLocale);
+
+const locale = "ko";
+const countryMap = i18nIsoCountries.getNames(locale, { select: "official" });
const countryArray = Object.entries(countryMap).map(([code, label]) => ({
code,
label,
-}))
+}));
-// Sort countries to put Korea first, then alphabetically
const sortedCountryArray = [...countryArray].sort((a, b) => {
- // Put Korea (KR) at the top
if (a.code === "KR") return -1;
if (b.code === "KR") return 1;
-
- // Otherwise sort alphabetically
return a.label.localeCompare(b.label);
});
-// Add English names for Korean locale
const enhancedCountryArray = sortedCountryArray.map(country => ({
...country,
label: locale === "ko" && country.code === "KR"
@@ -100,7 +80,6 @@ const enhancedCountryArray = sortedCountryArray.map(country => ({
: country.label
}));
-// Contact task options
const contactTaskOptions = [
{ value: "PRESIDENT_DIRECTOR", label: "회사대표 President/Director" },
{ value: "SALES_MANAGEMENT", label: "영업관리 Sales Management" },
@@ -114,8 +93,7 @@ const contactTaskOptions = [
{ value: "FIELD_SERVICE_ENGINEER", label: "FSE(야드작업자) Field Service Engineer" }
];
-// Comprehensive list of country dial codes
-export const countryDialCodes: { [key: string]: string } = {
+export const countryDialCodes = {
AF: "+93", AL: "+355", DZ: "+213", AS: "+1-684", AD: "+376", AO: "+244",
AI: "+1-264", AG: "+1-268", AR: "+54", AM: "+374", AW: "+297", AU: "+61",
AT: "+43", AZ: "+994", BS: "+1-242", BH: "+973", BD: "+880", BB: "+1-246",
@@ -153,312 +131,1102 @@ export const countryDialCodes: { [key: string]: string } = {
ZW: "+263"
};
-const MAX_FILE_SIZE = 3e9
-
-export function JoinForm() {
- const params = useParams() || {};
- const lng = params.lng ? String(params.lng) : "ko";
- const { t } = useTranslation(lng, "translation")
-
- const router = useRouter()
- const searchParams = useSearchParams() || new URLSearchParams();
- const defaultTaxId = searchParams.get("taxID") ?? ""
-
- // Define VendorType interface
- interface VendorType {
- id: number;
- code: string;
- nameKo: string;
- nameEn: string;
- }
-
- // Vendor Types state with proper typing
- const [vendorTypes, setVendorTypes] = React.useState<VendorType[]>([])
- const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true)
-
- // Individual file states
- const [businessRegistrationFiles, setBusinessRegistrationFiles] = React.useState<File[]>([])
- const [isoCertificationFiles, setIsoCertificationFiles] = React.useState<File[]>([])
- const [creditReportFiles, setCreditReportFiles] = React.useState<File[]>([])
- const [bankAccountFiles, setBankAccountFiles] = React.useState<File[]>([])
-
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- // Fetch vendor types on component mount
- React.useEffect(() => {
- async function loadVendorTypes() {
- setIsLoadingVendorTypes(true)
- try {
- const result = await getVendorTypes()
- if (result.data) {
- setVendorTypes(result.data)
- }
- } catch (error) {
- console.error("Failed to load vendor types:", error)
- toast({
- variant: "destructive",
- title: "Error",
- description: "Failed to load vendor types",
- })
- } finally {
- setIsLoadingVendorTypes(false)
+const MAX_FILE_SIZE = 3e9;
+
+// 스텝 정의
+const STEPS = [
+ { id: 1, title: '약관 동의', description: '서비스 이용 약관 동의', icon: FileText },
+ { id: 2, title: '계정 생성', description: '개인 계정 정보 입력', icon: User },
+ { id: 3, title: '업체 등록', description: '업체 정보 및 서류 제출', icon: Building }
+];
+
+export default function JoinForm() {
+ const params = useParams() || {};
+ const lng = params.lng ? String(params.lng) : "ko";
+ const { t } = useTranslation(lng, "translation");
+ const router = useRouter();
+ const searchParams = useSearchParams() || new URLSearchParams();
+ const defaultTaxId = searchParams.get("taxID") ?? "";
+
+ const [currentStep, setCurrentStep] = useState(1);
+ const [completedSteps, setCompletedSteps] = useState(new Set());
+
+ // 각 스텝별 데이터
+ const [consentData, setConsentData] = useState({
+ privacy: false,
+ terms: false,
+ marketing: false
+ });
+
+ const [accountData, setAccountData] = useState({
+ name: '',
+ email: '',
+ password: '',
+ confirmPassword: '',
+ phone: ''
+ });
+
+ const [vendorData, setVendorData] = useState({
+ vendorName: "",
+ vendorTypeId: undefined,
+ items: "",
+ taxId: defaultTaxId,
+ address: "",
+ email: "",
+ phone: "",
+ country: "",
+ website: "",
+ representativeName: "",
+ representativeBirth: "",
+ representativeEmail: "",
+ representativePhone: "",
+ corporateRegistrationNumber: "",
+ representativeWorkExpirence: false,
+ contacts: [
+ {
+ contactName: "",
+ contactPosition: "",
+ contactDepartment: "",
+ contactTask: "",
+ contactEmail: "",
+ contactPhone: "",
+ },
+ ],
+ });
+
+ // 업체 타입 및 파일 상태
+ const [vendorTypes, setVendorTypes] = useState([]);
+ const [isLoadingVendorTypes, setIsLoadingVendorTypes] = useState(true);
+ const [businessRegistrationFiles, setBusinessRegistrationFiles] = useState([]);
+ const [isoCertificationFiles, setIsoCertificationFiles] = useState([]);
+ const [creditReportFiles, setCreditReportFiles] = useState([]);
+ const [bankAccountFiles, setBankAccountFiles] = useState([]);
+
+ const [policyVersions, setPolicyVersions] = useState({
+ privacy_policy: '1.0',
+ terms_of_service: '1.0'
+ });
+
+ const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100;
+
+ // 정책 버전 및 업체 타입 로드
+ useEffect(() => {
+ fetchPolicyVersions();
+ loadVendorTypes();
+ }, []);
+
+ const fetchPolicyVersions = async () => {
+ try {
+ const response = await fetch('/api/consent/policy-versions');
+ const versions = await response.json();
+ setPolicyVersions(versions);
+ } catch (error) {
+ console.error('Failed to fetch policy versions:', error);
+ }
+ };
+
+ const loadVendorTypes = async () => {
+ setIsLoadingVendorTypes(true);
+ try {
+ const result = await getVendorTypes();
+ if (result.data) {
+ setVendorTypes(result.data);
}
+ } catch (error) {
+ console.error("Failed to load vendor types:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to load vendor types",
+ });
+ } finally {
+ setIsLoadingVendorTypes(false);
+ }
+ };
+
+ const handleStepComplete = (step) => {
+ setCompletedSteps(prev => new Set([...prev, step]));
+ if (step < STEPS.length) {
+ setCurrentStep(step + 1);
+ }
+ };
+
+ const handleStepClick = (stepId) => {
+ if (stepId <= Math.max(...completedSteps) + 1) {
+ setCurrentStep(stepId);
}
+ };
+
+ // 전화번호 플레이스홀더 함수들
+ const getPhonePlaceholder = (countryCode) => {
+ if (!countryCode || !countryDialCodes[countryCode]) return "+82 010-1234-5678";
- loadVendorTypes()
- }, [])
-
- // React Hook Form
- const form = useForm<CreateVendorSchema>({
- resolver: zodResolver(createVendorSchema),
- defaultValues: {
- vendorName: "",
- vendorTypeId: undefined,
- items: "",
- taxId: defaultTaxId,
- address: "",
- email: "",
- phone: "",
- country: "",
- representativeName: "",
- representativeBirth: "",
- representativeEmail: "",
- representativePhone: "",
- corporateRegistrationNumber: "",
- representativeWorkExpirence: false,
- // contacts (updated with new fields)
- contacts: [
- {
- contactName: "",
- contactPosition: "",
- contactDepartment: "",
- contactTask: "",
- contactEmail: "",
- contactPhone: "",
- },
- ],
+ const dialCode = countryDialCodes[countryCode];
+
+ switch (countryCode) {
+ case 'KR': return `${dialCode} 010-1234-5678`;
+ case 'US':
+ case 'CA': return `${dialCode} 555-123-4567`;
+ case 'JP': return `${dialCode} 90-1234-5678`;
+ case 'CN': return `${dialCode} 138-0013-8000`;
+ case 'GB': return `${dialCode} 20-7946-0958`;
+ case 'DE': return `${dialCode} 30-12345678`;
+ case 'FR': return `${dialCode} 1-42-86-83-16`;
+ default: return `${dialCode} 전화번호`;
+ }
+ };
+
+ const getPhoneDescription = (countryCode) => {
+ if (!countryCode) return "국가를 먼저 선택해주세요.";
+
+ const dialCode = countryDialCodes[countryCode];
+
+ switch (countryCode) {
+ case 'KR': return `${dialCode}로 시작하는 국제번호 또는 010으로 시작하는 국내번호를 입력하세요.`;
+ case 'US':
+ case 'CA': return `${dialCode}로 시작하는 10자리 번호를 입력하세요.`;
+ case 'JP': return `${dialCode}로 시작하는 일본 전화번호를 입력하세요.`;
+ case 'CN': return `${dialCode}로 시작하는 중국 전화번호를 입력하세요.`;
+ default: return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`;
+ }
+ };
+
+ return (
+ <div className="container max-w-6xl mx-auto py-8">
+ {/* 진행률 표시 */}
+ <div className="mb-8">
+ <div className="flex items-center justify-between mb-4">
+ <h1 className="text-2xl font-bold">파트너 등록</h1>
+ <span className="text-sm text-muted-foreground">
+ {currentStep} / {STEPS.length}
+ </span>
+ </div>
+ <Progress value={progress} className="mb-6" />
+
+ {/* 스텝 네비게이션 */}
+ <div className="flex items-center justify-between">
+ {STEPS.map((step, index) => {
+ const Icon = step.icon;
+ const isCompleted = completedSteps.has(step.id);
+ const isCurrent = currentStep === step.id;
+ const isAccessible = step.id <= Math.max(...completedSteps) + 1;
+
+ return (
+ <React.Fragment key={step.id}>
+ <div
+ className={cn(
+ "flex flex-col items-center cursor-pointer transition-all",
+ isAccessible ? "opacity-100" : "opacity-50 cursor-not-allowed"
+ )}
+ onClick={() => handleStepClick(step.id)}
+ >
+ <div className={cn(
+ "w-12 h-12 rounded-full flex items-center justify-center mb-2 border-2 transition-all",
+ isCompleted
+ ? "bg-green-500 border-green-500 text-white"
+ : isCurrent
+ ? "bg-blue-500 border-blue-500 text-white"
+ : "border-gray-300 text-gray-400"
+ )}>
+ {isCompleted ? (
+ <Check className="w-6 h-6" />
+ ) : (
+ <Icon className="w-6 h-6" />
+ )}
+ </div>
+ <div className="text-center">
+ <div className={cn(
+ "text-sm font-medium",
+ isCurrent ? "text-blue-600" : isCompleted ? "text-green-600" : "text-gray-500"
+ )}>
+ {step.title}
+ </div>
+ <div className="text-xs text-gray-400 mt-1">
+ {step.description}
+ </div>
+ </div>
+ </div>
+
+ {index < STEPS.length - 1 && (
+ <ChevronRight className="w-5 h-5 text-gray-300 mx-4" />
+ )}
+ </React.Fragment>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 스텝 콘텐츠 */}
+ <div className="bg-white rounded-lg border shadow-sm p-6">
+ {currentStep === 1 && (
+ <ConsentStep
+ data={consentData}
+ onChange={setConsentData}
+ onNext={() => handleStepComplete(1)}
+ policyVersions={policyVersions}
+ />
+ )}
+
+ {currentStep === 2 && (
+ <AccountStep
+ data={accountData}
+ onChange={setAccountData}
+ onNext={() => handleStepComplete(2)}
+ onBack={() => setCurrentStep(1)}
+ />
+ )}
+
+ {currentStep === 3 && (
+ <VendorStep
+ data={vendorData}
+ onChange={setVendorData}
+ onBack={() => setCurrentStep(2)}
+ onComplete={() => {
+ handleStepComplete(3);
+ // 완료 후 대시보드로 이동
+ router.push(`/${lng}/partners/dashboard`);
+ }}
+ accountData={accountData}
+ consentData={consentData}
+ vendorTypes={vendorTypes}
+ isLoadingVendorTypes={isLoadingVendorTypes}
+ businessRegistrationFiles={businessRegistrationFiles}
+ setBusinessRegistrationFiles={setBusinessRegistrationFiles}
+ isoCertificationFiles={isoCertificationFiles}
+ setIsoCertificationFiles={setIsoCertificationFiles}
+ creditReportFiles={creditReportFiles}
+ setCreditReportFiles={setCreditReportFiles}
+ bankAccountFiles={bankAccountFiles}
+ setBankAccountFiles={setBankAccountFiles}
+ getPhonePlaceholder={getPhonePlaceholder}
+ getPhoneDescription={getPhoneDescription}
+ enhancedCountryArray={enhancedCountryArray}
+ contactTaskOptions={contactTaskOptions}
+ lng={lng}
+ policyVersions={policyVersions}
+ />
+ )}
+ </div>
+ </div>
+ );
+}
+
+
+// Step 2: 계정 생성
+function AccountStep({ data, onChange, onNext, onBack }) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const isValid = data.name && data.email && data.password &&
+ data.confirmPassword && data.phone &&
+ data.password === data.confirmPassword;
+
+ const handleInputChange = (field, value) => {
+ onChange(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleNext = async () => {
+ if (!isValid) return;
+
+ setIsLoading(true);
+ try {
+ // 이메일 중복 확인
+ const response = await fetch('/api/auth/check-email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: data.email })
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ if (result.error === 'EMAIL_EXISTS') {
+ alert('이미 사용 중인 이메일입니다.');
+ return;
+ }
+ throw new Error(result.error);
+ }
+
+ onNext();
+ } catch (error) {
+ console.error('Email check error:', error);
+ alert('이메일 확인 중 오류가 발생했습니다.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <h2 className="text-xl font-semibold mb-2">계정 정보 입력</h2>
+ <p className="text-gray-600 text-sm">
+ 서비스 이용을 위한 개인 계정을 생성합니다.
+ </p>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 이름 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ type="text"
+ placeholder="이름을 입력하세요"
+ value={data.name}
+ onChange={(e) => handleInputChange('name', e.target.value)}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 이메일 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ type="email"
+ placeholder="이메일을 입력하세요"
+ value={data.email}
+ onChange={(e) => handleInputChange('email', e.target.value)}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 비밀번호 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ type="password"
+ placeholder="비밀번호를 입력하세요"
+ value={data.password}
+ onChange={(e) => handleInputChange('password', e.target.value)}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 비밀번호 확인 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ type="password"
+ placeholder="비밀번호를 다시 입력하세요"
+ value={data.confirmPassword}
+ onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
+ />
+ {data.confirmPassword && data.password !== data.confirmPassword && (
+ <p className="text-xs text-red-500 mt-1">비밀번호가 일치하지 않습니다.</p>
+ )}
+ </div>
+
+ <div className="md:col-span-2">
+ <label className="block text-sm font-medium mb-1">
+ 전화번호 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ type="tel"
+ placeholder="+82-10-1234-5678"
+ value={data.phone}
+ onChange={(e) => handleInputChange('phone', e.target.value)}
+ />
+ <p className="text-xs text-gray-500 mt-1">
+ SMS 인증에 사용됩니다. 국제번호 형식으로 입력해주세요.
+ </p>
+ </div>
+ </div>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={onBack}>
+ 이전
+ </Button>
+ <Button onClick={handleNext} disabled={!isValid || isLoading}>
+ {isLoading ? '확인 중...' : '다음 단계'}
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+// Step 3: 업체 등록 (기존 JoinForm 내용)
+function VendorStep(props) {
+ return <CompleteVendorForm {...props} />;
+}
+
+
+// 완전한 업체 등록 폼 컴포넌트 (기존 JoinForm 내용)
+function CompleteVendorForm({
+ data, onChange, onBack, onComplete, accountData, consentData,
+ vendorTypes, isLoadingVendorTypes, businessRegistrationFiles, setBusinessRegistrationFiles,
+ isoCertificationFiles, setIsoCertificationFiles, creditReportFiles, setCreditReportFiles,
+ bankAccountFiles, setBankAccountFiles, getPhonePlaceholder, getPhoneDescription,
+ enhancedCountryArray, contactTaskOptions, lng, policyVersions
+}) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // 담당자 관리 함수들
+ const addContact = () => {
+ onChange(prev => ({
+ ...prev,
+ contacts: [...prev.contacts, {
+ contactName: "",
+ contactPosition: "",
+ contactDepartment: "",
+ contactTask: "",
+ contactEmail: "",
+ contactPhone: "",
+ }]
+ }));
+ };
+
+ const removeContact = (index) => {
+ onChange(prev => ({
+ ...prev,
+ contacts: prev.contacts.filter((_, i) => i !== index)
+ }));
+ };
+
+ const updateContact = (index, field, value) => {
+ onChange(prev => ({
+ ...prev,
+ contacts: prev.contacts.map((contact, i) =>
+ i === index ? { ...contact, [field]: value } : contact
+ )
+ }));
+ };
+
+ // 폼 입력 변경 핸들러
+ const handleInputChange = (field, value) => {
+ onChange(prev => ({ ...prev, [field]: value }));
+ };
+
+ // 파일 업로드 핸들러들
+ const createFileUploadHandler = (setFiles, currentFiles) => ({
+ onDropAccepted: (acceptedFiles) => {
+ const newFiles = [...currentFiles, ...acceptedFiles];
+ setFiles(newFiles);
},
- mode: "onChange",
- })
+ onDropRejected: (fileRejections) => {
+ fileRejections.forEach((rej) => {
+ toast({
+ variant: "destructive",
+ title: "File Error",
+ description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`,
+ });
+ });
+ },
+ removeFile: (index) => {
+ const updated = [...currentFiles];
+ updated.splice(index, 1);
+ setFiles(updated);
+ }
+ });
- // Custom validation for file uploads
+ const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles);
+ const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles);
+ const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles);
+ const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles);
+
+ // 유효성 검사
const validateRequiredFiles = () => {
- const errors = []
+ const errors = [];
if (businessRegistrationFiles.length === 0) {
- errors.push("사업자등록증을 업로드해주세요.")
+ errors.push("사업자등록증을 업로드해주세요.");
}
if (isoCertificationFiles.length === 0) {
- errors.push("ISO 인증서를 업로드해주세요.")
+ errors.push("ISO 인증서를 업로드해주세요.");
}
if (creditReportFiles.length === 0) {
- errors.push("신용평가보고서를 업로드해주세요.")
+ errors.push("신용평가보고서를 업로드해주세요.");
}
- if (form.watch("country") !== "KR" && bankAccountFiles.length === 0) {
- errors.push("대금지급 통장사본을 업로드해주세요.")
+ if (data.country !== "KR" && bankAccountFiles.length === 0) {
+ errors.push("대금지급 통장사본을 업로드해주세요.");
}
- return errors
- }
-
- const isFormValid = form.formState.isValid && validateRequiredFiles().length === 0
-
- // Field array for contacts
- const { fields: contactFields, append: addContact, remove: removeContact } =
- useFieldArray({
- control: form.control,
- name: "contacts",
- })
-
- // File upload handlers
- const createFileUploadHandler = (
- setFiles: React.Dispatch<React.SetStateAction<File[]>>,
- currentFiles: File[]
- ) => ({
- onDropAccepted: (acceptedFiles: File[]) => {
- const newFiles = [...currentFiles, ...acceptedFiles]
- setFiles(newFiles)
- },
- onDropRejected: (fileRejections: any[]) => {
- fileRejections.forEach((rej) => {
- toast({
- variant: "destructive",
- title: "File Error",
- description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`,
- })
- })
- },
- removeFile: (index: number) => {
- const updated = [...currentFiles]
- updated.splice(index, 1)
- setFiles(updated)
- }
- })
+ return errors;
+ };
- const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles)
- const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles)
- const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles)
- const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles)
+ const isFormValid = data.vendorName && data.vendorTypeId && data.items &&
+ data.country && data.phone && data.email &&
+ data.contacts.length > 0 &&
+ data.contacts[0].contactName &&
+ validateRequiredFiles().length === 0;
- // Submit
- async function onSubmit(values: CreateVendorSchema) {
- const fileErrors = validateRequiredFiles()
+ // 최종 제출
+ const handleSubmit = async () => {
+ const fileErrors = validateRequiredFiles();
if (fileErrors.length > 0) {
toast({
variant: "destructive",
title: "파일 업로드 필수",
description: fileErrors.join("\n"),
- })
- return
+ });
+ return;
}
- setIsSubmitting(true)
+ setIsSubmitting(true);
try {
- const formData = new FormData()
-
- // Add vendor data
- const vendorData = {
- vendorName: values.vendorName,
- vendorTypeId: values.vendorTypeId,
- items: values.items,
- vendorCode: values.vendorCode,
- website: values.website,
- taxId: values.taxId,
- address: values.address,
- email: values.email,
- phone: values.phone,
- country: values.country,
- status: "PENDING_REVIEW" as const,
- representativeName: values.representativeName || "",
- representativeBirth: values.representativeBirth || "",
- representativeEmail: values.representativeEmail || "",
- representativePhone: values.representativePhone || "",
- corporateRegistrationNumber: values.corporateRegistrationNumber || "",
- representativeWorkExpirence: values.representativeWorkExpirence || false
- }
+ const formData = new FormData();
+
+ // 통합 데이터 준비
+ const completeData = {
+ account: accountData,
+ vendor: {
+ ...data,
+ email: data.email || accountData.email, // 업체 이메일이 없으면 계정 이메일 사용
+ },
+ consents: {
+ privacy_policy: {
+ agreed: consentData.privacy,
+ version: policyVersions.privacy_policy
+ },
+ terms_of_service: {
+ agreed: consentData.terms,
+ version: policyVersions.terms_of_service
+ },
+ marketing: {
+ agreed: consentData.marketing,
+ version: policyVersions.privacy_policy
+ }
+ }
+ };
- formData.append('vendorData', JSON.stringify(vendorData))
- formData.append('contacts', JSON.stringify(values.contacts))
+ formData.append('completeData', JSON.stringify(completeData));
- // Add files with specific types
+ // 파일들 추가
businessRegistrationFiles.forEach(file => {
- formData.append('businessRegistration', file)
- })
+ formData.append('businessRegistration', file);
+ });
isoCertificationFiles.forEach(file => {
- formData.append('isoCertification', file)
- })
+ formData.append('isoCertification', file);
+ });
creditReportFiles.forEach(file => {
- formData.append('creditReport', file)
- })
+ formData.append('creditReport', file);
+ });
- if (values.country !== "KR") {
+ if (data.country !== "KR") {
bankAccountFiles.forEach(file => {
- formData.append('bankAccount', file)
- })
+ formData.append('bankAccount', file);
+ });
}
- const response = await fetch('/api/vendors', {
+ const response = await fetch('/api/auth/signup-with-vendor', {
method: 'POST',
body: formData,
- })
+ });
- const result = await response.json()
+ const result = await response.json();
if (response.ok) {
toast({
title: "등록 완료",
- description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)",
- })
- router.push("/ko/partners")
+ description: "회원가입 및 업체 등록이 완료되었습니다. 관리자 승인 후 서비스를 이용하실 수 있습니다.",
+ });
+ onComplete();
} else {
toast({
variant: "destructive",
title: "오류",
description: result.error || "등록에 실패했습니다.",
- })
+ });
}
- } catch (error: any) {
- console.error(error)
+ } catch (error) {
+ console.error(error);
toast({
variant: "destructive",
title: "서버 에러",
description: error.message || "에러가 발생했습니다.",
- })
+ });
} finally {
- setIsSubmitting(false)
- }
- }
-
- // Get country code for phone number placeholder
- const getPhonePlaceholder = (countryCode: string) => {
- if (!countryCode || !countryDialCodes[countryCode]) return "+82 010-1234-5678";
-
- const dialCode = countryDialCodes[countryCode];
-
- switch (countryCode) {
- case 'KR':
- return `${dialCode} 010-1234-5678`;
- case 'US':
- case 'CA':
- return `${dialCode} 555-123-4567`;
- case 'JP':
- return `${dialCode} 90-1234-5678`;
- case 'CN':
- return `${dialCode} 138-0013-8000`;
- case 'GB':
- return `${dialCode} 20-7946-0958`;
- case 'DE':
- return `${dialCode} 30-12345678`;
- case 'FR':
- return `${dialCode} 1-42-86-83-16`;
- default:
- return `${dialCode} 전화번호`;
+ setIsSubmitting(false);
}
};
- const getPhoneDescription = (countryCode: string) => {
- if (!countryCode) return "국가를 먼저 선택해주세요.";
-
- const dialCode = countryDialCodes[countryCode];
-
- switch (countryCode) {
- case 'KR':
- return `${dialCode}로 시작하는 국제번호 또는 010으로 시작하는 국내번호를 입력하세요.`;
- case 'US':
- case 'CA':
- return `${dialCode}로 시작하는 10자리 번호를 입력하세요.`;
- case 'JP':
- return `${dialCode}로 시작하는 일본 전화번호를 입력하세요.`;
- case 'CN':
- return `${dialCode}로 시작하는 중국 전화번호를 입력하세요.`;
- default:
- return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`;
- }
- };
+ return (
+ <div className="space-y-8">
+ <div>
+ <h2 className="text-xl font-semibold mb-2">업체 정보 등록</h2>
+ <p className="text-gray-600 text-sm">
+ 업체 정보와 필요한 서류를 등록해주세요. 모든 정보는 관리자 검토 후 승인됩니다.
+ </p>
+ </div>
- // File display component
- const FileUploadSection = ({
- title,
- description,
- files,
- onDropAccepted,
- onDropRejected,
- removeFile,
- required = true
- }: {
- title: string;
- description: string;
- files: File[];
- onDropAccepted: (files: File[]) => void;
- onDropRejected: (rejections: any[]) => void;
- removeFile: (index: number) => void;
- required?: boolean;
- }) => (
+ {/* 기본 정보 */}
+ <div className="rounded-md border p-6 space-y-4">
+ <h4 className="text-md font-semibold">기본 정보</h4>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 업체 유형 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 업체유형 <span className="text-red-500">*</span>
+ </label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !data.vendorTypeId && "text-muted-foreground"
+ )}
+ disabled={isSubmitting || isLoadingVendorTypes}
+ >
+ {isLoadingVendorTypes
+ ? "Loading..."
+ : vendorTypes.find(type => type.id === data.vendorTypeId)?.[lng === "ko" ? "nameKo" : "nameEn"] || "업체유형 선택"}
+ <ChevronsUpDown className="ml-2 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput placeholder="업체유형 검색..." />
+ <CommandList>
+ <CommandEmpty>No vendor type found.</CommandEmpty>
+ <CommandGroup>
+ {vendorTypes.map((type) => (
+ <CommandItem
+ key={type.id}
+ value={lng === "ko" ? type.nameKo : type.nameEn}
+ onSelect={() => handleInputChange('vendorTypeId', type.id)}
+ >
+ <Check
+ className={cn(
+ "mr-2",
+ type.id === data.vendorTypeId
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {lng === "ko" ? type.nameKo : type.nameEn}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 업체명 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 업체명 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.vendorName}
+ onChange={(e) => handleInputChange('vendorName', e.target.value)}
+ disabled={isSubmitting}
+ />
+ <p className="text-xs text-gray-500 mt-1">
+ {data.country === "KR"
+ ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요."
+ : "해외 업체의 경우 영문 회사명을 입력하세요."}
+ </p>
+ </div>
+
+ {/* 공급품목 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 공급품목 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.items}
+ onChange={(e) => handleInputChange('items', e.target.value)}
+ disabled={isSubmitting}
+ />
+ <p className="text-xs text-gray-500 mt-1">
+ 공급 가능한 제품/서비스를 입력하세요
+ </p>
+ </div>
+
+ {/* 사업자등록번호 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 사업자등록번호 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.taxId}
+ onChange={(e) => handleInputChange('taxId', e.target.value)}
+ disabled={isSubmitting}
+ placeholder="123-45-67890"
+ />
+ </div>
+
+ {/* 주소 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">주소</label>
+ <Input
+ value={data.address}
+ onChange={(e) => handleInputChange('address', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+
+ {/* 국가 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 국가 <span className="text-red-500">*</span>
+ </label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !data.country && "text-muted-foreground"
+ )}
+ disabled={isSubmitting}
+ >
+ {enhancedCountryArray.find(c => c.code === data.country)?.label || "국가 선택"}
+ <ChevronsUpDown className="ml-2 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput placeholder="국가 검색..." />
+ <CommandList>
+ <CommandEmpty>No country found.</CommandEmpty>
+ <CommandGroup>
+ {enhancedCountryArray.map((country) => (
+ <CommandItem
+ key={country.code}
+ value={country.label}
+ onSelect={() => handleInputChange('country', country.code)}
+ >
+ <Check
+ className={cn(
+ "mr-2",
+ country.code === data.country
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {country.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 대표 전화 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 대표 전화 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.phone}
+ onChange={(e) => handleInputChange('phone', e.target.value)}
+ placeholder={getPhonePlaceholder(data.country)}
+ disabled={isSubmitting}
+ />
+ <p className="text-xs text-gray-500 mt-1">
+ {getPhoneDescription(data.country)}
+ </p>
+ </div>
+
+ {/* 대표 이메일 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 대표 이메일 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.email}
+ onChange={(e) => handleInputChange('email', e.target.value)}
+ disabled={isSubmitting}
+ placeholder={accountData.email}
+ />
+ <p className="text-xs text-gray-500 mt-1">
+ 비워두면 계정 이메일({accountData.email})을 사용합니다.
+ </p>
+ </div>
+
+ {/* 웹사이트 */}
+ <div>
+ <label className="block text-sm font-medium mb-1">웹사이트</label>
+ <Input
+ value={data.website}
+ onChange={(e) => handleInputChange('website', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 담당자 정보 */}
+ <div className="rounded-md border p-6 space-y-4">
+ <div className="flex items-center justify-between">
+ <h4 className="text-md font-semibold">담당자 정보 (최소 1명)</h4>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addContact}
+ disabled={isSubmitting}
+ >
+ <Plus className="mr-1 h-4 w-4" />
+ 담당자 추가
+ </Button>
+ </div>
+
+ <div className="space-y-4">
+ {data.contacts.map((contact, index) => (
+ <div
+ key={index}
+ className="bg-muted/10 rounded-md p-4 space-y-4"
+ >
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 담당자명 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={contact.contactName}
+ onChange={(e) => updateContact(index, 'contactName', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 직급 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={contact.contactPosition}
+ onChange={(e) => updateContact(index, 'contactPosition', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 부서 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={contact.contactDepartment}
+ onChange={(e) => updateContact(index, 'contactDepartment', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 담당업무 <span className="text-red-500">*</span>
+ </label>
+ <Select
+ value={contact.contactTask}
+ onValueChange={(value) => updateContact(index, 'contactTask', value)}
+ disabled={isSubmitting}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="담당업무를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {contactTaskOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 이메일 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={contact.contactEmail}
+ onChange={(e) => updateContact(index, 'contactEmail', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 전화번호 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={contact.contactPhone}
+ onChange={(e) => updateContact(index, 'contactPhone', e.target.value)}
+ placeholder={getPhonePlaceholder(data.country)}
+ disabled={isSubmitting}
+ />
+ </div>
+ </div>
+
+ {data.contacts.length > 1 && (
+ <div className="flex justify-end">
+ <Button
+ variant="destructive"
+ onClick={() => removeContact(index)}
+ disabled={isSubmitting}
+ >
+ <X className="mr-1 h-4 w-4" />
+ 삭제
+ </Button>
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* 한국 사업자 정보 */}
+ {data.country === "KR" && (
+ <div className="rounded-md border p-6 space-y-4">
+ <h4 className="text-md font-semibold">한국 사업자 정보</h4>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 대표자 이름 <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">
+ 대표자 생년월일 <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">
+ 대표자 이메일 <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">
+ 대표자 전화번호 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ value={data.representativePhone}
+ onChange={(e) => handleInputChange('representativePhone', e.target.value)}
+ disabled={isSubmitting}
+ />
+ </div>
+ <div>
+ <label className="block text-sm font-medium mb-1">
+ 법인등록번호 <span className="text-red-500">*</span>
+ </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">
+ 대표자 삼성중공업 근무이력
+ </label>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 필수 첨부 서류 */}
+ <div className="rounded-md border p-6 space-y-6">
+ <h4 className="text-md font-semibold">필수 첨부 서류</h4>
+
+ <FileUploadSection
+ title="사업자등록증"
+ description="사업자등록증 스캔본 또는 사진을 업로드해주세요."
+ files={businessRegistrationFiles}
+ onDropAccepted={businessRegistrationHandler.onDropAccepted}
+ onDropRejected={businessRegistrationHandler.onDropRejected}
+ removeFile={businessRegistrationHandler.removeFile}
+ isSubmitting={isSubmitting}
+ />
+
+ <FileUploadSection
+ title="ISO 인증서"
+ description="ISO 9001, ISO 14001 등 품질/환경 관리 인증서를 업로드해주세요."
+ files={isoCertificationFiles}
+ onDropAccepted={isoCertificationHandler.onDropAccepted}
+ onDropRejected={isoCertificationHandler.onDropRejected}
+ removeFile={isoCertificationHandler.removeFile}
+ isSubmitting={isSubmitting}
+ />
+
+ <FileUploadSection
+ title="신용평가보고서"
+ description="신용평가기관에서 발급한 발행 1년 이내의 신용평가보고서를 업로드해주세요."
+ files={creditReportFiles}
+ onDropAccepted={creditReportHandler.onDropAccepted}
+ onDropRejected={creditReportHandler.onDropRejected}
+ removeFile={creditReportHandler.removeFile}
+ isSubmitting={isSubmitting}
+ />
+
+ {data.country !== "KR" && (
+ <FileUploadSection
+ title="대금지급 통장사본"
+ description="대금 지급용 은행 계좌의 통장 사본 또는 계좌증명서를 업로드해주세요."
+ files={bankAccountFiles}
+ onDropAccepted={bankAccountHandler.onDropAccepted}
+ onDropRejected={bankAccountHandler.onDropRejected}
+ removeFile={bankAccountHandler.removeFile}
+ isSubmitting={isSubmitting}
+ />
+ )}
+ </div>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={onBack}>
+ 이전
+ </Button>
+ <Button onClick={handleSubmit} disabled={!isFormValid || isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 등록 중...
+ </>
+ ) : (
+ "등록 완료"
+ )}
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+// 파일 업로드 섹션 컴포넌트
+function FileUploadSection({
+ title,
+ description,
+ files,
+ onDropAccepted,
+ onDropRejected,
+ removeFile,
+ isSubmitting,
+ required = true
+}) {
+ return (
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium">
@@ -517,654 +1285,5 @@ export function JoinForm() {
</div>
)}
</div>
- )
-
- // Render
- return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-md border bg-background shadow-sm">
- <div className="p-6 md:p-10 space-y-6">
- <div className="space-y-2">
- <h3 className="text-xl font-semibold">
- {defaultTaxId}{" "}
- {t("joinForm.title", {
- defaultValue: "Vendor Administrator Creation",
- })}
- </h3>
- <p className="text-sm text-muted-foreground">
- {t("joinForm.description", {
- defaultValue:
- "Please provide basic company information and attach any required documents (e.g., business registration). We will review and approve as soon as possible.",
- })}
- </p>
- </div>
-
- <Separator />
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
- {/* ─────────────────────────────────────────
- Basic Info
- ───────────────────────────────────────── */}
- <div className="rounded-md border p-4 space-y-4">
- <h4 className="text-md font-semibold">기본 정보</h4>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- {/* Vendor Type */}
- <FormField
- control={form.control}
- name="vendorTypeId"
- render={({ field }) => {
- const selectedType = vendorTypes.find(type => type.id === field.value);
- const displayName = lng === "ko" ?
- (selectedType?.nameKo || "") :
- (selectedType?.nameEn || "");
-
- return (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 업체유형
- </FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- className={cn(
- "w-full justify-between",
- !field.value && "text-muted-foreground"
- )}
- disabled={isSubmitting || isLoadingVendorTypes}
- >
- {isLoadingVendorTypes
- ? "Loading..."
- : displayName || "업체유형 선택"}
- <ChevronsUpDown className="ml-2 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0">
- <Command>
- <CommandInput placeholder="업체유형 검색..." />
- <CommandList>
- <CommandEmpty>No vendor type found.</CommandEmpty>
- <CommandGroup>
- {vendorTypes.map((type) => (
- <CommandItem
- key={type.id}
- value={lng === "ko" ? type.nameKo : type.nameEn}
- onSelect={() => field.onChange(type.id)}
- >
- <Check
- className={cn(
- "mr-2",
- type.id === field.value
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- {lng === "ko" ? type.nameKo : type.nameEn}
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- );
- }}
- />
-
- {/* vendorName */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 업체명
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormDescription>
- {form.watch("country") === "KR"
- ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요."
- : "해외 업체의 경우 영문 회사명을 입력하세요."}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Items */}
- <FormField
- control={form.control}
- name="items"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 공급품목
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormDescription>
- 공급 가능한 제품/서비스를 입력하세요
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Address */}
- <FormField
- control={form.control}
- name="address"
- render={({ field }) => (
- <FormItem>
- <FormLabel>주소</FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Country */}
- <FormField
- control={form.control}
- name="country"
- render={({ field }) => {
- const selectedCountry = enhancedCountryArray.find(
- (c) => c.code === field.value
- )
- return (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 국가
- </FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- className={cn(
- "w-full justify-between",
- !field.value && "text-muted-foreground"
- )}
- disabled={isSubmitting}
- >
- {selectedCountry
- ? selectedCountry.label
- : "국가 선택"}
- <ChevronsUpDown className="ml-2 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-full p-0">
- <Command>
- <CommandInput placeholder="국가 검색..." />
- <CommandList>
- <CommandEmpty>No country found.</CommandEmpty>
- <CommandGroup>
- {enhancedCountryArray.map((country) => (
- <CommandItem
- key={country.code}
- value={country.label}
- onSelect={() =>
- field.onChange(country.code)
- }
- >
- <Check
- className={cn(
- "mr-2",
- country.code === field.value
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- {country.label}
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )
- }}
- />
- {/* Phone */}
- <FormField
- control={form.control}
- name="phone"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 대표 전화
- </FormLabel>
- <FormControl>
- <Input
- {...field}
- placeholder={getPhonePlaceholder(form.watch("country"))}
- disabled={isSubmitting}
- className={cn(
- form.formState.errors.phone && "border-red-500"
- )}
- />
- </FormControl>
- <FormDescription className="text-xs text-muted-foreground">
- {getPhoneDescription(form.watch("country"))}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Email */}
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 대표 이메일
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormDescription>
- 회사 도메인 이메일을 사용하세요. (naver.com, gmail.com, daum.net 등의 개인 이메일은 지양해주세요)
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Website */}
- <FormField
- control={form.control}
- name="website"
- render={({ field }) => (
- <FormItem>
- <FormLabel>웹사이트</FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* ─────────────────────────────────────────
- 담당자 정보 (contacts)
- ───────────────────────────────────────── */}
- <div className="rounded-md border p-4 space-y-4">
- <div className="flex items-center justify-between">
- <h4 className="text-md font-semibold">담당자 정보 (최소 1명)</h4>
- <Button
- type="button"
- variant="outline"
- onClick={() =>
- addContact({
- contactName: "",
- contactPosition: "",
- contactDepartment: "",
- contactTask: "",
- contactEmail: "",
- contactPhone: "",
- })
- }
- disabled={isSubmitting}
- >
- <Plus className="mr-1 h-4 w-4" />
- Add Contact
- </Button>
- </div>
-
- <div className="space-y-2">
- {contactFields.map((contact, index) => (
- <div
- key={contact.id}
- className="bg-muted/10 rounded-md p-4 space-y-4"
- >
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {/* contactName */}
- <FormField
- control={form.control}
- name={`contacts.${index}.contactName`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 담당자명
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* contactPosition */}
- <FormField
- control={form.control}
- name={`contacts.${index}.contactPosition`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 직급
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* contactDepartment */}
- <FormField
- control={form.control}
- name={`contacts.${index}.contactDepartment`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 부서
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* contactTask - Dropdown */}
- <FormField
- control={form.control}
- name={`contacts.${index}.contactTask`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 담당업무
- </FormLabel>
- <Select onValueChange={field.onChange} value={field.value} disabled={isSubmitting}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="담당업무를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {contactTaskOptions.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* contactEmail */}
- <FormField
- control={form.control}
- name={`contacts.${index}.contactEmail`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 이메일
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* contactPhone */}
- <FormField
- control={form.control}
- name={`contacts.${index}.contactPhone`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 전화번호
- </FormLabel>
- <FormControl>
- <Input
- {...field}
- placeholder={getPhonePlaceholder(form.watch("country"))}
- disabled={isSubmitting}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* Remove contact button row */}
- {contactFields.length > 1 && (
- <div className="flex justify-end">
- <Button
- variant="destructive"
- onClick={() => removeContact(index)}
- disabled={isSubmitting}
- >
- <X className="mr-1 h-4 w-4" />
- Remove
- </Button>
- </div>
- )}
- </div>
- ))}
- </div>
- </div>
-
- {/* ─────────────────────────────────────────
- 한국 사업자 (country === "KR")
- ───────────────────────────────────────── */}
- {form.watch("country") === "KR" && (
- <div className="rounded-md border p-4 space-y-4">
- <h4 className="text-md font-semibold">한국 사업자 정보</h4>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="representativeName"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 대표자 이름
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="representativeBirth"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 대표자 생년월일
- </FormLabel>
- <FormControl>
- <Input
- placeholder="YYYY-MM-DD"
- {...field}
- disabled={isSubmitting}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="representativeEmail"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 대표자 이메일
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="representativePhone"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 대표자 전화번호
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="corporateRegistrationNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- 법인등록번호
- </FormLabel>
- <FormControl>
- <Input {...field} disabled={isSubmitting} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="representativeWorkExpirence"
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={isSubmitting}
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel>
- 대표자 삼성중공업 근무이력
- </FormLabel>
- <FormDescription>
- 대표자가 삼성중공업에서 근무한 경험이 있는 경우 체크해주세요.
- </FormDescription>
- </div>
- </FormItem>
- )}
- />
-
- </div>
- </div>
- )}
-
- {/* ─────────────────────────────────────────
- Required Document Uploads
- ───────────────────────────────────────── */}
- <div className="rounded-md border p-4 space-y-6">
- <h4 className="text-md font-semibold">필수 첨부 서류</h4>
-
- {/* Business Registration */}
- <FileUploadSection
- title="사업자등록증"
- description="사업자등록증 스캔본 또는 사진을 업로드해주세요. 모든 내용이 선명하게 보여야 합니다."
- files={businessRegistrationFiles}
- onDropAccepted={businessRegistrationHandler.onDropAccepted}
- onDropRejected={businessRegistrationHandler.onDropRejected}
- removeFile={businessRegistrationHandler.removeFile}
- />
-
- <Separator />
-
- {/* ISO Certification */}
- <FileUploadSection
- title="ISO 인증서"
- description="ISO 9001, ISO 14001 등 품질/환경 관리 인증서를 업로드해주세요. 유효기간이 확인 가능해야 합니다."
- files={isoCertificationFiles}
- onDropAccepted={isoCertificationHandler.onDropAccepted}
- onDropRejected={isoCertificationHandler.onDropRejected}
- removeFile={isoCertificationHandler.removeFile}
- />
-
- <Separator />
-
- {/* Credit Report */}
- <FileUploadSection
- title="신용평가보고서"
- description="신용평가기관(KIS, NICE 등)에서 발급한 발행 1년 이내의 신용평가보고서를 업로드해주세요. 전년도 재무제표 필수표시. 신규업체, 영세업체로 재무제표 및 신용평가 결과가 없을 경우는 국세, 지방세 납입 증명으로 신용평가를 갈음할 수 있음"
- files={creditReportFiles}
- onDropAccepted={creditReportHandler.onDropAccepted}
- onDropRejected={creditReportHandler.onDropRejected}
- removeFile={creditReportHandler.removeFile}
- />
-
- {/* Bank Account Copy - Only for non-Korean companies */}
- {form.watch("country") !== "KR" && (
- <>
- <Separator />
- <FileUploadSection
- title="대금지급 통장사본"
- description="대금 지급용 은행 계좌의 통장 사본 또는 계좌증명서를 업로드해주세요. 계좌번호와 예금주명이 명확히 보여야 합니다."
- files={bankAccountFiles}
- onDropAccepted={bankAccountHandler.onDropAccepted}
- onDropRejected={bankAccountHandler.onDropRejected}
- removeFile={bankAccountHandler.removeFile}
- />
- </>
- )}
- </div>
-
- {/* ─────────────────────────────────────────
- Submit
- ───────────────────────────────────────── */}
- <div className="flex justify-end">
- <Button type="submit" disabled={!isFormValid || isSubmitting}>
- {isSubmitting ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 등록 중...
- </>
- ) : (
- "Submit"
- )}
- </Button>
- </div>
- </form>
- </Form>
- </div>
- </section>
- </div>
- )
-} \ No newline at end of file
+ );
+}