浏览代码

+ 해지 승인 인플루언서 벤더 분리

송용우 4 月之前
父节点
当前提交
a2b88dc771

+ 41 - 43
.cursor/rules/api-rule.mdc

@@ -2,6 +2,7 @@
 alwaysApply: true
 ---
 
+
 # 최우선 규칙: 한글 응답 필수
 
 **모든 응답은 한글로만 작성해야 함. 이 규칙은 다른 모든 규칙보다 우선한다.**
@@ -22,38 +23,32 @@ alwaysApply: true
 // 처리자 확인 (벤더사 SEQ인지 사용자 SEQ인지 확인)
 $processingUser = null;
 
-// 1. 먼저 USER_LIST에서 확인
+// 1. 먼저 USER_LIST에서 확인 (인플루언서)
 $processingUser = $this->userModel
     ->where('SEQ', $processedBy)
     ->where('IS_ACT', 'Y')
     ->first();
 
 if ($processingUser) {
-    // 사용자 SEQ인 경우 바로 사용
+    // 사용자 SEQ인 경우 (인플루언서) - 바로 사용
     $approvedByUserSeq = $processedBy;
 } else {
-    // 2. processedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+    // 2. VENDOR_LIST에서 확인 (벤더사)
     $vendorInfo = $this->vendorModel
         ->where('SEQ', $processedBy)
         ->where('IS_ACT', 'Y')
         ->first();
     
-    if ($vendorInfo && !empty($vendorInfo['COMPANY_NUMBER'])) {
-        // 벤더사의 COMPANY_NUMBER로 해당 담당자 USER 찾기
-        $vendorUser = $this->userModel
-            ->where('COMPANY_NUMBER', $vendorInfo['COMPANY_NUMBER'])
-            ->where('IS_ACT', 'Y')
-            ->first();
+    if ($vendorInfo) {
+        // 벤더사 SEQ인 경우 - 벤더사가 직접 처리하는 것으로 간주
+        $approvedByUserSeq = $processedBy;
         
-        if ($vendorUser) {
-            $approvedByUserSeq = $vendorUser['SEQ'];
-            $processingUser = $vendorUser;
-        } else {
-            return $this->response->setStatusCode(400)->setJSON([
-                'success' => false,
-                'message' => "벤더사 SEQ {$processedBy}에 해당하는 담당자를 USER_LIST에서 찾을 수 없습니다."
-            ]);
-        }
+        // 응답용 정보 설정 (필요시)
+        $processingUser = [
+            'SEQ' => $vendorInfo['SEQ'],
+            'NICK_NAME' => $vendorInfo['COMPANY_NAME'] . ' (벤더사)',
+            'NAME' => $vendorInfo['COMPANY_NAME']
+        ];
     } else {
         return $this->response->setStatusCode(400)->setJSON([
             'success' => false,
@@ -72,9 +67,10 @@ if ($processingUser) {
 - **기타 모든 사용자 인증이 필요한 벤더-인플루언서 관련 API**
 
 ## 이유
-- 프론트엔드에서 벤더사 SEQ를 전달할 수 있음
-- 데이터베이스 외래키는 USER_LIST.SEQ를 참조함
-- 벤더사 SEQ → 해당 벤더사 담당자 USER SEQ 변환 필요
+- **인플루언서**: USER_LIST 테이블에서 개인 계정으로 관리
+- **벤더사**: VENDOR_LIST 테이블에서 회사 계정으로 관리
+- 두 시스템을 구분하여 처리하되, 데이터베이스 저장 시에는 해당 SEQ를 그대로 사용
+- USER_LIST에는 COMPANY_NUMBER 컬럼이 불필요함 (인플루언서는 개인이므로)
 
 # API & Store Rules
 
@@ -223,7 +219,11 @@ const loadData = async () => {
 - 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어
 - useAxios()를 통한 HTTP 통신만 사용
 - 응답 형태: `res.data.success`, `res.data.data`, `res.data.message`
-- 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어# 최우선 규칙: 한글 응답 필수
+- 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어
+- useAxios()를 통한 HTTP 통신만 사용
+- 응답 형태: `res.data.success`, `res.data.data`, `res.data.message`
+- 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어
+# 최우선 규칙: 한글 응답 필수
 
 **모든 응답은 한글로만 작성해야 함. 이 규칙은 다른 모든 규칙보다 우선한다.**
 
@@ -243,38 +243,32 @@ const loadData = async () => {
 // 처리자 확인 (벤더사 SEQ인지 사용자 SEQ인지 확인)
 $processingUser = null;
 
-// 1. 먼저 USER_LIST에서 확인
+// 1. 먼저 USER_LIST에서 확인 (인플루언서)
 $processingUser = $this->userModel
     ->where('SEQ', $processedBy)
     ->where('IS_ACT', 'Y')
     ->first();
 
 if ($processingUser) {
-    // 사용자 SEQ인 경우 바로 사용
+    // 사용자 SEQ인 경우 (인플루언서) - 바로 사용
     $approvedByUserSeq = $processedBy;
 } else {
-    // 2. processedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+    // 2. VENDOR_LIST에서 확인 (벤더사)
     $vendorInfo = $this->vendorModel
         ->where('SEQ', $processedBy)
         ->where('IS_ACT', 'Y')
         ->first();
     
-    if ($vendorInfo && !empty($vendorInfo['COMPANY_NUMBER'])) {
-        // 벤더사의 COMPANY_NUMBER로 해당 담당자 USER 찾기
-        $vendorUser = $this->userModel
-            ->where('COMPANY_NUMBER', $vendorInfo['COMPANY_NUMBER'])
-            ->where('IS_ACT', 'Y')
-            ->first();
+    if ($vendorInfo) {
+        // 벤더사 SEQ인 경우 - 벤더사가 직접 처리하는 것으로 간주
+        $approvedByUserSeq = $processedBy;
         
-        if ($vendorUser) {
-            $approvedByUserSeq = $vendorUser['SEQ'];
-            $processingUser = $vendorUser;
-        } else {
-            return $this->response->setStatusCode(400)->setJSON([
-                'success' => false,
-                'message' => "벤더사 SEQ {$processedBy}에 해당하는 담당자를 USER_LIST에서 찾을 수 없습니다."
-            ]);
-        }
+        // 응답용 정보 설정 (필요시)
+        $processingUser = [
+            'SEQ' => $vendorInfo['SEQ'],
+            'NICK_NAME' => $vendorInfo['COMPANY_NAME'] . ' (벤더사)',
+            'NAME' => $vendorInfo['COMPANY_NAME']
+        ];
     } else {
         return $this->response->setStatusCode(400)->setJSON([
             'success' => false,
@@ -293,9 +287,10 @@ if ($processingUser) {
 - **기타 모든 사용자 인증이 필요한 벤더-인플루언서 관련 API**
 
 ## 이유
-- 프론트엔드에서 벤더사 SEQ를 전달할 수 있음
-- 데이터베이스 외래키는 USER_LIST.SEQ를 참조함
-- 벤더사 SEQ → 해당 벤더사 담당자 USER SEQ 변환 필요
+- **인플루언서**: USER_LIST 테이블에서 개인 계정으로 관리
+- **벤더사**: VENDOR_LIST 테이블에서 회사 계정으로 관리
+- 두 시스템을 구분하여 처리하되, 데이터베이스 저장 시에는 해당 SEQ를 그대로 사용
+- USER_LIST에는 COMPANY_NUMBER 컬럼이 불필요함 (인플루언서는 개인이므로)
 
 # API & Store Rules
 
@@ -444,4 +439,7 @@ const loadData = async () => {
 - 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어
 - useAxios()를 통한 HTTP 통신만 사용
 - 응답 형태: `res.data.success`, `res.data.data`, `res.data.message`
+- 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어
+- useAxios()를 통한 HTTP 통신만 사용
+- 응답 형태: `res.data.success`, `res.data.data`, `res.data.message`
 - 백엔드는 직접 만들거야 다만 너가 backend-examples 폴더에 프론트와 수신할수는있는 형태의 api예제를 만들어

+ 182 - 0
backend-examples/vendor-influencer-reapply.php

@@ -0,0 +1,182 @@
+<?php
+
+/**
+ * 벤더-인플루언서 재승인요청 API 예제
+ * 
+ * 기능: 해지된 파트너십에 대한 재계약 요청 처리
+ * 경로: POST /api/vendor-influencer/reapply-request
+ */
+
+namespace App\Controllers;
+
+use App\Controllers\BaseController;
+use App\Models\VendorModel;
+use App\Models\UserModel;
+use App\Models\VendorInfluencerMappingModel;
+use CodeIgniter\HTTP\ResponseInterface;
+
+class VendorInfluencerController extends BaseController
+{
+    protected $vendorModel;
+    protected $userModel;
+    protected $vendorInfluencerModel;
+    
+    public function __construct()
+    {
+        $this->vendorModel = new VendorModel();
+        $this->userModel = new UserModel();
+        $this->vendorInfluencerModel = new VendorInfluencerMappingModel();
+    }
+    
+    /**
+     * 재승인 요청 (해지된 파트너십에 대한 재계약 요청)
+     * 
+     * @route POST /api/vendor-influencer/reapply-request
+     * @param int vendorSeq 벤더사 SEQ
+     * @param int influencerSeq 인플루언서 SEQ  
+     * @param string requestMessage 요청 메시지
+     * @param int requestedBy 요청자 SEQ (인플루언서)
+     * 
+     * @return JSON
+     */
+    public function reapplyRequest()
+    {
+        try {
+            $request = $this->request->getJSON();
+            
+            $vendorSeq = $request->vendorSeq ?? null;
+            $influencerSeq = $request->influencerSeq ?? null;
+            $requestMessage = $request->requestMessage ?? '';
+            $requestedBy = $request->requestedBy ?? null;
+            
+            // 필수 파라미터 검증
+            if (!$vendorSeq || !$influencerSeq || !$requestedBy) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '필수 파라미터가 누락되었습니다.'
+                ]);
+            }
+            
+            // 기존 해지된 파트너십 확인
+            $terminatedPartnership = $this->vendorInfluencerModel
+                ->where('VENDOR_SEQ', $vendorSeq)
+                ->where('INFLUENCER_SEQ', $influencerSeq)
+                ->where('STATUS', 'TERMINATED')
+                ->where('IS_ACT', 'Y')
+                ->orderBy('REG_DATE', 'DESC')
+                ->first();
+            
+            if (!$terminatedPartnership) {
+                return $this->response->setStatusCode(404)->setJSON([
+                    'success' => false,
+                    'message' => '해지된 파트너십 기록을 찾을 수 없습니다.'
+                ]);
+            }
+            
+            // 현재 처리 중인 요청이 있는지 확인
+            $existingPendingRequest = $this->vendorInfluencerModel
+                ->where('VENDOR_SEQ', $vendorSeq)
+                ->where('INFLUENCER_SEQ', $influencerSeq)
+                ->where('STATUS', 'PENDING')
+                ->where('IS_ACT', 'Y')
+                ->first();
+            
+            if ($existingPendingRequest) {
+                return $this->response->setStatusCode(409)->setJSON([
+                    'success' => false,
+                    'message' => '이미 처리 중인 승인 요청이 있습니다.'
+                ]);
+            }
+            
+            // 재승인 요청 생성
+            $reapplyData = [
+                'VENDOR_SEQ' => $vendorSeq,
+                'INFLUENCER_SEQ' => $influencerSeq,
+                'REQUEST_TYPE' => 'INFLUENCER_REQUEST',
+                'STATUS' => 'PENDING',
+                'REQUEST_MESSAGE' => '[재계약 요청] ' . $requestMessage,
+                'REQUESTED_BY' => $requestedBy,
+                'COMMISSION_RATE' => $terminatedPartnership['COMMISSION_RATE'], // 이전 수수료율 유지
+                'SPECIAL_CONDITIONS' => $terminatedPartnership['SPECIAL_CONDITIONS'], // 이전 특별조건 유지
+                'EXPIRED_DATE' => date('Y-m-d H:i:s', strtotime('+7 days')),
+                'ADD_INFO1' => 'REAPPLY', // 재신청 구분자
+                'ADD_INFO2' => $terminatedPartnership['SEQ'], // 이전 파트너십 SEQ 참조
+                'ADD_INFO3' => date('Y-m-d H:i:s') // 재신청 일시
+            ];
+            
+            $insertId = $this->vendorInfluencerModel->insert($reapplyData);
+            
+            // 생성된 재승인 요청 정보 조회
+            $createdReapply = $this->vendorInfluencerModel
+                ->select('vim.*, v.COMPANY_NAME as vendorName, u.NICK_NAME as influencerName, req_user.NICK_NAME as requestedByName')
+                ->from('VENDOR_INFLUENCER_MAPPING vim')
+                ->join('VENDOR_LIST v', 'vim.VENDOR_SEQ = v.SEQ', 'left')
+                ->join('USER_LIST u', 'vim.INFLUENCER_SEQ = u.SEQ', 'left')
+                ->join('USER_LIST req_user', 'vim.REQUESTED_BY = req_user.SEQ', 'left')
+                ->where('vim.SEQ', $insertId)
+                ->get()
+                ->getRowArray();
+            
+            return $this->response->setJSON([
+                'success' => true,
+                'message' => '재승인 요청이 성공적으로 생성되었습니다.',
+                'data' => [
+                    'reapplyRequest' => $createdReapply,
+                    'previousPartnership' => $terminatedPartnership
+                ]
+            ]);
+            
+        } catch (\Exception $e) {
+            return $this->response->setStatusCode(500)->setJSON([
+                'success' => false,
+                'message' => '재승인 요청 생성 중 오류가 발생했습니다.',
+                'error' => ENVIRONMENT === 'development' ? $e->getMessage() : null
+            ]);
+        }
+    }
+}
+
+/*
+사용 예시:
+
+POST /api/vendor-influencer/reapply-request
+Content-Type: application/json
+
+{
+    "vendorSeq": 8,
+    "influencerSeq": 15,
+    "requestMessage": "이전 계약이 만료되어 재계약을 요청드립니다. 새로운 프로모션 진행을 위해 파트너십을 재개하고 싶습니다.",
+    "requestedBy": 15
+}
+
+응답 예시:
+{
+    "success": true,
+    "message": "재승인 요청이 성공적으로 생성되었습니다.",
+    "data": {
+        "reapplyRequest": {
+            "SEQ": 25,
+            "VENDOR_SEQ": 8,
+            "INFLUENCER_SEQ": 15,
+            "REQUEST_TYPE": "INFLUENCER_REQUEST",
+            "STATUS": "PENDING",
+            "REQUEST_MESSAGE": "[재계약 요청] 이전 계약이 만료되어 재계약을 요청드립니다...",
+            "REQUESTED_BY": 15,
+            "COMMISSION_RATE": 10.5,
+            "SPECIAL_CONDITIONS": "월 2회 포스팅",
+            "EXPIRED_DATE": "2024-01-20 10:30:00",
+            "ADD_INFO1": "REAPPLY",
+            "ADD_INFO2": "23",
+            "ADD_INFO3": "2024-01-13 10:30:00",
+            "vendorName": "뷰티코리아",
+            "influencerName": "뷰티블로거",
+            "requestedByName": "뷰티블로거"
+        },
+        "previousPartnership": {
+            "SEQ": 23,
+            "STATUS": "TERMINATED",
+            "PARTNERSHIP_END_DATE": "2024-01-10 15:20:00"
+        }
+    }
+}
+*/ 

+ 171 - 165
backend/app/Config/Routes.php

@@ -1,187 +1,193 @@
 <?php
-
-use CodeIgniter\Router\RouteCollection;
-
-/**
- * @var RouteCollection $routes
- */
-$routes->get('auth/googleLogin', 'Auth::googleLogin');
-$routes->get('auth/callback', 'Auth::callback');
-$routes->post('auth/joinmember', 'Auth::join');
-$routes->post('auth/joinvendor', 'Auth::joinVendor');
-$routes->post('auth/withdrawal', 'Auth::withdrawal'); //구글 회원탈퇴 , 일반회원 탈퇴
-$routes->post('auth/kakaowithdrawal', 'Auth::kakaoWithdrawal'); //카카오 회웥탈퇴
-$routes->get('auth/kakaoLogin', 'Auth::kakaoLogin');
-$routes->get('auth/kakao', 'Auth::kakao');
-$routes->get('auth/naverLogin', 'Auth::naverLogin');
-$routes->get('auth/naver', 'Auth::naver');
-$routes->get('auth/naverWithdrawal', 'Auth::naverWithdrawal');
-$routes->post('auth/checkId', 'Auth::checkId'); // 사용 중 체크 아이디
-
-$routes->get('/', 'Home::index'); //홈화면 리다이렉트용
-$routes->post('roulette/login', 'Roulette::login'); //로그인 페이지 토큰 상관없이 호출가능
-$routes->post('roulette/refreshToken', 'Roulette::refreshToken'); //엑세스 토큰이 있어야만 발급 가능
-
-$routes->get('alimtalk/send', 'Alimtalk::send');
-$routes->post('alimtalk/send', 'Alimtalk::send'); // POST 요청인 경우
-
-$routes->post('winner/reg', 'Winner::winnerReg');
-$routes->post('winner/itemcount', 'Winner::itemCount');
-$routes->post('winner/winnerchk', 'Winner::winnerChk');
+  
+  use CodeIgniter\Router\RouteCollection;
+  
+  /**
+   * @var RouteCollection $routes
+   */
+  $routes->get('auth/googleLogin', 'Auth::googleLogin');
+  $routes->get('auth/callback', 'Auth::callback');
+  $routes->post('auth/joinmember', 'Auth::join');
+  $routes->post('auth/joinvendor', 'Auth::joinVendor');
+  $routes->post('auth/withdrawal', 'Auth::withdrawal'); //구글 회원탈퇴 , 일반회원 탈퇴
+  $routes->post('auth/kakaowithdrawal', 'Auth::kakaoWithdrawal'); //카카오 회웥탈퇴
+  $routes->get('auth/kakaoLogin', 'Auth::kakaoLogin');
+  $routes->get('auth/kakao', 'Auth::kakao');
+  $routes->get('auth/naverLogin', 'Auth::naverLogin');
+  $routes->get('auth/naver', 'Auth::naver');
+  $routes->get('auth/naverWithdrawal', 'Auth::naverWithdrawal');
+  $routes->post('auth/checkId', 'Auth::checkId'); // 사용 중 체크 아이디
+  
+  $routes->get('/', 'Home::index'); //홈화면 리다이렉트용
+  $routes->post('roulette/login', 'Roulette::login'); //로그인 페이지 토큰 상관없이 호출가능
+  $routes->post('roulette/refreshToken', 'Roulette::refreshToken'); //엑세스 토큰이 있어야만 발급 가능
+  
+  $routes->get('alimtalk/send', 'Alimtalk::send');
+  $routes->post('alimtalk/send', 'Alimtalk::send'); // POST 요청인 경우
+  
+  $routes->post('winner/reg', 'Winner::winnerReg');
+  $routes->post('winner/itemcount', 'Winner::itemCount');
+  $routes->post('winner/winnerchk', 'Winner::winnerChk');
 
 // 관리자 라우트
-$routes->post('mng/list', 'Mng::mnglist');
-$routes->post('mng/search', 'Mng::mngSearch');
-$routes->post('mng/reg', 'Mng::mngRegister');
-$routes->post('mng/chk', 'Mng::mngIDChk');
-$routes->post('mng/update', 'Mng::mngUpdate');
-$routes->get('mng/detail/(:segment)', 'Mng::mngDetail/$1');
-$routes->post('mng/stupdate/(:segment)', 'Mng::mngStatusUpdate/$1');
-$routes->post('mng/delete/(:segment)', 'Mng::mngDelete/$1');
+  $routes->post('mng/list', 'Mng::mnglist');
+  $routes->post('mng/search', 'Mng::mngSearch');
+  $routes->post('mng/reg', 'Mng::mngRegister');
+  $routes->post('mng/chk', 'Mng::mngIDChk');
+  $routes->post('mng/update', 'Mng::mngUpdate');
+  $routes->get('mng/detail/(:segment)', 'Mng::mngDetail/$1');
+  $routes->post('mng/stupdate/(:segment)', 'Mng::mngStatusUpdate/$1');
+  $routes->post('mng/delete/(:segment)', 'Mng::mngDelete/$1');
 
 // 아이템 라우트
-$routes->post('item/list', 'Item::itemlist');
-$routes->post('item/reg', 'Item::itemRegister');
-$routes->get('item/detail/(:num)', 'Item::itemDetail/$1');
-$routes->post('item/update/(:num)', 'Item::itemUpdate/$1');
-$routes->post('item/delete/(:num)', 'Item::itemDelete/$1');
-$routes->post('item/search', 'Item::itemSearch');
+  $routes->post('item/list', 'Item::itemlist');
+  $routes->post('item/reg', 'Item::itemRegister');
+  $routes->get('item/detail/(:num)', 'Item::itemDetail/$1');
+  $routes->post('item/update/(:num)', 'Item::itemUpdate/$1');
+  $routes->post('item/delete/(:num)', 'Item::itemDelete/$1');
+  $routes->post('item/search', 'Item::itemSearch');
 // 파일 다운로드
-$routes->get('item/download/(:segment)', 'Item::file/$1');
+  $routes->get('item/download/(:segment)', 'Item::file/$1');
 
-// 당첨자 라우트
-$routes->post('winner/list', 'Winner::winnerlist');
-$routes->get('winner/detail/(:num)', 'Winner::winnerDetail/$1');
-$routes->post('winner/partclist', 'Winner::getParticipationByItem');
-$routes->post('winner/matcheduser', 'Winner::matchedUser');
-
-$routes->group('', ['filter' => 'auth'], function ($routes) {
-});
+// 제품 주문 라우트
+  $routes->post('deli/list', 'Deli::delilist');
+  $routes->post('deli/reg', 'Deli::deliRegister');
 
-// API 라우트 그룹
-$routes->group('api', ['namespace' => 'App\Controllers'], function($routes) {
-  
-  // 벤더사 관련 API
-  $routes->group('vendor', function($routes) {
-    $routes->post('search', 'VendorInfluencerController::searchVendors');
-    $routes->post('list', 'VendorController::getList');
-    $routes->post('detail', 'VendorController::getDetail');
-    $routes->post('create', 'VendorController::create');
-    $routes->post('update', 'VendorController::update');
-    $routes->post('delete', 'VendorController::delete');
-  });
-  
-  // 벤더사-인플루언서 매핑 관련 API
-  $routes->group('vendor-influencer', function($routes) {
-    $routes->post('request', 'VendorInfluencerController::createRequest');
-    $routes->post('requests', 'VendorInfluencerController::getList');         // 요청목록 조회 (벤더사용)
-    $routes->post('approve', 'VendorInfluencerController::approveRequest');
-    $routes->post('process', 'VendorInfluencerController::approveRequest');   // 승인/거부 처리 (통합)
-    $routes->post('terminate', 'VendorInfluencerController::terminate');      // 파트너십 해지
-    $routes->post('list', 'VendorInfluencerController::getList');
-    $routes->post('detail', 'VendorInfluencerController::getDetail');
-    $routes->post('cancel', 'VendorInfluencerController::cancelRequest');
-    $routes->post('stats', 'VendorInfluencerController::getStats');
-    $routes->post('history/(:num)', 'VendorInfluencerController::getHistory/$1');
-  });
-  
-  // 인증 관련 API
-  $routes->group('auth', function($routes) {
-    $routes->post('login', 'AuthController::login');
-    $routes->post('logout', 'AuthController::logout');
-    $routes->post('register', 'AuthController::register');
-    $routes->post('refresh', 'AuthController::refreshToken');
-    $routes->post('verify', 'AuthController::verifyToken');
-  });
-  
-  // 사용자 관련 API
-  $routes->group('user', function($routes) {
-    $routes->post('profile', 'UserController::getProfile');
-    $routes->post('update-profile', 'UserController::updateProfile');
-    $routes->post('change-password', 'UserController::changePassword');
-    $routes->post('upload-avatar', 'UserController::uploadAvatar');
-  });
-  
-  // 제품 관련 API
-  $routes->group('item', function($routes) {
-    $routes->post('list', 'ItemController::getList');
-    $routes->post('detail', 'ItemController::getDetail');
-    $routes->post('create', 'ItemController::create');
-    $routes->post('update', 'ItemController::update');
-    $routes->post('delete', 'ItemController::delete');
-    $routes->post('search', 'ItemController::search');
-  });
-  
-  // 파일 업로드 관련 API
-  $routes->group('upload', function($routes) {
-    $routes->post('image', 'UploadController::uploadImage');
-    $routes->post('file', 'UploadController::uploadFile');
-    $routes->post('multiple', 'UploadController::uploadMultiple');
-  });
+// 당첨자 라우트
+  $routes->post('winner/list', 'Winner::winnerlist');
+  $routes->get('winner/detail/(:num)', 'Winner::winnerDetail/$1');
+  $routes->post('winner/partclist', 'Winner::getParticipationByItem');
+  $routes->post('winner/matcheduser', 'Winner::matchedUser');
   
-  // 알림 관련 API
-  $routes->group('notification', function($routes) {
-    $routes->post('list', 'NotificationController::getList');
-    $routes->post('mark-read', 'NotificationController::markAsRead');
-    $routes->post('mark-all-read', 'NotificationController::markAllAsRead');
-    $routes->post('delete', 'NotificationController::delete');
+  $routes->group('', ['filter' => 'auth'], function ($routes) {
   });
-  
-  // 대시보드 관련 API
-  $routes->group('dashboard', function($routes) {
-    $routes->post('stats', 'DashboardController::getStats');
-    $routes->post('recent-activities', 'DashboardController::getRecentActivities');
-    $routes->post('chart-data', 'DashboardController::getChartData');
+
+// API 라우트 그룹
+  $routes->group('api', ['namespace' => 'App\Controllers'], function($routes) {
+    
+    // 벤더사 관련 API
+    $routes->group('vendor', function($routes) {
+      $routes->post('search', 'VendorInfluencerController::searchVendors');
+      $routes->post('list', 'VendorController::getList');
+      $routes->post('detail', 'VendorController::getDetail');
+      $routes->post('create', 'VendorController::create');
+      $routes->post('update', 'VendorController::update');
+      $routes->post('delete', 'VendorController::delete');
+    });
+    
+    // 벤더사-인플루언서 매핑 관련 API
+    $routes->group('vendor-influencer', function($routes) {
+      $routes->post('search-vendors', 'VendorInfluencerController::searchVendors'); // 벤더사 검색
+      $routes->post('request', 'VendorInfluencerController::createRequest');
+      $routes->post('requests', 'VendorInfluencerController::getList');         // 요청목록 조회 (벤더사용)
+      $routes->post('approve', 'VendorInfluencerController::approveRequest');
+      $routes->post('process', 'VendorInfluencerController::approveRequest');   // 승인/거부 처리 (통합)
+      $routes->post('terminate', 'VendorInfluencerController::terminate');      // 파트너십 해지
+      $routes->post('reapply-request', 'VendorInfluencerController::reapplyRequest'); // 재승인요청
+      $routes->post('list', 'VendorInfluencerController::getList');
+      $routes->post('detail', 'VendorInfluencerController::getDetail');
+      $routes->post('cancel', 'VendorInfluencerController::cancelRequest');
+      $routes->post('stats', 'VendorInfluencerController::getStats');
+      $routes->post('history/(:num)', 'VendorInfluencerController::getHistory/$1');
+    });
+    
+    // 인증 관련 API
+    $routes->group('auth', function($routes) {
+      $routes->post('login', 'AuthController::login');
+      $routes->post('logout', 'AuthController::logout');
+      $routes->post('register', 'AuthController::register');
+      $routes->post('refresh', 'AuthController::refreshToken');
+      $routes->post('verify', 'AuthController::verifyToken');
+    });
+    
+    // 사용자 관련 API
+    $routes->group('user', function($routes) {
+      $routes->post('profile', 'UserController::getProfile');
+      $routes->post('update-profile', 'UserController::updateProfile');
+      $routes->post('change-password', 'UserController::changePassword');
+      $routes->post('upload-avatar', 'UserController::uploadAvatar');
+    });
+    
+    // 제품 관련 API
+    $routes->group('item', function($routes) {
+      $routes->post('list', 'ItemController::getList');
+      $routes->post('detail', 'ItemController::getDetail');
+      $routes->post('create', 'ItemController::create');
+      $routes->post('update', 'ItemController::update');
+      $routes->post('delete', 'ItemController::delete');
+      $routes->post('search', 'ItemController::search');
+    });
+    
+    // 파일 업로드 관련 API
+    $routes->group('upload', function($routes) {
+      $routes->post('image', 'UploadController::uploadImage');
+      $routes->post('file', 'UploadController::uploadFile');
+      $routes->post('multiple', 'UploadController::uploadMultiple');
+    });
+    
+    // 알림 관련 API
+    $routes->group('notification', function($routes) {
+      $routes->post('list', 'NotificationController::getList');
+      $routes->post('mark-read', 'NotificationController::markAsRead');
+      $routes->post('mark-all-read', 'NotificationController::markAllAsRead');
+      $routes->post('delete', 'NotificationController::delete');
+    });
+    
+    // 대시보드 관련 API
+    $routes->group('dashboard', function($routes) {
+      $routes->post('stats', 'DashboardController::getStats');
+      $routes->post('recent-activities', 'DashboardController::getRecentActivities');
+      $routes->post('chart-data', 'DashboardController::getChartData');
+    });
   });
-});
 
 // 인증이 필요한 API 라우트 (필터 적용)
-$routes->group('api', ['namespace' => 'App\Controllers', 'filter' => 'auth'], function($routes) {
-  
-  // 보호된 벤더사-인플루언서 API
-  $routes->group('vendor-influencer/protected', function($routes) {
-    $routes->post('my-requests', 'VendorInfluencerController::getMyRequests');
-    $routes->post('my-partnerships', 'VendorInfluencerController::getMyPartnerships');
-    $routes->post('pending-approvals', 'VendorInfluencerController::getPendingApprovals');
-  });
-  
-  // 관리자 전용 API
-  $routes->group('admin', ['filter' => 'admin'], function($routes) {
-    $routes->post('vendor-influencer/all', 'AdminController::getAllMappings');
-    $routes->post('vendor-influencer/expired', 'AdminController::getExpiredRequests');
-    $routes->post('vendor-influencer/process-expired', 'AdminController::processExpiredRequests');
-    $routes->post('system/stats', 'AdminController::getSystemStats');
+  $routes->group('api', ['namespace' => 'App\Controllers', 'filter' => 'auth'], function($routes) {
+    
+    // 보호된 벤더사-인플루언서 API
+    $routes->group('vendor-influencer/protected', function($routes) {
+      $routes->post('my-requests', 'VendorInfluencerController::getMyRequests');
+      $routes->post('my-partnerships', 'VendorInfluencerController::getMyPartnerships');
+      $routes->post('pending-approvals', 'VendorInfluencerController::getPendingApprovals');
+    });
+    
+    // 관리자 전용 API
+    $routes->group('admin', ['filter' => 'admin'], function($routes) {
+      $routes->post('vendor-influencer/all', 'AdminController::getAllMappings');
+      $routes->post('vendor-influencer/expired', 'AdminController::getExpiredRequests');
+      $routes->post('vendor-influencer/process-expired', 'AdminController::processExpiredRequests');
+      $routes->post('system/stats', 'AdminController::getSystemStats');
+    });
   });
-});
 
 // 웹훅 및 외부 API
-$routes->group('webhook', ['namespace' => 'App\Controllers'], function($routes) {
-  $routes->post('payment/success', 'WebhookController::paymentSuccess');
-  $routes->post('payment/failure', 'WebhookController::paymentFailure');
-  $routes->post('notification/send', 'WebhookController::sendNotification');
-});
+  $routes->group('webhook', ['namespace' => 'App\Controllers'], function($routes) {
+    $routes->post('payment/success', 'WebhookController::paymentSuccess');
+    $routes->post('payment/failure', 'WebhookController::paymentFailure');
+    $routes->post('notification/send', 'WebhookController::sendNotification');
+  });
 
 // 크론잡 및 스케줄러 API
-$routes->group('cron', ['namespace' => 'App\Controllers', 'filter' => 'cron'], function($routes) {
-  $routes->get('process-expired-requests', 'CronController::processExpiredRequests');
-  $routes->get('send-reminder-notifications', 'CronController::sendReminderNotifications');
-  $routes->get('cleanup-old-data', 'CronController::cleanupOldData');
-});
+  $routes->group('cron', ['namespace' => 'App\Controllers', 'filter' => 'cron'], function($routes) {
+    $routes->get('process-expired-requests', 'CronController::processExpiredRequests');
+    $routes->get('send-reminder-notifications', 'CronController::sendReminderNotifications');
+    $routes->get('cleanup-old-data', 'CronController::cleanupOldData');
+  });
 
 // 개발 및 테스트용 라우트 (개발 환경에서만 사용)
-if (ENVIRONMENT === 'development') {
-  $routes->group('dev', ['namespace' => 'App\Controllers'], function($routes) {
-    $routes->get('test-db', 'DevController::testDatabase');
-    $routes->get('seed-data', 'DevController::seedTestData');
-    $routes->get('clear-cache', 'DevController::clearCache');
-    $routes->post('test-api', 'DevController::testApi');
-  });
-}
+  if (ENVIRONMENT === 'development') {
+    $routes->group('dev', ['namespace' => 'App\Controllers'], function($routes) {
+      $routes->get('test-db', 'DevController::testDatabase');
+      $routes->get('seed-data', 'DevController::seedTestData');
+      $routes->get('clear-cache', 'DevController::clearCache');
+      $routes->post('test-api', 'DevController::testApi');
+    });
+  }
 
 // 디버깅용 라우트 (임시)
-$routes->group('debug', ['namespace' => 'App\\Controllers'], function($routes) {
-  $routes->get('foreign-key', 'DebugController::debugForeignKey');
-  $routes->get('simple-update', 'DebugController::testSimpleUpdate');
-});
-
-$routes->post('api/influencer/profile', 'InfluencerController::getProfile');
+  $routes->group('debug', ['namespace' => 'App\\Controllers'], function($routes) {
+    $routes->get('foreign-key', 'DebugController::debugForeignKey');
+    $routes->get('simple-update', 'DebugController::testSimpleUpdate');
+  });
+  
+  $routes->post('api/influencer/profile', 'InfluencerController::getProfile');

+ 163 - 31
backend/app/Controllers/VendorInfluencerController.php

@@ -449,28 +449,27 @@ class VendorInfluencerController extends BaseController
             // processedBy가 벤더사 SEQ인지 사용자 SEQ인지 확인
             $processingUser = null;
             
-            // 1. 먼저 USER_LIST에서 확인
+            // 1. 먼저 USER_LIST에서 확인 (인플루언서)
             $processingUser = $this->userModel
                 ->where('SEQ', $processedBy)
                 ->where('IS_ACT', 'Y')
                 ->first();
             
             if ($processingUser) {
-                // 사용자 SEQ인 경우 바로 사용
+                // 사용자 SEQ인 경우 (인플루언서) - 바로 사용
                 $approvedByUserSeq = $processedBy;
             } else {
-                // 2. processedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+                // 2. VENDOR_LIST에서 확인 (벤더사)
                 $vendorInfo = $this->vendorModel
                     ->where('SEQ', $processedBy)
                     ->where('IS_ACT', 'Y')
                     ->first();
                 
                 if ($vendorInfo) {
-                    // 벤더사 SEQ인 경우, 해당 벤더사 SEQ를 APPROVED_BY에 직접 사용
-                    // 외래키 제약조건을 우회하기 위해 벤더사 SEQ = 사용자 SEQ로 가정
+                    // 벤더사 SEQ인 경우 - 벤더사가 직접 처리하는 것으로 간주
                     $approvedByUserSeq = $processedBy;
                     
-                    log_message('debug', "벤더사 SEQ {$processedBy}를 APPROVED_BY로 직접 사용합니다.");
+                    log_message('debug', "벤더사 SEQ {$processedBy}가 직접 처리합니다.");
                 } else {
                     return $this->response->setStatusCode(400)->setJSON([
                         'success' => false,
@@ -685,38 +684,33 @@ class VendorInfluencerController extends BaseController
             // 해지 처리자 확인 (벤더사 SEQ인지 사용자 SEQ인지 확인)
             $terminatingUser = null;
             
-            // 1. 먼저 USER_LIST에서 확인
+            // 1. 먼저 USER_LIST에서 확인 (인플루언서)
             $terminatingUser = $this->userModel
                 ->where('SEQ', $terminatedBy)
                 ->where('IS_ACT', 'Y')
                 ->first();
             
             if ($terminatingUser) {
-                // 사용자 SEQ인 경우 바로 사용
+                // 사용자 SEQ인 경우 (인플루언서) - 바로 사용
                 $approvedByUserSeq = $terminatedBy;
             } else {
-                // 2. terminatedBy가 벤더사 SEQ인 경우, 해당 벤더사의 담당자 사용자를 찾기
+                // 2. VENDOR_LIST에서 확인 (벤더사)
                 $vendorInfo = $this->vendorModel
                     ->where('SEQ', $terminatedBy)
                     ->where('IS_ACT', 'Y')
                     ->first();
                 
-                if ($vendorInfo && !empty($vendorInfo['COMPANY_NUMBER'])) {
-                    // 벤더사의 COMPANY_NUMBER로 해당 담당자 USER 찾기
-                    $vendorUser = $this->userModel
-                        ->where('COMPANY_NUMBER', $vendorInfo['COMPANY_NUMBER'])
-                        ->where('IS_ACT', 'Y')
-                        ->first();
+                if ($vendorInfo) {
+                    // 벤더사 SEQ인 경우 - 벤더사가 직접 처리하는 것으로 간주
+                    // 벤더사 자체의 SEQ를 APPROVED_BY에 저장 (벤더사 계정이 처리)
+                    $approvedByUserSeq = $terminatedBy;
                     
-                    if ($vendorUser) {
-                        $approvedByUserSeq = $vendorUser['SEQ'];
-                        $terminatingUser = $vendorUser; // terminatingUser로 설정
-                    } else {
-                        return $this->response->setStatusCode(400)->setJSON([
-                            'success' => false,
-                            'message' => "벤더사 SEQ {$terminatedBy}에 해당하는 담당자를 USER_LIST에서 찾을 수 없습니다. COMPANY_NUMBER: {$vendorInfo['COMPANY_NUMBER']}"
-                        ]);
-                    }
+                    // 응답용 정보 설정
+                    $terminatingUser = [
+                        'SEQ' => $vendorInfo['SEQ'],
+                        'NICK_NAME' => $vendorInfo['COMPANY_NAME'] . ' (벤더사)',
+                        'NAME' => $vendorInfo['COMPANY_NAME']
+                    ];
                 } else {
                     return $this->response->setStatusCode(400)->setJSON([
                         'success' => false,
@@ -730,18 +724,53 @@ class VendorInfluencerController extends BaseController
                 'STATUS' => 'TERMINATED',
                 'RESPONSE_MESSAGE' => '파트너십 해지: ' . $terminateReason,
                 'RESPONSE_DATE' => date('Y-m-d H:i:s'),
-                'APPROVED_BY' => $approvedByUserSeq,
                 'PARTNERSHIP_END_DATE' => date('Y-m-d H:i:s'),
                 'MOD_DATE' => date('Y-m-d H:i:s')
             ];
             
-            $result = $this->vendorInfluencerModel->update($mappingSeq, $terminateData);
+            log_message('debug', "해지 처리 데이터: " . json_encode($terminateData));
+            log_message('debug', "매핑 SEQ: {$mappingSeq}");
+            log_message('debug', "처리자 SEQ: {$terminatedBy} -> 승인자 SEQ: {$approvedByUserSeq}");
             
-            if (!$result) {
-                return $this->response->setStatusCode(500)->setJSON([
-                    'success' => false,
-                    'message' => '파트너십 해지 처리 중 오류가 발생했습니다.'
-                ]);
+            // 단계별 업데이트로 외래키 제약조건 문제 우회
+            try {
+                // 1단계: APPROVED_BY 없이 먼저 업데이트
+                $result1 = $this->vendorInfluencerModel->update($mappingSeq, $terminateData);
+                log_message('debug', "1단계 업데이트 결과: " . ($result1 ? 'SUCCESS' : 'FAILED'));
+                
+                // 2단계: APPROVED_BY만 별도로 업데이트 (필요시에만)
+                if ($result1 && $approvedByUserSeq) {
+                    $approvedByData = ['APPROVED_BY' => $approvedByUserSeq];
+                    $result2 = $this->vendorInfluencerModel->update($mappingSeq, $approvedByData);
+                    log_message('debug', "2단계 업데이트 결과: " . ($result2 ? 'SUCCESS' : 'FAILED'));
+                } else {
+                    $result2 = true; // APPROVED_BY 업데이트가 필요없는 경우
+                }
+                
+                if (!$result1 || !$result2) {
+                    throw new \Exception("단계별 업데이트 실패: 1단계={$result1}, 2단계={$result2}");
+                }
+                
+            } catch (\Exception $updateException) {
+                log_message('error', "해지 처리 업데이트 오류: " . $updateException->getMessage());
+                
+                // 외래키 제약조건 오류인 경우 더 상세한 정보 제공
+                if (strpos($updateException->getMessage(), 'foreign key constraint') !== false) {
+                    return $this->response->setStatusCode(500)->setJSON([
+                        'success' => false,
+                        'message' => '외래키 제약조건 오류가 발생했습니다.',
+                        'error' => ENVIRONMENT === 'development' ? $updateException->getMessage() : null,
+                        'debug_info' => ENVIRONMENT === 'development' ? [
+                            'terminatedBy' => $terminatedBy,
+                            'approvedByUserSeq' => $approvedByUserSeq,
+                            'terminatingUser' => $terminatingUser,
+                            'existingMapping' => $existingMapping,
+                            'terminateData' => $terminateData
+                        ] : null
+                    ]);
+                }
+                
+                throw $updateException; // 다른 예외는 기존 처리 방식 유지
             }
             
             // 해지된 매핑 정보 조회
@@ -768,9 +797,112 @@ class VendorInfluencerController extends BaseController
             ]);
             
         } catch (\Exception $e) {
+            log_message('error', "파트너십 해지 처리 오류: " . $e->getMessage());
+            log_message('error', "스택 트레이스: " . $e->getTraceAsString());
+            
             return $this->response->setStatusCode(500)->setJSON([
                 'success' => false,
                 'message' => '파트너십 해지 처리 중 오류가 발생했습니다.',
+                'error' => ENVIRONMENT === 'development' ? $e->getMessage() : null,
+                'trace' => ENVIRONMENT === 'development' ? $e->getTraceAsString() : null
+            ]);
+        }
+    }
+    
+    /**
+     * 재승인 요청 (해지된 파트너십에 대한 재계약 요청)
+     */
+    public function reapplyRequest()
+    {
+        try {
+            $request = $this->request->getJSON();
+            
+            $vendorSeq = $request->vendorSeq ?? null;
+            $influencerSeq = $request->influencerSeq ?? null;
+            $requestMessage = $request->requestMessage ?? '';
+            $requestedBy = $request->requestedBy ?? null;
+            
+            // 필수 파라미터 검증
+            if (!$vendorSeq || !$influencerSeq || !$requestedBy) {
+                return $this->response->setStatusCode(400)->setJSON([
+                    'success' => false,
+                    'message' => '필수 파라미터가 누락되었습니다.'
+                ]);
+            }
+            
+            // 기존 해지된 파트너십 확인
+            $terminatedPartnership = $this->vendorInfluencerModel
+                ->where('VENDOR_SEQ', $vendorSeq)
+                ->where('INFLUENCER_SEQ', $influencerSeq)
+                ->where('STATUS', 'TERMINATED')
+                ->where('IS_ACT', 'Y')
+                ->orderBy('REG_DATE', 'DESC')
+                ->first();
+            
+            if (!$terminatedPartnership) {
+                return $this->response->setStatusCode(404)->setJSON([
+                    'success' => false,
+                    'message' => '해지된 파트너십 기록을 찾을 수 없습니다.'
+                ]);
+            }
+            
+            // 현재 처리 중인 요청이 있는지 확인
+            $existingPendingRequest = $this->vendorInfluencerModel
+                ->where('VENDOR_SEQ', $vendorSeq)
+                ->where('INFLUENCER_SEQ', $influencerSeq)
+                ->where('STATUS', 'PENDING')
+                ->where('IS_ACT', 'Y')
+                ->first();
+            
+            if ($existingPendingRequest) {
+                return $this->response->setStatusCode(409)->setJSON([
+                    'success' => false,
+                    'message' => '이미 처리 중인 승인 요청이 있습니다.'
+                ]);
+            }
+            
+            // 재승인 요청 생성
+            $reapplyData = [
+                'VENDOR_SEQ' => $vendorSeq,
+                'INFLUENCER_SEQ' => $influencerSeq,
+                'REQUEST_TYPE' => 'INFLUENCER_REQUEST',
+                'STATUS' => 'PENDING',
+                'REQUEST_MESSAGE' => '[재계약 요청] ' . $requestMessage,
+                'REQUESTED_BY' => $requestedBy,
+                'COMMISSION_RATE' => $terminatedPartnership['COMMISSION_RATE'], // 이전 수수료율 유지
+                'SPECIAL_CONDITIONS' => $terminatedPartnership['SPECIAL_CONDITIONS'], // 이전 특별조건 유지
+                'EXPIRED_DATE' => date('Y-m-d H:i:s', strtotime('+7 days')),
+                'ADD_INFO1' => 'REAPPLY', // 재신청 구분자
+                'ADD_INFO2' => $terminatedPartnership['SEQ'], // 이전 파트너십 SEQ 참조
+                'ADD_INFO3' => date('Y-m-d H:i:s') // 재신청 일시
+            ];
+            
+            $insertId = $this->vendorInfluencerModel->insert($reapplyData);
+            
+            // 생성된 재승인 요청 정보 조회
+            $createdReapply = $this->vendorInfluencerModel
+                ->select('vim.*, v.COMPANY_NAME as vendorName, u.NICK_NAME as influencerName, req_user.NICK_NAME as requestedByName')
+                ->from('VENDOR_INFLUENCER_MAPPING vim')
+                ->join('VENDOR_LIST v', 'vim.VENDOR_SEQ = v.SEQ', 'left')
+                ->join('USER_LIST u', 'vim.INFLUENCER_SEQ = u.SEQ', 'left')
+                ->join('USER_LIST req_user', 'vim.REQUESTED_BY = req_user.SEQ', 'left')
+                ->where('vim.SEQ', $insertId)
+                ->get()
+                ->getRowArray();
+            
+            return $this->response->setJSON([
+                'success' => true,
+                'message' => '재승인 요청이 성공적으로 생성되었습니다.',
+                'data' => [
+                    'reapplyRequest' => $createdReapply,
+                    'previousPartnership' => $terminatedPartnership
+                ]
+            ]);
+            
+        } catch (\Exception $e) {
+            return $this->response->setStatusCode(500)->setJSON([
+                'success' => false,
+                'message' => '재승인 요청 생성 중 오류가 발생했습니다.',
                 'error' => ENVIRONMENT === 'development' ? $e->getMessage() : null
             ]);
         }

+ 7 - 2
backend/app/Models/VendorInfluencerMappingModel.php

@@ -26,9 +26,14 @@ class VendorInfluencerMappingModel extends Model
         'EXPIRED_DATE',
         'REQUEST_DATE',
         'RESPONSE_DATE',
+        'PARTNERSHIP_START_DATE',
+        'PARTNERSHIP_END_DATE',
         'REG_DATE',
         'MOD_DATE',
-        'IS_ACT'
+        'IS_ACT',
+        'ADD_INFO1',
+        'ADD_INFO2',
+        'ADD_INFO3'
     ];
     
     protected $useTimestamps = true;
@@ -39,7 +44,7 @@ class VendorInfluencerMappingModel extends Model
         'VENDOR_SEQ' => 'required|integer',
         'INFLUENCER_SEQ' => 'required|integer',
         'REQUEST_TYPE' => 'required|in_list[INFLUENCER_REQUEST,VENDOR_REQUEST]',
-        'STATUS' => 'required|in_list[PENDING,APPROVED,REJECTED,CANCELLED,EXPIRED]',
+        'STATUS' => 'required|in_list[PENDING,APPROVED,REJECTED,CANCELLED,EXPIRED,TERMINATED]',
         'REQUESTED_BY' => 'required|integer',
     ];
     

+ 1 - 1
components/common/header.vue

@@ -149,7 +149,7 @@
           menuId: "menu03",
           parentMenuId: "menu03",
           menuName: "벤더 관리",
-          linkType: "/view/vendor/search",
+          linkType: "/view/influencer/search",
         },
         {
           menuId: "menu04",

+ 709 - 0
pages/view/influencer/search.vue

@@ -0,0 +1,709 @@
+<template>
+  <div>
+    <div class="inner--headers">
+      <h2>벤더사 검색</h2>
+      <div class="bread--crumbs--wrap">
+        <span>홈</span>
+        <span>벤더사 검색</span>
+      </div>
+    </div>
+
+    <!-- 검색 및 필터 영역 -->
+    <div class="search--modules type2">
+      <div class="search--inner">
+        <div class="form--cont--filter">
+          <v-select
+            v-model="searchFilter.category"
+            :items="categoryOptions"
+            variant="outlined"
+            class="custom-select"
+            label="카테고리"
+            clearable
+          >
+          </v-select>
+        </div>
+        <div class="form--cont--filter">
+          <v-select
+            v-model="searchFilter.region"
+            :items="regionOptions"
+            variant="outlined"
+            class="custom-select"
+            label="지역"
+            clearable
+          >
+          </v-select>
+        </div>
+        <div class="form--cont--text">
+          <v-text-field
+            v-model="searchFilter.keyword"
+            class="custom-input mini"
+            style="width: 100%"
+            placeholder="벤더사명을 입력하세요"
+            @keyup.enter="handleSearch"
+          ></v-text-field>
+        </div>
+      </div>
+      <v-btn
+        class="custom-btn btn-blue mini sch--btn"
+        @click="handleSearch"
+        :loading="loading"
+      >
+        <v-icon>mdi-magnify</v-icon>
+        검색
+      </v-btn>
+    </div>
+
+    <!-- 파트너십 상태 탭 -->
+    <div class="partnership--tabs">
+      <v-tabs v-model="activeTab" class="custom-tabs">
+        <v-tab value="new">신규 벤더사</v-tab>
+        <v-tab value="current">현재 파트너십</v-tab>
+        <v-tab value="terminated">해지된 파트너십</v-tab>
+      </v-tabs>
+    </div>
+
+    <!-- 검색 결과 -->
+    <div class="vendor--grid">
+      <div class="vendors--list">
+        <!-- 로딩 상태 -->
+        <div v-if="loading" class="loading-wrap">
+          <v-progress-circular
+            indeterminate
+            color="primary"
+            size="64"
+          ></v-progress-circular>
+          <p>검색 중...</p>
+        </div>
+
+        <!-- 검색 결과 없음 -->
+        <div v-else-if="vendors.length === 0" class="no-results">
+          <div class="no-data">
+            <v-icon size="64" color="grey-lighten-1">mdi-office-building-outline</v-icon>
+            <h3>검색 결과가 없습니다</h3>
+            <p>다른 키워드로 검색해보세요</p>
+          </div>
+        </div>
+
+        <!-- 벤더사 카드 리스트 -->
+        <div v-else class="vendor--cards">
+          <div
+            v-for="vendor in vendors"
+            :key="vendor.SEQ"
+            class="vendor--card"
+            :class="{ 'partnership-exists': vendor.PARTNERSHIP_STATUS }"
+          >
+            <!-- 벤더사 로고 -->
+            <div class="vendor--logo">
+              <v-img
+                v-if="vendor.LOGO"
+                :src="vendor.LOGO"
+                :alt="vendor.COMPANY_NAME"
+                width="80"
+                height="80"
+                cover
+              ></v-img>
+              <div v-else class="no-logo">
+                {{ vendor.COMPANY_NAME?.charAt(0) || "V" }}
+              </div>
+            </div>
+
+            <!-- 벤더사 정보 -->
+            <div class="vendor--info">
+              <h3 class="vendor--name">{{ vendor.COMPANY_NAME }}</h3>
+
+              <div class="vendor--meta">
+                <div v-if="vendor.CATEGORY" class="meta--item">
+                  <v-icon size="16">mdi-tag-outline</v-icon>
+                  <span>{{ getCategoryText(vendor.CATEGORY) }}</span>
+                </div>
+                <div v-if="vendor.REGION" class="meta--item">
+                  <v-icon size="16">mdi-map-marker-outline</v-icon>
+                  <span>{{ vendor.REGION }}</span>
+                </div>
+                <div class="meta--item">
+                  <v-icon size="16">mdi-handshake-outline</v-icon>
+                  <span>{{ vendor.PARTNERSHIP_COUNT || 0 }}개 파트너십</span>
+                </div>
+              </div>
+
+              <p v-if="vendor.DESCRIPTION" class="vendor--description">
+                {{ vendor.DESCRIPTION }}
+              </p>
+
+              <!-- 파트너십 상태 -->
+              <div v-if="vendor.PARTNERSHIP_STATUS" class="partnership--status">
+                <v-chip
+                  :color="getPartnershipColor(vendor.PARTNERSHIP_STATUS)"
+                  size="small"
+                  variant="tonal"
+                >
+                  {{ getPartnershipText(vendor.PARTNERSHIP_STATUS) }}
+                </v-chip>
+              </div>
+            </div>
+
+            <!-- 액션 버튼 -->
+            <div class="vendor--actions">
+              <!-- 신규 벤더사 - 승인요청 -->
+              <v-btn
+                v-if="!vendor.PARTNERSHIP_STATUS"
+                color="primary"
+                variant="flat"
+                size="small"
+                @click="requestPartnership(vendor)"
+                :loading="processing"
+              >
+                <v-icon left size="16">mdi-handshake</v-icon>
+                승인요청
+              </v-btn>
+
+              <!-- 해지된 파트너십 - 재승인요청 -->
+              <v-btn
+                v-else-if="vendor.PARTNERSHIP_STATUS === 'TERMINATED'"
+                color="success"
+                variant="flat"
+                size="small"
+                @click="requestReapply(vendor)"
+                :loading="processing"
+              >
+                <v-icon left size="16">mdi-refresh</v-icon>
+                재승인요청
+              </v-btn>
+
+              <!-- 진행중인 파트너십 -->
+              <v-btn
+                v-else
+                variant="outlined"
+                size="small"
+                @click="viewPartnership(vendor)"
+              >
+                파트너십 보기
+              </v-btn>
+
+              <!-- 상세보기 버튼 -->
+              <v-btn variant="text" size="small" @click="viewVendorDetail(vendor.SEQ)">
+                상세보기
+              </v-btn>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 페이지네이션 -->
+      <div v-if="pagination.totalPages > 1" class="pagination-wrap">
+        <v-pagination
+          v-model="currentPage"
+          :length="pagination.totalPages"
+          :total-visible="5"
+          @update:model-value="handlePageChange"
+        ></v-pagination>
+      </div>
+    </div>
+
+    <!-- 승인요청 모달 -->
+    <v-dialog v-model="requestModal.show" max-width="600px" persistent>
+      <v-card>
+        <v-card-title class="d-flex align-center">
+          <v-icon class="mr-3" color="primary">mdi-handshake</v-icon>
+          {{ requestModal.isReapply ? "재승인요청" : "파트너십 승인요청" }}
+        </v-card-title>
+
+        <v-card-text>
+          <div class="request--content">
+            <div class="vendor--summary">
+              <h4>{{ requestModal.vendor?.COMPANY_NAME }}</h4>
+              <p>
+                {{ getCategoryText(requestModal.vendor?.CATEGORY) }} ·
+                {{ requestModal.vendor?.REGION }}
+              </p>
+            </div>
+
+            <v-divider class="my-4"></v-divider>
+
+            <v-textarea
+              v-model="requestModal.message"
+              label="요청 메시지"
+              placeholder="파트너십을 원하는 이유나 제안사항을 입력해주세요"
+              rows="4"
+              variant="outlined"
+              class="mb-4"
+            ></v-textarea>
+
+            <div class="form-row">
+              <v-text-field
+                v-model="requestModal.commissionRate"
+                label="희망 수수료율 (%)"
+                type="number"
+                variant="outlined"
+                class="mr-2"
+                :disabled="requestModal.isReapply"
+              ></v-text-field>
+
+              <v-text-field
+                v-model="requestModal.specialConditions"
+                label="특별 조건"
+                variant="outlined"
+                :disabled="requestModal.isReapply"
+              ></v-text-field>
+            </div>
+
+            <div v-if="requestModal.isReapply" class="reapply--info">
+              <v-alert type="info" variant="tonal" class="mb-3">
+                재승인요청 시 이전 계약 조건이 자동으로 적용됩니다.
+              </v-alert>
+            </div>
+          </div>
+        </v-card-text>
+
+        <v-card-actions>
+          <v-spacer></v-spacer>
+          <v-btn variant="text" @click="closeRequestModal">취소</v-btn>
+          <v-btn
+            color="primary"
+            variant="flat"
+            @click="submitRequest"
+            :loading="processing"
+            :disabled="!requestModal.message.trim()"
+          >
+            {{ requestModal.isReapply ? "재승인요청" : "승인요청" }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted } from "vue";
+
+  definePageMeta({
+    layout: "default",
+  });
+
+  const { $toast } = useNuxtApp();
+  const authStore = useAuthStore();
+
+  // 반응형 데이터
+  const loading = ref(false);
+  const processing = ref(false);
+  const vendors = ref([]);
+  const currentPage = ref(1);
+  const activeTab = ref("new");
+
+  const searchFilter = ref({
+    keyword: "",
+    category: "",
+    region: "",
+  });
+
+  const pagination = ref({
+    currentPage: 1,
+    totalPages: 1,
+    totalCount: 0,
+    pageSize: 12,
+  });
+
+  const requestModal = ref({
+    show: false,
+    vendor: null,
+    message: "",
+    commissionRate: "",
+    specialConditions: "",
+    isReapply: false,
+  });
+
+  // 옵션 데이터
+  const categoryOptions = [
+    { title: "패션·뷰티", value: "FASHION_BEAUTY" },
+    { title: "식품·건강", value: "FOOD_HEALTH" },
+    { title: "라이프스타일", value: "LIFESTYLE" },
+    { title: "테크·가전", value: "TECH_ELECTRONICS" },
+    { title: "스포츠·레저", value: "SPORTS_LEISURE" },
+    { title: "문화·엔터테인먼트", value: "CULTURE_ENTERTAINMENT" },
+  ];
+
+  const regionOptions = [
+    { title: "서울", value: "SEOUL" },
+    { title: "경기", value: "GYEONGGI" },
+    { title: "인천", value: "INCHEON" },
+    { title: "부산", value: "BUSAN" },
+    { title: "대구", value: "DAEGU" },
+    { title: "대전", value: "DAEJEON" },
+    { title: "광주", value: "GWANGJU" },
+    { title: "울산", value: "ULSAN" },
+    { title: "기타", value: "OTHER" },
+  ];
+
+  // 현재 사용자 SEQ
+  const currentUserSeq = computed(() => authStore.getUserSeq);
+
+  // 필터링된 벤더사 목록
+  const filteredVendors = computed(() => {
+    if (activeTab.value === "new") {
+      return vendors.value.filter((v) => !v.PARTNERSHIP_STATUS);
+    } else if (activeTab.value === "current") {
+      return vendors.value.filter((v) =>
+        ["PENDING", "APPROVED"].includes(v.PARTNERSHIP_STATUS)
+      );
+    } else if (activeTab.value === "terminated") {
+      return vendors.value.filter((v) => v.PARTNERSHIP_STATUS === "TERMINATED");
+    }
+    return vendors.value;
+  });
+
+  // 메서드들
+  const handleSearch = async () => {
+    loading.value = true;
+    currentPage.value = 1;
+
+    try {
+      const params = {
+        keyword: searchFilter.value.keyword,
+        category: searchFilter.value.category,
+        region: searchFilter.value.region,
+        sortBy: "latest",
+        page: currentPage.value,
+        size: pagination.value.pageSize,
+        influencerSeq: currentUserSeq.value,
+      };
+
+      useAxios()
+        .post("/api/vendor-influencer/search-vendors", params)
+        .then((res) => {
+          if (res.data.success) {
+            vendors.value = res.data.data.items || [];
+            pagination.value = res.data.data.pagination || {};
+          } else {
+            $toast.error(res.data.message || "검색에 실패했습니다.");
+          }
+        })
+        .catch((err) => {
+          $toast.error("검색 중 오류가 발생했습니다.");
+          console.error("Search error:", err);
+        })
+        .finally(() => {
+          loading.value = false;
+        });
+    } catch (err) {
+      $toast.error("검색 중 오류가 발생했습니다.");
+      loading.value = false;
+    }
+  };
+
+  const handlePageChange = (page) => {
+    currentPage.value = page;
+    handleSearch();
+  };
+
+  // 파트너십 요청
+  const requestPartnership = (vendor) => {
+    requestModal.value = {
+      show: true,
+      vendor: vendor,
+      message: "",
+      commissionRate: "",
+      specialConditions: "",
+      isReapply: false,
+    };
+  };
+
+  // 재승인요청
+  const requestReapply = (vendor) => {
+    requestModal.value = {
+      show: true,
+      vendor: vendor,
+      message: "",
+      commissionRate: vendor.COMMISSION_RATE || "",
+      specialConditions: vendor.SPECIAL_CONDITIONS || "",
+      isReapply: true,
+    };
+  };
+
+  const submitRequest = async () => {
+    try {
+      processing.value = true;
+
+      const endpoint = requestModal.value.isReapply
+        ? "/api/vendor-influencer/reapply-request"
+        : "/api/vendor-influencer/create-request";
+
+      const params = {
+        vendorSeq: requestModal.value.vendor.SEQ,
+        influencerSeq: currentUserSeq.value,
+        requestMessage: requestModal.value.message,
+        requestedBy: currentUserSeq.value,
+        ...(requestModal.value.isReapply
+          ? {}
+          : {
+              commissionRate: requestModal.value.commissionRate,
+              specialConditions: requestModal.value.specialConditions,
+            }),
+      };
+
+      useAxios()
+        .post(endpoint, params)
+        .then((res) => {
+          if (res.data.success) {
+            $toast.success(res.data.message);
+            closeRequestModal();
+            handleSearch(); // 리스트 새로고침
+          } else {
+            $toast.error(res.data.message || "요청 처리에 실패했습니다.");
+          }
+        })
+        .catch((err) => {
+          $toast.error("요청 처리 중 오류가 발생했습니다.");
+          console.error("Request error:", err);
+        })
+        .finally(() => {
+          processing.value = false;
+        });
+    } catch (err) {
+      $toast.error("요청 처리 중 오류가 발생했습니다.");
+      processing.value = false;
+    }
+  };
+
+  const closeRequestModal = () => {
+    requestModal.value = {
+      show: false,
+      vendor: null,
+      message: "",
+      commissionRate: "",
+      specialConditions: "",
+      isReapply: false,
+    };
+  };
+
+  const viewPartnership = (vendor) => {
+    navigateTo(`/view/influencer/partnerships`);
+  };
+
+  const viewVendorDetail = (vendorSeq) => {
+    navigateTo(`/view/vendor/${vendorSeq}`);
+  };
+
+  // 유틸리티 함수들
+  const getCategoryText = (category) => {
+    const categoryMap = {
+      FASHION_BEAUTY: "패션·뷰티",
+      FOOD_HEALTH: "식품·건강",
+      LIFESTYLE: "라이프스타일",
+      TECH_ELECTRONICS: "테크·가전",
+      SPORTS_LEISURE: "스포츠·레저",
+      CULTURE_ENTERTAINMENT: "문화·엔터테인먼트",
+    };
+    return categoryMap[category] || category || "기타";
+  };
+
+  const getPartnershipColor = (status) => {
+    const colorMap = {
+      PENDING: "warning",
+      APPROVED: "success",
+      REJECTED: "error",
+      TERMINATED: "grey",
+      CANCELLED: "grey",
+    };
+    return colorMap[status] || "grey";
+  };
+
+  const getPartnershipText = (status) => {
+    const textMap = {
+      PENDING: "승인 대기중",
+      APPROVED: "파트너십 진행중",
+      REJECTED: "승인 거부",
+      TERMINATED: "파트너십 해지됨",
+      CANCELLED: "요청 취소됨",
+    };
+    return textMap[status] || status || "알 수 없음";
+  };
+
+  // 라이프사이클
+  onMounted(() => {
+    handleSearch();
+  });
+</script>
+
+<style scoped>
+  .partnership--tabs {
+    margin: 24px 0;
+  }
+
+  .vendor--grid {
+    margin-top: 24px;
+  }
+
+  .vendor--cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+    gap: 24px;
+  }
+
+  .vendor--card {
+    background: white;
+    border-radius: 12px;
+    padding: 24px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    transition: transform 0.2s, box-shadow 0.2s;
+    display: flex;
+    gap: 20px;
+  }
+
+  .vendor--card:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+  }
+
+  .vendor--card.partnership-exists {
+    border-left: 4px solid #4caf50;
+  }
+
+  .vendor--logo {
+    width: 80px;
+    height: 80px;
+    border-radius: 8px;
+    overflow: hidden;
+    flex-shrink: 0;
+    background: #f5f5f5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .no-logo {
+    font-size: 32px;
+    font-weight: bold;
+    color: #666;
+  }
+
+  .vendor--info {
+    flex: 1;
+  }
+
+  .vendor--name {
+    margin: 0 0 12px 0;
+    font-size: 18px;
+    font-weight: 600;
+    color: #333;
+  }
+
+  .vendor--meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 12px;
+    margin-bottom: 12px;
+  }
+
+  .meta--item {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    color: #666;
+    font-size: 14px;
+  }
+
+  .vendor--description {
+    font-size: 14px;
+    line-height: 1.5;
+    color: #666;
+    margin: 0 0 16px 0;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .partnership--status {
+    margin-bottom: 16px;
+  }
+
+  .vendor--actions {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    flex-shrink: 0;
+  }
+
+  .request--content {
+    padding: 8px 0;
+  }
+
+  .vendor--summary h4 {
+    margin: 0 0 4px 0;
+    font-size: 16px;
+    font-weight: 600;
+  }
+
+  .vendor--summary p {
+    margin: 0;
+    color: #666;
+    font-size: 14px;
+  }
+
+  .form-row {
+    display: flex;
+    gap: 12px;
+  }
+
+  .reapply--info {
+    margin-top: 16px;
+  }
+
+  .loading-wrap {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 60px 20px;
+  }
+
+  .loading-wrap p {
+    margin-top: 16px;
+    color: #666;
+  }
+
+  .no-results {
+    display: flex;
+    justify-content: center;
+    padding: 60px 20px;
+  }
+
+  .no-data {
+    text-align: center;
+  }
+
+  .no-data h3 {
+    margin: 16px 0 8px;
+    color: #666;
+  }
+
+  .no-data p {
+    color: #999;
+  }
+
+  .pagination-wrap {
+    display: flex;
+    justify-content: center;
+    margin-top: 32px;
+  }
+
+  @media (max-width: 768px) {
+    .vendor--cards {
+      grid-template-columns: 1fr;
+    }
+
+    .vendor--card {
+      flex-direction: column;
+      text-align: center;
+    }
+
+    .vendor--actions {
+      flex-direction: row;
+      justify-content: center;
+    }
+
+    .form-row {
+      flex-direction: column;
+    }
+  }
+</style>

+ 104 - 57
pages/view/vendor/dashboard/influencer-requests.vue

@@ -188,14 +188,21 @@
                     </span>
                   </div>
 
-                  <div v-if="request.influencerDescription" class="influencer--description">
+                  <div
+                    v-if="request.influencerDescription"
+                    class="influencer--description"
+                  >
                     <p>{{ request.influencerDescription }}</p>
                   </div>
 
                   <div v-if="request.influencerSnsChannels" class="influencer--sns">
-                    <div v-for="(channel, index) in parseSnsChannels(request.influencerSnsChannels)" 
-                         :key="index" 
-                         class="sns--item">
+                    <div
+                      v-for="(channel, index) in parseSnsChannels(
+                        request.influencerSnsChannels
+                      )"
+                      :key="index"
+                      class="sns--item"
+                    >
                       <v-icon size="small">{{ getSnsIcon(channel.platform) }}</v-icon>
                       {{ channel.handle }}
                     </div>
@@ -282,6 +289,18 @@
                   </v-btn>
                 </div>
 
+                <div
+                  v-else-if="request.STATUS === 'TERMINATED'"
+                  class="terminated--actions"
+                >
+                  <v-btn
+                    class="custom-btn mini btn-outline"
+                    @click="viewRequestHistory(request.SEQ)"
+                  >
+                    이력보기
+                  </v-btn>
+                </div>
+
                 <v-btn
                   v-else
                   class="custom-btn mini btn-outline"
@@ -444,7 +463,8 @@
             </div>
 
             <v-alert type="warning" class="mb-4">
-              <strong>주의:</strong> 파트너십을 해지하면 협업 관계가 종료되며, 이 작업은 되돌릴 수 없습니다.
+              <strong>주의:</strong> 파트너십을 해지하면 협업 관계가 종료되며, 이 작업은
+              되돌릴 수 없습니다.
             </v-alert>
 
             <p>이 인플루언서와의 파트너십을 해지하시겠습니까?</p>
@@ -464,9 +484,9 @@
         <v-card-actions>
           <v-spacer></v-spacer>
           <v-btn color="grey" variant="text" @click="closeTerminateModal">취소</v-btn>
-          <v-btn 
+          <v-btn
             class="btn-terminate-confirm"
-            @click="confirmTerminate" 
+            @click="confirmTerminate"
             :loading="processing"
           >
             <v-icon left>mdi-link-off</v-icon>
@@ -580,7 +600,7 @@
   ************************************************************************/
   const currentUser = computed(() => {
     const authData = JSON.parse(localStorage.getItem("authStore"))?.auth || {};
-    console.log('🔍 currentUser (벤더 대시보드):', authData);
+    console.log("🔍 currentUser (벤더 대시보드):", authData);
     return authData;
   });
 
@@ -617,23 +637,23 @@
         size: pagination.value.pageSize,
       };
 
-      console.log('🔍 loadRequests 호출됨:', params);
-      
+      console.log("🔍 loadRequests 호출됨:", params);
+
       useAxios()
         .post("/api/vendor-influencer/requests", params)
         .then((res) => {
-          console.log('📥 API 응답:', res.data);
+          console.log("📥 API 응답:", res.data);
           if (res.data.success) {
             const items = res.data.data.items;
-            console.log('📋 받아온 요청 목록:', items.length, items);
-            
+            console.log("📋 받아온 요청 목록:", items.length, items);
+
             // SEQ 중복 확인
-            const seqs = items.map(item => item.SEQ);
+            const seqs = items.map((item) => item.SEQ);
             const uniqueSeqs = [...new Set(seqs)];
             if (seqs.length !== uniqueSeqs.length) {
-              console.warn('⚠️ 중복된 SEQ 발견:', seqs);
+              console.warn("⚠️ 중복된 SEQ 발견:", seqs);
             }
-            
+
             requests.value = items;
             pagination.value = res.data.data.pagination;
             stats.value = res.data.data.stats || stats.value;
@@ -680,20 +700,20 @@
         processedBy: currentUser.value.seq,
         responseMessage: approveModal.value.approveMessage,
       };
-      
-      console.log('✅ 승인 처리 시작:', params);
+
+      console.log("✅ 승인 처리 시작:", params);
 
       useAxios()
         .post("/api/vendor-influencer/approve", params)
         .then((res) => {
-          console.log('📥 승인 처리 응답:', res.data);
+          console.log("📥 승인 처리 응답:", res.data);
           if (res.data.success) {
             $toast.success("승인요청이 승인되었습니다.");
             closeApproveModal();
-            console.log('🔄 승인 후 목록 새로고침');
+            console.log("🔄 승인 후 목록 새로고침");
             loadRequests();
           } else {
-            console.error('❌ 승인 처리 실패:', res.data);
+            console.error("❌ 승인 처리 실패:", res.data);
             $toast.error(res.data.message || "승인 처리 중 오류가 발생했습니다.");
           }
         })
@@ -772,6 +792,9 @@
     router.push(`/view/vendor/request-history/${requestSeq}`);
   };
 
+  /**
+   * 파트너십 해지
+   */
   const handleTerminate = (request) => {
     terminateModal.value = {
       show: true,
@@ -803,19 +826,19 @@
         terminatedBy: currentUser.value.seq,
       };
 
-      console.log('🔗 파트너십 해지 처리 시작:', params);
+      console.log("🔗 파트너십 해지 처리 시작:", params);
 
       useAxios()
         .post("/api/vendor-influencer/terminate", params)
         .then((res) => {
-          console.log('📥 해지 처리 응답:', res.data);
+          console.log("📥 해지 처리 응답:", res.data);
           if (res.data.success) {
             $toast.success("파트너십이 해지되었습니다.");
             closeTerminateModal();
-            console.log('🔄 해지 후 목록 새로고침');
+            console.log("🔄 해지 후 목록 새로고침");
             loadRequests();
           } else {
-            console.error('❌ 해지 처리 실패:', res.data);
+            console.error("❌ 해지 처리 실패:", res.data);
             $toast.error(res.data.message || "해지 처리 중 오류가 발생했습니다.");
           }
         })
@@ -891,21 +914,21 @@
 
   const getSnsIcon = (platform) => {
     const iconMap = {
-      instagram: 'mdi-instagram',
-      youtube: 'mdi-youtube',
-      tiktok: 'mdi-music-note',
-      blog: 'mdi-post',
-      facebook: 'mdi-facebook',
-      twitter: 'mdi-twitter'
+      instagram: "mdi-instagram",
+      youtube: "mdi-youtube",
+      tiktok: "mdi-music-note",
+      blog: "mdi-post",
+      facebook: "mdi-facebook",
+      twitter: "mdi-twitter",
     };
-    return iconMap[platform.toLowerCase()] || 'mdi-link';
+    return iconMap[platform.toLowerCase()] || "mdi-link";
   };
 
   /************************************************************************
   |    라이프사이클
   ************************************************************************/
   onMounted(async () => {
-    console.log('🚀 influencer-requests 컴포넌트 마운트됨');
+    console.log("🚀 influencer-requests 컴포넌트 마운트됨");
     await loadRequests();
   });
 </script>
@@ -1138,7 +1161,8 @@
   }
 
   .approval--actions,
-  .approved--actions {
+  .approved--actions,
+  .terminated--actions {
     display: flex;
     gap: 8px;
   }
@@ -1173,7 +1197,8 @@
   }
 
   .approve--content,
-  .reject--content {
+  .reject--content,
+  .terminate--content {
     padding: 8px 0;
   }
 
@@ -1293,39 +1318,61 @@
 
   /* 해지 버튼 전용 스타일 */
   .btn-terminate {
-    background: linear-gradient(135deg, #ff4757 0%, #ff3742 100%) !important;
-    color: white !important;
-    font-weight: 600 !important;
-    border: 2px solid #ff4757 !important;
-    box-shadow: 0 2px 8px rgba(255, 71, 87, 0.3) !important;
-    transition: all 0.3s ease !important;
-    text-transform: none !important;
-    letter-spacing: 0.5px !important;
+    background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
+    color: white;
+    border: none;
+    font-weight: 700;
+    min-width: 100px;
+    padding: 8px 16px;
+    box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3);
+    transition: all 0.3s ease;
   }
 
   .btn-terminate:hover {
-    background: linear-gradient(135deg, #ff3742 0%, #ff2f3a 100%) !important;
-    border-color: #ff3742 !important;
-    box-shadow: 0 4px 12px rgba(255, 71, 87, 0.4) !important;
-    transform: translateY(-1px) !important;
+    transform: translateY(-2px);
+    box-shadow: 0 6px 20px rgba(229, 62, 62, 0.4);
+    background: linear-gradient(135deg, #c53030 0%, #9b2c2c 100%);
   }
 
   .btn-terminate:active {
-    transform: translateY(0) !important;
-    box-shadow: 0 2px 6px rgba(255, 71, 87, 0.4) !important;
+    transform: translateY(0);
   }
 
-  .btn-terminate .v-icon {
-    color: white !important;
-    margin-right: 4px !important;
+  .btn-terminate:disabled {
+    background: #e2e8f0;
+    color: #a0aec0;
+    box-shadow: none;
+    transform: none !important;
   }
 
-  .btn-terminate:disabled {
-    background: #ffbdc1 !important;
-    color: #999 !important;
-    border-color: #ffbdc1 !important;
-    box-shadow: none !important;
+  .btn-green {
+    background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
+    color: white;
+    border: none;
+    font-weight: 600;
+    min-width: 140px;
+    padding: 8px 16px;
+    box-shadow: 0 4px 12px rgba(56, 161, 105, 0.3);
+    transition: all 0.3s ease;
+  }
+
+  .btn-green:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 6px 20px rgba(56, 161, 105, 0.4);
+    background: linear-gradient(135deg, #2f855a 0%, #276749 100%);
+  }
+
+  .btn-green:active {
+    transform: translateY(0);
+  }
+
+  .btn-green:disabled {
+    background: #e6fffa;
+    color: #38a169;
+    border: 1px solid #38a169;
+    box-shadow: none;
     transform: none !important;
+    opacity: 0.7;
   }
 
   /* 해지 확인 모달 버튼 스타일 */

+ 0 - 594
pages/view/vendor/product-register.vue

@@ -1,594 +0,0 @@
-<template>
-  <div>
-    <div class="inner--headers">
-      <h2>{{ pageId }}</h2>
-      <div class="bread--crumbs--wrap">
-        <span>홈</span>
-        <span>제품 관리</span>
-        <span>{{ pageId }}</span>
-      </div>
-    </div>
-
-    <div class="product-register--form">
-      <div class="form--container">
-        <div class="form--section">
-          <h3>기본 정보</h3>
-          <div class="form--row">
-            <div class="form--field">
-              <label class="form--label">제품명 <span class="required">*</span></label>
-              <v-text-field
-                v-model="form.productName"
-                placeholder="제품명을 입력하세요"
-                class="custom-input"
-                :rules="[rules.required]"
-              ></v-text-field>
-            </div>
-          </div>
-
-          <div class="form--row">
-            <div class="form--field">
-              <label class="form--label">공급가 <span class="required">*</span></label>
-              <v-text-field
-                v-model="form.supplyPrice"
-                type="number"
-                placeholder="공급가를 입력하세요"
-                class="custom-input"
-                :rules="[rules.required]"
-              ></v-text-field>
-            </div>
-            <div class="form--field">
-              <label class="form--label">판매가 <span class="required">*</span></label>
-              <v-text-field
-                v-model="form.sellPrice"
-                type="number"
-                placeholder="판매가를 입력하세요"
-                class="custom-input"
-                :rules="[rules.required]"
-              ></v-text-field>
-            </div>
-          </div>
-
-          <div class="form--row">
-            <div class="form--field">
-              <label class="form--label">배송비 <span class="required">*</span></label>
-              <v-text-field
-                v-model="form.shippingCost"
-                placeholder="배송비 정보를 입력하세요 (예: 3,000원, 무료배송)"
-                class="custom-input"
-                :rules="[rules.required]"
-              ></v-text-field>
-            </div>
-          </div>
-
-          <div class="form--row">
-            <div class="form--field">
-              <label class="form--label">소타이틀</label>
-              <v-text-field
-                v-model="form.subtitle"
-                placeholder="소타이틀을 입력하세요"
-                class="custom-input"
-              ></v-text-field>
-            </div>
-          </div>
-        </div>
-
-        <div class="form--section">
-          <h3>상세 정보</h3>
-          <div class="form--row">
-            <div class="form--field full-width">
-              <label class="form--label">상세내용</label>
-              <div class="editor--container">
-                <div class="editor--toolbar">
-                  <v-btn-toggle v-model="editorMode" density="compact" class="editor--mode-toggle">
-                    <v-btn value="html" size="small">HTML</v-btn>
-                    <v-btn value="text" size="small">TEXT</v-btn>
-                  </v-btn-toggle>
-                </div>
-                <div v-show="editorMode === 'html'" class="html-editor">
-                  <textarea
-                    v-model="form.detailContent"
-                    placeholder="HTML 내용을 입력하세요"
-                    class="html-textarea"
-                    rows="15"
-                  ></textarea>
-                </div>
-                <div v-show="editorMode === 'text'" class="text-editor">
-                  <v-textarea
-                    v-model="form.detailContent"
-                    placeholder="텍스트 내용을 입력하세요"
-                    class="custom-input"
-                    rows="15"
-                  ></v-textarea>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="form--section">
-          <h3>첨부파일</h3>
-          <div class="form--row">
-            <div class="form--field">
-              <label class="form--label">상세다운로드</label>
-              <div class="file-upload--container">
-                <input
-                  type="file"
-                  ref="fileInput"
-                  @change="handleFileUpload"
-                  accept=".zip"
-                  class="file-input"
-                  style="display: none"
-                />
-                <div class="file-upload--area" @click="triggerFileUpload">
-                  <div v-if="!form.detailFile" class="file-upload--placeholder">
-                    <i class="upload-icon">📎</i>
-                    <p>ZIP 파일을 선택하세요</p>
-                    <small>클릭하여 파일 선택</small>
-                  </div>
-                  <div v-else class="file-upload--selected">
-                    <i class="file-icon">📁</i>
-                    <div class="file-info">
-                      <p class="file-name">{{ form.detailFile.name }}</p>
-                      <small class="file-size">{{ formatFileSize(form.detailFile.size) }}</small>
-                    </div>
-                    <v-btn
-                      @click.stop="removeFile"
-                      class="file-remove"
-                      size="small"
-                      color="error"
-                      icon="mdi-close"
-                    ></v-btn>
-                  </div>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div class="form--section">
-          <h3>상태 설정</h3>
-          <div class="form--row">
-            <div class="form--field">
-              <label class="form--label">상태 <span class="required">*</span></label>
-              <v-select
-                v-model="form.status"
-                :items="statusOptions"
-                variant="outlined"
-                class="custom-select"
-                :rules="[rules.required]"
-              ></v-select>
-            </div>
-            <div class="form--field">
-              <label class="form--label">노출상태 <span class="required">*</span></label>
-              <v-select
-                v-model="form.displayStatus"
-                :items="displayStatusOptions"
-                variant="outlined"
-                class="custom-select"
-                :rules="[rules.required]"
-              ></v-select>
-            </div>
-          </div>
-        </div>
-
-        <div class="form--section">
-          <h3>업데이트 내역</h3>
-          <div class="form--row">
-            <div class="form--field full-width">
-              <label class="form--label">업데이트 내역</label>
-              <v-textarea
-                v-model="form.updateHistory"
-                placeholder="업데이트 내역을 입력하세요 (최대 500자)"
-                class="custom-input"
-                rows="5"
-                :counter="500"
-                :rules="[rules.maxLength(500)]"
-              ></v-textarea>
-            </div>
-          </div>
-        </div>
-
-        <div class="form--actions">
-          <v-btn
-            class="custom-btn btn-white"
-            @click="goBack"
-          >
-            취소
-          </v-btn>
-          <v-btn
-            class="custom-btn btn-blue"
-            @click="saveProduct"
-            :loading="loading"
-          >
-            저장
-          </v-btn>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup>
-/************************************************************************
-|    레이아웃
-************************************************************************/
-definePageMeta({
-  layout: "default",
-});
-
-/************************************************************************
-|    스토어
-************************************************************************/
-const useDtStore = useDetailStore();
-
-/************************************************************************
-|    전역 변수
-************************************************************************/
-const { $toast, $log } = useNuxtApp();
-const router = useRouter();
-const pageId = ref("제품 등록");
-const loading = ref(false);
-const editorMode = ref("text");
-const fileInput = ref(null);
-
-/************************************************************************
-|    폼 데이터
-************************************************************************/
-const form = ref({
-  productName: "",
-  supplyPrice: "",
-  sellPrice: "",
-  shippingCost: "",
-  subtitle: "",
-  detailContent: "",
-  detailFile: null,
-  status: "판매중",
-  displayStatus: "노출",
-  updateHistory: ""
-});
-
-/************************************************************************
-|    옵션 데이터
-************************************************************************/
-const statusOptions = ref([
-  { title: "판매중", value: "판매중" },
-  { title: "품절", value: "품절" }
-]);
-
-const displayStatusOptions = ref([
-  { title: "노출", value: "노출" },
-  { title: "비노출", value: "비노출" }
-]);
-
-/************************************************************************
-|    유효성 검사
-************************************************************************/
-const rules = {
-  required: (value) => !!value || "필수 입력 항목입니다.",
-  maxLength: (max) => (value) => {
-    if (!value) return true;
-    return value.length <= max || `최대 ${max}자까지 입력 가능합니다.`;
-  }
-};
-
-/************************************************************************
-|    함수(METHODS)
-************************************************************************/
-
-// 파일 업로드 트리거
-const triggerFileUpload = () => {
-  fileInput.value.click();
-};
-
-// 파일 업로드 처리
-const handleFileUpload = (event) => {
-  const file = event.target.files[0];
-  if (file) {
-    if (file.type !== 'application/zip' && !file.name.endsWith('.zip')) {
-      $toast.error('ZIP 파일만 업로드 가능합니다.');
-      return;
-    }
-    
-    form.value.detailFile = file;
-    $toast.success('파일이 선택되었습니다.');
-  }
-};
-
-// 파일 제거
-const removeFile = () => {
-  form.value.detailFile = null;
-  if (fileInput.value) {
-    fileInput.value.value = '';
-  }
-};
-
-// 파일 크기 포맷팅
-const formatFileSize = (bytes) => {
-  if (bytes === 0) return '0 Bytes';
-  const k = 1024;
-  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-  const i = Math.floor(Math.log(bytes) / Math.log(k));
-  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
-};
-
-// 뒤로가기
-const goBack = () => {
-  router.push('/view/vendor');
-};
-
-// 제품 저장
-const saveProduct = async () => {
-  // 유효성 검사
-  if (!form.value.productName) {
-    $toast.error('제품명을 입력하세요.');
-    return;
-  }
-  
-  if (!form.value.supplyPrice) {
-    $toast.error('공급가를 입력하세요.');
-    return;
-  }
-  
-  if (!form.value.sellPrice) {
-    $toast.error('판매가를 입력하세요.');
-    return;
-  }
-  
-  if (!form.value.shippingCost) {
-    $toast.error('배송비를 입력하세요.');
-    return;
-  }
-
-  loading.value = true;
-  
-  try {
-    // FormData 생성 (파일 업로드용)
-    const formData = new FormData();
-    formData.append('productName', form.value.productName);
-    formData.append('supplyPrice', form.value.supplyPrice);
-    formData.append('sellPrice', form.value.sellPrice);
-    formData.append('shippingCost', form.value.shippingCost);
-    formData.append('subtitle', form.value.subtitle);
-    formData.append('detailContent', form.value.detailContent);
-    formData.append('status', form.value.status);
-    formData.append('displayStatus', form.value.displayStatus);
-    formData.append('updateHistory', form.value.updateHistory);
-    formData.append('compId', useAuthStore().getCompanyId);
-    
-    if (form.value.detailFile) {
-      formData.append('detailFile', form.value.detailFile);
-    }
-
-    await useAxios()
-      .post("/product/register", formData, {
-        headers: {
-          'Content-Type': 'multipart/form-data'
-        }
-      })
-      .then((res) => {
-        if (res.data.success) {
-          $toast.success('제품이 등록되었습니다.');
-          router.push('/view/vendor');
-        } else {
-          $toast.error('제품 등록에 실패했습니다.');
-        }
-      });
-  } catch (error) {
-    $log.error('제품 등록 오류:', error);
-    $toast.error('제품 등록 중 오류가 발생했습니다.');
-  } finally {
-    loading.value = false;
-  }
-};
-</script>
-
-<style scoped>
-.product-register--form {
-  margin: 20px 0;
-}
-
-.form--container {
-  background: white;
-  border-radius: 8px;
-  padding: 30px;
-  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
-}
-
-.form--section {
-  margin-bottom: 40px;
-}
-
-.form--section h3 {
-  font-size: 18px;
-  font-weight: 600;
-  color: #333;
-  margin-bottom: 20px;
-  padding-bottom: 10px;
-  border-bottom: 2px solid #f0f0f0;
-}
-
-.form--row {
-  display: flex;
-  gap: 20px;
-  margin-bottom: 20px;
-}
-
-.form--field {
-  flex: 1;
-}
-
-.form--field.full-width {
-  width: 100%;
-}
-
-.form--label {
-  display: block;
-  font-size: 14px;
-  font-weight: 500;
-  color: #333;
-  margin-bottom: 8px;
-}
-
-.form--label .required {
-  color: #f44336;
-}
-
-.custom-input {
-  width: 100%;
-}
-
-.custom-select {
-  width: 100%;
-}
-
-/* 에디터 스타일 */
-.editor--container {
-  border: 1px solid #ddd;
-  border-radius: 4px;
-  overflow: hidden;
-}
-
-.editor--toolbar {
-  background: #f8f9fa;
-  padding: 10px;
-  border-bottom: 1px solid #ddd;
-}
-
-.editor--mode-toggle {
-  background: white;
-  border-radius: 4px;
-}
-
-.html-textarea {
-  width: 100%;
-  border: none;
-  outline: none;
-  padding: 15px;
-  font-family: 'Courier New', monospace;
-  font-size: 14px;
-  resize: vertical;
-  min-height: 400px;
-}
-
-.text-editor {
-  padding: 15px;
-}
-
-/* 파일 업로드 스타일 */
-.file-upload--container {
-  width: 100%;
-}
-
-.file-upload--area {
-  border: 2px dashed #ddd;
-  border-radius: 8px;
-  padding: 30px;
-  text-align: center;
-  cursor: pointer;
-  transition: all 0.3s ease;
-}
-
-.file-upload--area:hover {
-  border-color: #3f51b5;
-  background: #f8f9ff;
-}
-
-.file-upload--placeholder {
-  color: #666;
-}
-
-.file-upload--placeholder .upload-icon {
-  font-size: 48px;
-  display: block;
-  margin-bottom: 10px;
-}
-
-.file-upload--placeholder p {
-  font-size: 16px;
-  margin: 10px 0;
-}
-
-.file-upload--placeholder small {
-  color: #999;
-}
-
-.file-upload--selected {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-  padding: 15px;
-  background: #f8f9fa;
-  border-radius: 4px;
-}
-
-.file-upload--selected .file-icon {
-  font-size: 24px;
-}
-
-.file-info {
-  flex: 1;
-  text-align: left;
-}
-
-.file-name {
-  font-weight: 500;
-  margin: 0;
-}
-
-.file-size {
-  color: #666;
-}
-
-.file-remove {
-  margin-left: auto;
-}
-
-/* 액션 버튼 스타일 */
-.form--actions {
-  display: flex;
-  justify-content: center;
-  gap: 15px;
-  margin-top: 40px;
-  padding-top: 20px;
-  border-top: 1px solid #e0e0e0;
-}
-
-.custom-btn {
-  padding: 12px 30px;
-  font-size: 14px;
-  font-weight: 500;
-  border-radius: 4px;
-  min-width: 120px;
-}
-
-.btn-white {
-  background: white;
-  color: #666;
-  border: 1px solid #ddd;
-}
-
-.btn-blue {
-  background: #3f51b5;
-  color: white;
-}
-
-/* 반응형 */
-@media (max-width: 768px) {
-  .form--row {
-    flex-direction: column;
-    gap: 15px;
-  }
-  
-  .form--container {
-    padding: 20px;
-  }
-  
-  .form--actions {
-    flex-direction: column;
-  }
-  
-  .custom-btn {
-    width: 100%;
-  }
-}
-</style>

+ 0 - 997
pages/view/vendor/search.vue

@@ -1,997 +0,0 @@
-<template>
-  <div>
-    <div class="inner--headers">
-      <h2>{{ pageId }}</h2>
-      <div class="bread--crumbs--wrap">
-        <span>홈</span>
-        <span>{{ pageId }}</span>
-      </div>
-    </div>
-
-    <!-- 검색 및 필터 영역 -->
-    <div class="search--modules type2">
-      <div class="search--inner">
-        <div class="form--cont--filter">
-          <v-select
-            v-model="searchFilter.category"
-            :items="categoryOptions"
-            variant="outlined"
-            class="custom-select"
-            label="카테고리"
-            clearable
-          >
-          </v-select>
-        </div>
-        <div class="form--cont--filter">
-          <v-select
-            v-model="searchFilter.region"
-            :items="regionOptions"
-            variant="outlined"
-            class="custom-select"
-            label="지역"
-            clearable
-          >
-          </v-select>
-        </div>
-        <div class="form--cont--text">
-          <v-text-field
-            v-model="searchFilter.keyword"
-            class="custom-input mini"
-            style="width: 100%"
-            placeholder="벤더사명을 입력하세요"
-            @keyup.enter="handleSearch"
-          ></v-text-field>
-        </div>
-      </div>
-      <v-btn
-        class="custom-btn btn-blue mini sch--btn"
-        @click="handleSearch"
-        :loading="loading"
-      >
-        검색
-      </v-btn>
-    </div>
-
-    <!-- 내 승인 요청 현황 -->
-    <div class="data--list--wrap mb-4">
-      <div class="section--header">
-        <h3>내 승인 요청 현황</h3>
-        <v-btn
-          class="custom-btn mini btn-outline"
-          @click="showMyRequests = !showMyRequests"
-        >
-          {{ showMyRequests ? "숨기기" : "보기" }}
-        </v-btn>
-      </div>
-
-      <div v-show="showMyRequests" class="my--requests--wrap">
-        <div v-if="myRequests.length === 0" class="no-data">
-          <p>진행 중인 승인 요청이 없습니다.</p>
-        </div>
-        <div v-else class="request--cards">
-          <div
-            v-for="request in myRequests"
-            :key="request.SEQ"
-            class="request--card"
-            :class="getStatusClass(request.STATUS)"
-          >
-            <div class="card--header">
-              <h4>{{ request.vendorName }}</h4>
-              <v-chip :color="getStatusColor(request.STATUS)" size="small">
-                {{ getStatusText(request.STATUS) }}
-              </v-chip>
-            </div>
-            <div class="card--content">
-              <p class="request--date">요청일: {{ formatDate(request.REQUEST_DATE) }}</p>
-              <p v-if="request.STATUS === 'PENDING'" class="expire--date">
-                만료일: {{ formatDate(request.EXPIRED_DATE) }}
-              </p>
-              <p v-if="request.REQUEST_MESSAGE" class="request--message">
-                "{{ request.REQUEST_MESSAGE }}"
-              </p>
-            </div>
-            <div class="card--actions">
-              <v-btn
-                v-if="request.STATUS === 'PENDING'"
-                class="custom-btn mini btn-outline-red"
-                @click="cancelRequest(request.SEQ)"
-                size="small"
-              >
-                취소
-              </v-btn>
-              <v-btn
-                class="custom-btn mini btn-outline"
-                @click="viewRequestDetail(request.SEQ)"
-                size="small"
-              >
-                상세보기
-              </v-btn>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 벤더사 검색 결과 -->
-    <div class="data--list--wrap">
-      <div class="btn--actions--wrap">
-        <div class="left--sections">
-          <span class="result-count">
-            총 {{ pagination.totalCount || 0 }}개의 벤더사
-          </span>
-        </div>
-        <div class="right--sections">
-          <v-select
-            v-model="sortOption"
-            :items="sortOptions"
-            variant="outlined"
-            class="custom-select mini"
-            @update:model-value="handleSort"
-          >
-          </v-select>
-        </div>
-      </div>
-
-      <!-- 로딩 상태 -->
-      <div v-if="loading" class="loading-wrap">
-        <v-progress-circular indeterminate color="primary"></v-progress-circular>
-        <p>벤더사를 검색하고 있습니다...</p>
-      </div>
-
-      <!-- 에러 상태 -->
-      <div v-else-if="error" class="error-wrap">
-        <v-alert type="error" dismissible @click:close="error = null">
-          {{ error }}
-        </v-alert>
-      </div>
-
-      <!-- 벤더사 리스트 -->
-      <div v-else-if="vendors.length > 0" class="vendor--search--wrap">
-        <div class="vendor--grid">
-          <div v-for="vendor in vendors" :key="vendor.SEQ" class="vendor--card">
-            <div class="vendor--card--header">
-              <div class="vendor--logo">
-                <v-img
-                  v-if="vendor.LOGO"
-                  :src="vendor.LOGO"
-                  :alt="vendor.COMPANY_NAME + ' 로고'"
-                  width="60"
-                  height="60"
-                ></v-img>
-                <div v-else class="no-logo">
-                  {{ vendor.COMPANY_NAME?.charAt(0) || "V" }}
-                </div>
-              </div>
-              <div class="vendor--info">
-                <h3>{{ vendor.COMPANY_NAME }}</h3>
-                <p class="vendor--category">{{ getCategoryText(vendor.CATEGORY) }}</p>
-                <div class="vendor--meta">
-                  <span v-if="vendor.REGION">📍 {{ vendor.REGION }}</span>
-                  <span v-if="vendor.PARTNERSHIP_COUNT"
-                    >🤝 {{ vendor.PARTNERSHIP_COUNT }}개 파트너십</span
-                  >
-                </div>
-              </div>
-            </div>
-
-            <div class="vendor--card--body">
-              <p v-if="vendor.DESCRIPTION" class="vendor--description">
-                {{ vendor.DESCRIPTION }}
-              </p>
-              <div class="vendor--tags" v-if="vendor.TAGS">
-                <v-chip
-                  v-for="tag in vendor.TAGS.split(',')"
-                  :key="tag"
-                  size="small"
-                  variant="outlined"
-                  class="mr-1 mb-1"
-                >
-                  {{ tag.trim() }}
-                </v-chip>
-              </div>
-            </div>
-
-            <div class="vendor--card--footer">
-              <div class="partnership--status">
-                <span
-                  v-if="getPartnershipStatus(vendor.SEQ)"
-                  :class="[
-                    'status-badge',
-                    getPartnershipStatus(vendor.SEQ)?.toLowerCase() || 'unknown',
-                  ]"
-                >
-                  {{ getPartnershipStatusText(vendor.SEQ) }}
-                </span>
-                <span v-else class="status-badge available">신규 파트너십 가능</span>
-              </div>
-              <div class="card--actions">
-                <v-btn
-                  class="custom-btn mini btn-outline mr-2"
-                  @click="viewVendorDetail(vendor.SEQ)"
-                >
-                  상세보기
-                </v-btn>
-                <v-btn
-                  v-if="showRequestButton(vendor.SEQ)"
-                  class="custom-btn mini btn-blue"
-                  @click="openRequestModal(vendor)"
-                >
-                  승인요청
-                </v-btn>
-                <v-chip
-                  v-else-if="getPartnershipStatus(vendor.SEQ) === 'APPROVED'"
-                  color="success"
-                  size="small"
-                >
-                  승인완료
-                </v-chip>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 페이지네이션 -->
-        <div class="pagination-wrap" v-if="pagination.totalPages > 1">
-          <v-pagination
-            v-model="currentPage"
-            :length="pagination.totalPages"
-            :total-visible="7"
-            @update:model-value="handlePageChange"
-          ></v-pagination>
-        </div>
-      </div>
-
-      <!-- 검색 결과 없음 -->
-      <div v-else class="no-data-wrap">
-        <div class="no-data">
-          <v-icon size="64" color="grey-lighten-1">mdi-store-search</v-icon>
-          <h3>검색된 벤더사가 없습니다</h3>
-          <p>다른 검색 조건을 시도해보세요</p>
-        </div>
-      </div>
-    </div>
-
-    <!-- 승인요청 모달 -->
-    <v-dialog v-model="requestModal.show" max-width="600px">
-      <v-card>
-        <v-card-title class="text-h5">
-          {{ requestModal.vendor?.COMPANY_NAME }}에 승인요청
-        </v-card-title>
-        <v-card-text>
-          <div class="request--form">
-            <div class="vendor--summary">
-              <div class="vendor--logo--small">
-                <v-img
-                  v-if="requestModal.vendor?.LOGO"
-                  :src="requestModal.vendor.LOGO"
-                  width="40"
-                  height="40"
-                ></v-img>
-                <div v-else class="no-logo--small">
-                  {{ requestModal.vendor?.COMPANY_NAME?.charAt(0) || "V" }}
-                </div>
-              </div>
-              <div>
-                <h4>{{ requestModal.vendor?.COMPANY_NAME }}</h4>
-                <p>{{ getCategoryText(requestModal.vendor?.CATEGORY) }}</p>
-              </div>
-            </div>
-
-            <v-textarea
-              v-model="requestModal.message"
-              label="요청 메시지"
-              placeholder="벤더사에 전달할 메시지를 작성해주세요..."
-              rows="4"
-              counter="500"
-              maxlength="500"
-              class="mt-4"
-            ></v-textarea>
-
-            <div class="form--section">
-              <h5>희망 조건 (선택사항)</h5>
-              <v-text-field
-                v-model="requestModal.commissionRate"
-                label="희망 수수료율 (%)"
-                type="number"
-                min="0"
-                max="100"
-                step="0.1"
-                class="mt-2"
-              ></v-text-field>
-
-              <v-textarea
-                v-model="requestModal.specialConditions"
-                label="특별 조건"
-                placeholder="기타 협업 조건이나 요청사항을 입력해주세요..."
-                rows="3"
-                class="mt-2"
-              ></v-textarea>
-            </div>
-          </div>
-        </v-card-text>
-        <v-card-actions>
-          <v-spacer></v-spacer>
-          <v-btn color="grey" variant="text" @click="closeRequestModal"> 취소 </v-btn>
-          <v-btn color="primary" @click="submitRequest" :loading="submitting">
-            승인요청
-          </v-btn>
-        </v-card-actions>
-      </v-card>
-    </v-dialog>
-  </div>
-</template>
-
-<script setup>
-  import { ref, onMounted, computed } from "vue";
-  import { useRouter } from "vue-router";
-
-  /************************************************************************
-|    레이아웃
-************************************************************************/
-  definePageMeta({
-    layout: "default",
-  });
-
-  /************************************************************************
-|    스토어 & 라우터
-************************************************************************/
-  const router = useRouter();
-  const { $toast } = useNuxtApp();
-
-  /************************************************************************
-|    반응형 데이터
-************************************************************************/
-  const pageId = ref("벤더사 검색");
-  const loading = ref(false);
-  const submitting = ref(false);
-  const error = ref(null);
-  const currentPage = ref(1);
-  const showMyRequests = ref(false);
-
-  // 검색 필터
-  const searchFilter = ref({
-    keyword: "",
-    category: "",
-    region: "",
-  });
-
-  // 정렬 옵션
-  const sortOption = ref("latest");
-  const sortOptions = ref([
-    { title: "최신순", value: "latest" },
-    { title: "파트너십 많은순", value: "partnership" },
-    { title: "이름순", value: "name" },
-  ]);
-
-  // 카테고리 옵션
-  const categoryOptions = ref([
-    { title: "전체", value: "" },
-    { title: "패션·뷰티", value: "FASHION_BEAUTY" },
-    { title: "식품·건강", value: "FOOD_HEALTH" },
-    { title: "라이프스타일", value: "LIFESTYLE" },
-    { title: "테크·가전", value: "TECH_ELECTRONICS" },
-    { title: "스포츠·레저", value: "SPORTS_LEISURE" },
-    { title: "문화·엔터테인먼트", value: "CULTURE_ENTERTAINMENT" },
-  ]);
-
-  // 지역 옵션
-  const regionOptions = ref([
-    { title: "전체", value: "" },
-    { title: "서울", value: "SEOUL" },
-    { title: "경기", value: "GYEONGGI" },
-    { title: "인천", value: "INCHEON" },
-    { title: "부산", value: "BUSAN" },
-    { title: "대구", value: "DAEGU" },
-    { title: "대전", value: "DAEJEON" },
-    { title: "광주", value: "GWANGJU" },
-    { title: "울산", value: "ULSAN" },
-    { title: "기타", value: "OTHER" },
-  ]);
-
-  // 데이터
-  const vendors = ref([]);
-  const myRequests = ref([]);
-  const pagination = ref({
-    currentPage: 1,
-    totalPages: 1,
-    totalCount: 0,
-    pageSize: 12,
-  });
-
-  // 승인요청 모달
-  const requestModal = ref({
-    show: false,
-    vendor: null,
-    message: "",
-    commissionRate: null,
-    specialConditions: "",
-  });
-
-  /************************************************************************
-|    computed
-************************************************************************/
-  const currentUser = computed(() => {
-    // 인증 스토어에서 사용자 정보 가져오기
-    const authStore = useAuthStore();
-    
-    console.log('🔍 currentUser computed 디버깅:', {
-      'authStore.auth': authStore.auth,
-      'snsTempData': authStore.auth.snsTempData,
-      'localStorage': localStorage.getItem("authStore")
-    });
-    
-    // SNS 로그인 시 snsTempData가 있는 경우 해당 데이터 사용
-    if (authStore.auth.snsTempData?.user) {
-      console.log('✅ SNS 로그인 데이터 사용:', authStore.auth.snsTempData.user);
-      return authStore.auth.snsTempData.user;
-    }
-    
-    // 일반 로그인 시 auth 데이터 사용
-    if (authStore.auth.seq) {
-      console.log('✅ 일반 로그인 데이터 사용:', authStore.auth);
-      return authStore.auth;
-    }
-    
-    // 로컬스토리지 백업
-    try {
-      const localAuthData = JSON.parse(localStorage.getItem("authStore"))?.auth;
-      console.log('💾 localStorage 데이터:', localAuthData);
-      
-      if (localAuthData?.snsTempData?.user) {
-        console.log('✅ localStorage SNS 데이터 사용:', localAuthData.snsTempData.user);
-        return localAuthData.snsTempData.user;
-      }
-      
-      console.log('✅ localStorage 일반 데이터 사용:', localAuthData);
-      return localAuthData || {};
-    } catch (e) {
-      console.error('❌ localStorage authStore 파싱 오류:', e);
-      return {};
-    }
-  });
-
-  /************************************************************************
-|    메서드
-************************************************************************/
-  const handleSearch = async () => {
-    currentPage.value = 1;
-    await loadVendors();
-  };
-
-  const handlePageChange = async (page) => {
-    currentPage.value = page;
-    await loadVendors();
-  };
-
-  const handleSort = async () => {
-    currentPage.value = 1;
-    await loadVendors();
-  };
-
-  const loadVendors = async () => {
-    try {
-      loading.value = true;
-      error.value = null;
-
-      const params = {
-        keyword: searchFilter.value.keyword,
-        category: searchFilter.value.category,
-        region: searchFilter.value.region,
-        sortBy: sortOption.value,
-        page: currentPage.value,
-        size: pagination.value.pageSize,
-        influencerSeq: currentUser.value.SEQ || currentUser.value.seq || currentUser.value.ID || currentUser.value.id,
-      };
-
-      useAxios()
-        .post("/api/vendor/search", params)
-        .then((res) => {
-          if (res.data.success) {
-            vendors.value = res.data.data.items;
-            pagination.value = res.data.data.pagination;
-          } else {
-            error.value = res.data.message || "벤더사 검색 중 오류가 발생했습니다.";
-          }
-        })
-        .catch((err) => {
-          error.value = err.message || "벤더사 검색 중 오류가 발생했습니다.";
-        })
-        .finally(() => {
-          loading.value = false;
-        });
-    } catch (err) {
-      error.value = err.message || "벤더사 검색 중 오류가 발생했습니다.";
-      loading.value = false;
-    }
-  };
-
-  const loadMyRequests = async () => {
-    try {
-      const params = {
-        influencerSeq: currentUser.value.SEQ || currentUser.value.seq || currentUser.value.ID || currentUser.value.id,
-        // status 파라미터 제거하여 모든 상태의 요청을 로드
-      };
-
-      useAxios()
-        .post("/api/vendor-influencer/list", params)
-        .then((res) => {
-          if (res.data.success) {
-            myRequests.value = res.data.data.items;
-          }
-        })
-        .catch((err) => {
-          console.error("내 요청 목록 로드 오류:", err);
-        });
-    } catch (err) {
-      console.error("내 요청 목록 로드 오류:", err);
-    }
-  };
-
-  const openRequestModal = (vendor) => {
-    requestModal.value = {
-      show: true,
-      vendor: vendor,
-      message: "",
-      commissionRate: null,
-      specialConditions: "",
-    };
-  };
-
-  const closeRequestModal = () => {
-    requestModal.value = {
-      show: false,
-      vendor: null,
-      message: "",
-      commissionRate: null,
-      specialConditions: "",
-    };
-  };
-
-  const submitRequest = async () => {
-    try {
-      submitting.value = true;
-
-      // 사용자 seq 필드 확인 (SNS 로그인 시 대문자 SEQ, 일반 로그인 시 소문자 seq)
-      console.log('🔍 currentUser.value 전체:', currentUser.value);
-      console.log('🔍 SEQ 후보들:', {
-        'SEQ': currentUser.value.SEQ,
-        'seq': currentUser.value.seq, 
-        'id': currentUser.value.id,
-        'ID': currentUser.value.ID
-      });
-      
-      const userSeq = currentUser.value.SEQ || currentUser.value.seq || currentUser.value.ID || currentUser.value.id;
-      
-      console.log('🎯 최종 선택된 userSeq:', userSeq);
-      
-      if (!userSeq) {
-        console.error('❌ userSeq가 비어있음:', currentUser.value);
-        $toast.error('로그인 정보를 확인할 수 없습니다. 다시 로그인해주세요.');
-        return;
-      }
-      
-      const params = {
-        vendorSeq: requestModal.value.vendor.SEQ,
-        influencerSeq: userSeq,
-        requestType: "INFLUENCER_REQUEST",
-        requestMessage: requestModal.value.message,
-        requestedBy: userSeq,
-        commissionRate: requestModal.value.commissionRate,
-        specialConditions: requestModal.value.specialConditions,
-      };
-      
-      console.log('📤 API 호출 파라미터:', params);
-
-      useAxios()
-        .post("/api/vendor-influencer/request", params)
-        .then((res) => {
-          if (res.data.success) {
-            $toast.success("승인요청이 성공적으로 전송되었습니다.");
-            closeRequestModal();
-            loadMyRequests();
-            loadVendors();
-          } else {
-            $toast.error(res.data.message || "승인요청 전송 중 오류가 발생했습니다.");
-          }
-        })
-        .catch((err) => {
-          $toast.error(err.message || "승인요청 전송 중 오류가 발생했습니다.");
-        })
-        .finally(() => {
-          submitting.value = false;
-        });
-    } catch (err) {
-      $toast.error(err.message || "승인요청 전송 중 오류가 발생했습니다.");
-      submitting.value = false;
-    }
-  };
-
-  const cancelRequest = async (requestSeq) => {
-    if (!confirm("승인요청을 취소하시겠습니까?")) return;
-
-    try {
-      const params = {
-        mappingSeq: requestSeq,
-        cancelledBy: currentUser.value.SEQ || currentUser.value.seq || currentUser.value.ID || currentUser.value.id,
-        cancelReason: "사용자에 의한 취소",
-      };
-
-      useAxios()
-        .post("/api/vendor-influencer/cancel", params)
-        .then((res) => {
-          if (res.data.success) {
-            $toast.success("승인요청이 취소되었습니다.");
-            loadMyRequests();
-          } else {
-            $toast.error(res.data.message || "요청 취소 중 오류가 발생했습니다.");
-          }
-        })
-        .catch((err) => {
-          $toast.error(err.message || "요청 취소 중 오류가 발생했습니다.");
-        });
-    } catch (err) {
-      $toast.error(err.message || "요청 취소 중 오류가 발생했습니다.");
-    }
-  };
-
-  const viewVendorDetail = (vendorSeq) => {
-    router.push(`/view/vendor/${vendorSeq}`);
-  };
-
-  const viewRequestDetail = (requestSeq) => {
-    router.push(`/view/vendor/request/${requestSeq}`);
-  };
-
-  // 유틸리티 함수들
-  const getCategoryText = (category) => {
-    const categoryMap = {
-      FASHION_BEAUTY: "패션·뷰티",
-      FOOD_HEALTH: "식품·건강",
-      LIFESTYLE: "라이프스타일",
-      TECH_ELECTRONICS: "테크·가전",
-      SPORTS_LEISURE: "스포츠·레저",
-      CULTURE_ENTERTAINMENT: "문화·엔터테인먼트",
-    };
-    return categoryMap[category] || category || "기타";
-  };
-
-  const getStatusText = (status) => {
-    const statusMap = {
-      PENDING: "대기중",
-      APPROVED: "승인완료",
-      REJECTED: "거절됨",
-      CANCELLED: "취소됨",
-    };
-    return statusMap[status] || status || "알 수 없음";
-  };
-
-  const getStatusColor = (status) => {
-    const colorMap = {
-      PENDING: "orange",
-      APPROVED: "success",
-      REJECTED: "error",
-      CANCELLED: "grey",
-    };
-    return colorMap[status] || "grey";
-  };
-
-  const getStatusClass = (status) => {
-    return `status-${status?.toLowerCase() || "unknown"}`;
-  };
-
-  const getPartnershipStatus = (vendorSeq) => {
-    // 현재 인플루언서의 해당 벤더사에 대한 파트너십 상태 확인
-    const request = myRequests.value.find(req => req.VENDOR_SEQ === vendorSeq);
-    return request ? request.STATUS : null;
-  };
-
-  const getPartnershipStatusText = (vendorSeq) => {
-    const status = getPartnershipStatus(vendorSeq);
-    const statusMap = {
-      PENDING: "승인 대기중",
-      APPROVED: "승인 완료",
-      REJECTED: "승인 거절됨",
-      CANCELLED: "요청 취소됨"
-    };
-    return statusMap[status] || "신규 파트너십 가능";
-  };
-
-  // 승인요청 버튼 표시 여부 확인
-  const showRequestButton = (vendorSeq) => {
-    const status = getPartnershipStatus(vendorSeq);
-    return !status || status === 'REJECTED' || status === 'CANCELLED';
-  };
-
-  const formatDate = (dateString) => {
-    return new Date(dateString).toLocaleDateString("ko-KR");
-  };
-
-  /************************************************************************
-|    라이프사이클
-************************************************************************/
-  onMounted(async () => {
-    await Promise.all([loadVendors(), loadMyRequests()]);
-  });
-</script>
-
-<style scoped>
-  .section--header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    margin-bottom: 16px;
-    padding-bottom: 8px;
-    border-bottom: 1px solid #e0e0e0;
-  }
-
-  .my--requests--wrap {
-    background: #f8f9fa;
-    border-radius: 8px;
-    padding: 16px;
-    margin-bottom: 20px;
-  }
-
-  .request--cards {
-    display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
-    gap: 16px;
-  }
-
-  .request--card {
-    background: white;
-    border-radius: 8px;
-    padding: 16px;
-    border-left: 4px solid #e0e0e0;
-  }
-
-  .request--card.status-pending {
-    border-left-color: #ff9800;
-  }
-
-  .request--card.status-approved {
-    border-left-color: #4caf50;
-  }
-
-  .request--card.status-rejected {
-    border-left-color: #f44336;
-  }
-
-  .card--header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    margin-bottom: 12px;
-  }
-
-  .card--header h4 {
-    margin: 0;
-    font-size: 16px;
-    font-weight: 600;
-  }
-
-  .card--content {
-    margin-bottom: 12px;
-  }
-
-  .card--content p {
-    margin: 4px 0;
-    font-size: 14px;
-    color: #666;
-  }
-
-  .request--message {
-    font-style: italic;
-    color: #333 !important;
-  }
-
-  .card--actions {
-    display: flex;
-    gap: 8px;
-  }
-
-  .vendor--search--wrap {
-    margin-top: 20px;
-  }
-
-  .vendor--grid {
-    display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
-    gap: 20px;
-    margin-bottom: 20px;
-  }
-
-  .vendor--card {
-    background: white;
-    border-radius: 12px;
-    padding: 20px;
-    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-    transition: transform 0.2s, box-shadow 0.2s;
-  }
-
-  .vendor--card:hover {
-    transform: translateY(-2px);
-    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
-  }
-
-  .vendor--card--header {
-    display: flex;
-    gap: 16px;
-    margin-bottom: 16px;
-  }
-
-  .vendor--logo {
-    width: 60px;
-    height: 60px;
-    border-radius: 8px;
-    overflow: hidden;
-    flex-shrink: 0;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    background: #f5f5f5;
-  }
-
-  .no-logo {
-    font-size: 24px;
-    font-weight: bold;
-    color: #666;
-  }
-
-  .vendor--info h3 {
-    margin: 0 0 4px 0;
-    font-size: 18px;
-    font-weight: 600;
-  }
-
-  .vendor--category {
-    color: #666;
-    font-size: 14px;
-    margin: 0 0 8px 0;
-  }
-
-  .vendor--meta {
-    display: flex;
-    flex-direction: column;
-    gap: 4px;
-  }
-
-  .vendor--meta span {
-    font-size: 12px;
-    color: #888;
-  }
-
-  .vendor--card--body {
-    margin-bottom: 16px;
-  }
-
-  .vendor--description {
-    font-size: 14px;
-    color: #666;
-    line-height: 1.4;
-    margin-bottom: 12px;
-  }
-
-  .vendor--tags {
-    margin-bottom: 8px;
-  }
-
-  .vendor--card--footer {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-  }
-
-  .partnership--status {
-    flex: 1;
-  }
-
-  .status-badge {
-    padding: 4px 8px;
-    border-radius: 4px;
-    font-size: 12px;
-    font-weight: 500;
-  }
-
-  .status-badge.available {
-    background: #e8f5e8;
-    color: #2e7d32;
-  }
-
-  .status-badge.pending {
-    background: #fff3e0;
-    color: #ef6c00;
-  }
-
-  .status-badge.approved {
-    background: #e8f5e8;
-    color: #2e7d32;
-  }
-
-  .status-badge.rejected {
-    background: #ffebee;
-    color: #c62828;
-  }
-
-  .card--actions {
-    display: flex;
-    gap: 8px;
-  }
-
-  .loading-wrap,
-  .error-wrap,
-  .no-data-wrap {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-    padding: 60px 20px;
-  }
-
-  .no-data {
-    text-align: center;
-  }
-
-  .no-data h3 {
-    margin: 16px 0 8px;
-    color: #666;
-  }
-
-  .no-data p {
-    color: #999;
-  }
-
-  .pagination-wrap {
-    display: flex;
-    justify-content: center;
-    margin-top: 20px;
-  }
-
-  .request--form {
-    padding: 8px 0;
-  }
-
-  .vendor--summary {
-    display: flex;
-    align-items: center;
-    gap: 12px;
-    padding: 12px;
-    background: #f8f9fa;
-    border-radius: 8px;
-    margin-bottom: 16px;
-  }
-
-  .vendor--logo--small {
-    width: 40px;
-    height: 40px;
-    border-radius: 6px;
-    overflow: hidden;
-    flex-shrink: 0;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    background: #f5f5f5;
-  }
-
-  .no-logo--small {
-    font-size: 16px;
-    font-weight: bold;
-    color: #666;
-  }
-
-  .form--section {
-    margin-top: 16px;
-  }
-
-  .form--section h5 {
-    margin: 0 0 8px 0;
-    font-size: 14px;
-    font-weight: 600;
-    color: #333;
-  }
-
-  .result-count {
-    font-size: 14px;
-    color: #666;
-    font-weight: 500;
-  }
-</style>