influencer-requests.vue 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465
  1. <template>
  2. <div>
  3. <div class="inner--headers">
  4. <h2>{{ pageId }}</h2>
  5. <div class="bread--crumbs--wrap">
  6. <span>홈</span>
  7. <span>벤더 대시보드</span>
  8. <span>{{ pageId }}</span>
  9. </div>
  10. </div>
  11. <!-- 통계 카드 -->
  12. <div class="stats--cards--wrap">
  13. <div class="stats--card">
  14. <div class="stats--icon pending">
  15. <v-icon>mdi-clock-outline</v-icon>
  16. </div>
  17. <div class="stats--content">
  18. <h3>{{ stats.pending || 0 }}</h3>
  19. <p>대기 중인 승인요청</p>
  20. </div>
  21. </div>
  22. <div class="stats--card">
  23. <div class="stats--icon approved">
  24. <v-icon>mdi-check-circle</v-icon>
  25. </div>
  26. <div class="stats--content">
  27. <h3>{{ stats.approved || 0 }}</h3>
  28. <p>승인 완료</p>
  29. </div>
  30. </div>
  31. <div class="stats--card">
  32. <div class="stats--icon rejected">
  33. <v-icon>mdi-close-circle</v-icon>
  34. </div>
  35. <div class="stats--content">
  36. <h3>{{ stats.rejected || 0 }}</h3>
  37. <p>거부</p>
  38. </div>
  39. </div>
  40. <div class="stats--card">
  41. <div class="stats--icon total">
  42. <v-icon>mdi-account-group</v-icon>
  43. </div>
  44. <div class="stats--content">
  45. <h3>{{ stats.total || 0 }}</h3>
  46. <p>총 요청 수</p>
  47. </div>
  48. </div>
  49. </div>
  50. <!-- 필터 및 검색 -->
  51. <div class="search--modules type2">
  52. <div class="search--inner">
  53. <div class="form--cont--filter">
  54. <v-select
  55. v-model="searchFilter.status"
  56. :items="statusOptions"
  57. variant="outlined"
  58. class="custom-select"
  59. label="상태"
  60. clearable
  61. >
  62. </v-select>
  63. </div>
  64. <div class="form--cont--filter">
  65. <v-select
  66. v-model="searchFilter.category"
  67. :items="categoryOptions"
  68. variant="outlined"
  69. class="custom-select"
  70. label="인플루언서 카테고리"
  71. clearable
  72. >
  73. </v-select>
  74. </div>
  75. <div class="form--cont--text">
  76. <v-text-field
  77. v-model="searchFilter.keyword"
  78. class="custom-input mini"
  79. style="width: 100%"
  80. placeholder="인플루언서명을 입력하세요"
  81. @keyup.enter="handleSearch"
  82. ></v-text-field>
  83. </div>
  84. </div>
  85. <v-btn
  86. class="custom-btn btn-blue mini sch--btn"
  87. @click="handleSearch"
  88. :loading="loading"
  89. >
  90. 검색
  91. </v-btn>
  92. </div>
  93. <!-- 인플루언서 승인 요청 목록 -->
  94. <div class="data--list--wrap">
  95. <div class="btn--actions--wrap">
  96. <div class="left--sections">
  97. <span class="result-count">
  98. 총 {{ pagination.totalCount || 0 }}개의 승인요청
  99. </span>
  100. </div>
  101. <div class="right--sections">
  102. <v-select
  103. v-model="sortOption"
  104. :items="sortOptions"
  105. variant="outlined"
  106. class="custom-select mini"
  107. @update:model-value="handleSort"
  108. >
  109. </v-select>
  110. </div>
  111. </div>
  112. <!-- 로딩 상태 -->
  113. <div v-if="loading" class="loading-wrap">
  114. <v-progress-circular indeterminate color="primary"></v-progress-circular>
  115. <p>승인요청을 불러오고 있습니다...</p>
  116. </div>
  117. <!-- 에러 상태 -->
  118. <div v-else-if="error" class="error-wrap">
  119. <v-alert type="error" dismissible @click:close="error = null">
  120. {{ error }}
  121. </v-alert>
  122. </div>
  123. <!-- 승인요청 리스트 -->
  124. <div v-else-if="requests && requests.length > 0" class="requests--list--wrap">
  125. <div class="requests--grid">
  126. <div
  127. v-for="request in requests"
  128. :key="request.SEQ"
  129. class="request--card"
  130. :class="getRequestStatusClass(request.STATUS)"
  131. >
  132. <!-- 카드 헤더 -->
  133. <div class="request--card--header">
  134. <div class="influencer--info">
  135. <div class="influencer--avatar">
  136. <v-img
  137. v-if="request.influencerAvatar"
  138. :src="request.influencerAvatar"
  139. :alt="request.influencerNickname + ' 프로필'"
  140. width="50"
  141. height="50"
  142. ></v-img>
  143. <div v-else class="no-avatar">
  144. {{ request.influencerNickname?.charAt(0) || "U" }}
  145. </div>
  146. </div>
  147. <div class="influencer--details">
  148. <div class="influencer--header">
  149. <h4>{{ request.influencerNickname || request.influencerName }}</h4>
  150. <p class="influencer--category">
  151. {{ getCategoryText(request.influencerCategory) }}
  152. </p>
  153. </div>
  154. <div class="influencer--contact">
  155. <p v-if="request.influencerEmail" class="contact--item">
  156. <v-icon size="small">mdi-email</v-icon>
  157. {{ request.influencerEmail }}
  158. </p>
  159. <p v-if="request.influencerPhone" class="contact--item">
  160. <v-icon size="small">mdi-phone</v-icon>
  161. {{ request.influencerPhone }}
  162. </p>
  163. <p v-if="request.influencerRegion" class="contact--item">
  164. <v-icon size="small">mdi-map-marker</v-icon>
  165. {{ request.influencerRegion }}
  166. </p>
  167. </div>
  168. <div class="influencer--meta">
  169. <span v-if="request.followerCount" class="meta--item">
  170. <v-icon size="small">mdi-account-group</v-icon>
  171. {{ formatNumber(request.followerCount) }} 팔로워
  172. </span>
  173. <span v-if="request.avgViews" class="meta--item">
  174. <v-icon size="small">mdi-eye</v-icon>
  175. 평균 {{ formatNumber(request.avgViews) }} 조회
  176. </span>
  177. <span v-if="request.engagementRate" class="meta--item">
  178. <v-icon size="small">mdi-chart-line</v-icon>
  179. 참여율 {{ request.engagementRate }}%
  180. </span>
  181. </div>
  182. <div
  183. v-if="request.influencerDescription"
  184. class="influencer--description"
  185. >
  186. <p>{{ request.influencerDescription }}</p>
  187. </div>
  188. <div v-if="request.influencerSnsChannels" class="influencer--sns">
  189. <div
  190. v-for="(channel, index) in parseSnsChannels(
  191. request.influencerSnsChannels
  192. )"
  193. :key="index"
  194. class="sns--item"
  195. >
  196. <v-icon size="small">{{ getSnsIcon(channel.platform) }}</v-icon>
  197. {{ channel.handle }}
  198. </div>
  199. </div>
  200. </div>
  201. </div>
  202. <div class="request--status">
  203. <div class="status--badges">
  204. <v-chip :color="getStatusColor(request.STATUS)" size="small">
  205. {{ getStatusText(request.STATUS) }}
  206. </v-chip>
  207. <v-chip
  208. v-if="request.ADD_INFO1 === 'REAPPLY'"
  209. color="orange"
  210. size="small"
  211. class="ml-2"
  212. >
  213. 재승인요청
  214. </v-chip>
  215. </div>
  216. <p class="request--date">{{ formatDate(request.REQUEST_DATE) }}</p>
  217. </div>
  218. </div>
  219. <!-- 카드 바디 -->
  220. <div class="request--card--body">
  221. <!-- 재승인 요청 안내 -->
  222. <div v-if="request.ADD_INFO1 === 'REAPPLY'" class="reapply--notice">
  223. <v-alert
  224. type="info"
  225. variant="tonal"
  226. density="compact"
  227. class="mb-4"
  228. >
  229. <v-icon size="16">mdi-refresh</v-icon>
  230. <span class="ml-2">
  231. 이전에 파트너십을 맺었던 인플루언서의 재승인 요청입니다.
  232. <br>이전 파트너십 종료일: {{ formatDate(request.ADD_INFO3) }}
  233. </span>
  234. </v-alert>
  235. </div>
  236. <div v-if="request.REQUEST_MESSAGE" class="request--message">
  237. <h5>{{ request.ADD_INFO1 === 'REAPPLY' ? '재승인 요청 메시지' : '요청 메시지' }}</h5>
  238. <p>"{{ request.REQUEST_MESSAGE }}"</p>
  239. </div>
  240. <div
  241. v-if="request.COMMISSION_RATE || request.SPECIAL_CONDITIONS"
  242. class="request--conditions"
  243. >
  244. <h5>희망 조건</h5>
  245. <div v-if="request.COMMISSION_RATE" class="condition--item">
  246. <span class="condition--label">희망 수수료율:</span>
  247. <span class="condition--value">{{ request.COMMISSION_RATE }}%</span>
  248. </div>
  249. <div v-if="request.SPECIAL_CONDITIONS" class="condition--item">
  250. <span class="condition--label">특별 조건:</span>
  251. <span class="condition--value">{{ request.SPECIAL_CONDITIONS }}</span>
  252. </div>
  253. </div>
  254. <div v-if="request.STATUS === 'PENDING'" class="expire--info">
  255. <v-icon size="16" color="warning">mdi-clock-alert</v-icon>
  256. <span>만료일: {{ formatDate(request.EXPIRED_DATE) }}</span>
  257. </div>
  258. </div>
  259. <!-- 카드 푸터 (액션 버튼) -->
  260. <div class="request--card--footer">
  261. <div class="card--actions">
  262. <v-btn
  263. class="custom-btn mini btn-outline"
  264. @click="viewInfluencerDetail(request.INFLUENCER_SEQ)"
  265. >
  266. 프로필 보기
  267. </v-btn>
  268. <div v-if="request.STATUS === 'PENDING'" class="approval--actions">
  269. <v-btn
  270. class="custom-btn mini btn-red"
  271. @click="handleReject(request)"
  272. :loading="processing"
  273. >
  274. 거부
  275. </v-btn>
  276. <v-btn
  277. class="custom-btn mini btn-blue"
  278. @click="handleApprove(request)"
  279. :loading="processing"
  280. >
  281. {{ request.ADD_INFO1 === 'REAPPLY' ? '재승인' : '승인' }}
  282. </v-btn>
  283. </div>
  284. <div v-else-if="request.STATUS === 'APPROVED'" class="approved--actions">
  285. <v-btn
  286. class="custom-btn mini btn-outline"
  287. @click="viewRequestHistory(request.SEQ)"
  288. >
  289. 이력보기
  290. </v-btn>
  291. <v-btn
  292. class="custom-btn mini btn-terminate"
  293. @click="handleTerminate(request)"
  294. :loading="processing"
  295. >
  296. <v-icon left size="small">mdi-link-off</v-icon>
  297. 해지
  298. </v-btn>
  299. </div>
  300. <div
  301. v-else-if="request.STATUS === 'TERMINATED'"
  302. class="terminated--actions"
  303. >
  304. <v-btn
  305. class="custom-btn mini btn-outline"
  306. @click="viewRequestHistory(request.SEQ)"
  307. >
  308. 이력보기
  309. </v-btn>
  310. </div>
  311. <v-btn
  312. v-else
  313. class="custom-btn mini btn-outline"
  314. @click="viewRequestHistory(request.SEQ)"
  315. >
  316. 이력보기
  317. </v-btn>
  318. </div>
  319. </div>
  320. </div>
  321. </div>
  322. <!-- 페이지네이션 -->
  323. <div class="pagination-wrap" v-if="pagination.totalPages > 1">
  324. <v-pagination
  325. v-model="currentPage"
  326. :length="pagination.totalPages"
  327. :total-visible="7"
  328. @update:model-value="handlePageChange"
  329. ></v-pagination>
  330. </div>
  331. </div>
  332. <!-- 검색 결과 없음 -->
  333. <div v-else class="no-data-wrap">
  334. <div class="no-data">
  335. <v-icon size="64" color="grey-lighten-1">mdi-account-search</v-icon>
  336. <h3>승인요청이 없습니다</h3>
  337. <p>아직 인플루언서로부터 승인요청이 없습니다</p>
  338. </div>
  339. </div>
  340. </div>
  341. <!-- 승인 확인 모달 -->
  342. <v-dialog v-model="approveModal.show" max-width="500px">
  343. <v-card>
  344. <v-card-title class="text-h5 text-success">
  345. <v-icon left>mdi-check-circle</v-icon>
  346. 승인 확인
  347. </v-card-title>
  348. <v-card-text>
  349. <div class="approve--content">
  350. <div class="influencer--summary">
  351. <div class="influencer--avatar--small">
  352. <v-img
  353. v-if="approveModal.request?.influencerAvatar"
  354. :src="approveModal.request.influencerAvatar"
  355. width="40"
  356. height="40"
  357. ></v-img>
  358. <div v-else class="no-avatar--small">
  359. {{ approveModal.request?.influencerNickname?.charAt(0) || "U" }}
  360. </div>
  361. </div>
  362. <div>
  363. <h4>{{ approveModal.request?.influencerNickname }}</h4>
  364. <p>{{ getCategoryText(approveModal.request?.influencerCategory) }}</p>
  365. </div>
  366. </div>
  367. <p>이 인플루언서의 승인요청을 승인하시겠습니까?</p>
  368. <v-textarea
  369. v-model="approveModal.approveMessage"
  370. label="승인 메시지 (선택사항)"
  371. placeholder="인플루언서에게 전달할 메시지를 입력해주세요..."
  372. rows="3"
  373. counter="300"
  374. maxlength="300"
  375. class="mt-4"
  376. ></v-textarea>
  377. </div>
  378. </v-card-text>
  379. <v-card-actions>
  380. <v-spacer></v-spacer>
  381. <v-btn color="grey" variant="text" @click="closeApproveModal">취소</v-btn>
  382. <v-btn color="success" @click="confirmApprove" :loading="processing">
  383. 승인하기
  384. </v-btn>
  385. </v-card-actions>
  386. </v-card>
  387. </v-dialog>
  388. <!-- 거부 확인 모달 -->
  389. <v-dialog v-model="rejectModal.show" max-width="500px">
  390. <v-card>
  391. <v-card-title class="text-h5 text-error">
  392. <v-icon left>mdi-close-circle</v-icon>
  393. 거부 확인
  394. </v-card-title>
  395. <v-card-text>
  396. <div class="reject--content">
  397. <div class="influencer--summary">
  398. <div class="influencer--avatar--small">
  399. <v-img
  400. v-if="rejectModal.request?.influencerAvatar"
  401. :src="rejectModal.request.influencerAvatar"
  402. width="40"
  403. height="40"
  404. ></v-img>
  405. <div v-else class="no-avatar--small">
  406. {{ rejectModal.request?.influencerNickname?.charAt(0) || "U" }}
  407. </div>
  408. </div>
  409. <div>
  410. <h4>{{ rejectModal.request?.influencerNickname }}</h4>
  411. <p>{{ getCategoryText(rejectModal.request?.influencerCategory) }}</p>
  412. </div>
  413. </div>
  414. <p>이 인플루언서의 승인요청을 거부하시겠습니까?</p>
  415. <v-textarea
  416. v-model="rejectModal.rejectReason"
  417. label="거부 사유"
  418. placeholder="거부 사유를 입력해주세요..."
  419. rows="4"
  420. counter="500"
  421. maxlength="500"
  422. class="mt-4"
  423. required
  424. ></v-textarea>
  425. </div>
  426. </v-card-text>
  427. <v-card-actions>
  428. <v-spacer></v-spacer>
  429. <v-btn color="grey" variant="text" @click="closeRejectModal">취소</v-btn>
  430. <v-btn color="error" @click="confirmReject" :loading="processing">
  431. 거부하기
  432. </v-btn>
  433. </v-card-actions>
  434. </v-card>
  435. </v-dialog>
  436. <!-- 해지 확인 모달 -->
  437. <v-dialog v-model="terminateModal.show" max-width="500px">
  438. <v-card>
  439. <v-card-title class="text-h5 text-warning">
  440. <v-icon left>mdi-link-off</v-icon>
  441. 파트너십 해지 확인
  442. </v-card-title>
  443. <v-card-text>
  444. <div class="terminate--content">
  445. <div class="influencer--summary">
  446. <div class="influencer--avatar--small">
  447. <v-img
  448. v-if="terminateModal.request?.influencerAvatar"
  449. :src="terminateModal.request.influencerAvatar"
  450. width="40"
  451. height="40"
  452. ></v-img>
  453. <div v-else class="no-avatar--small">
  454. {{ terminateModal.request?.influencerNickname?.charAt(0) || "U" }}
  455. </div>
  456. </div>
  457. <div>
  458. <h4>{{ terminateModal.request?.influencerNickname }}</h4>
  459. <p>{{ getCategoryText(terminateModal.request?.influencerCategory) }}</p>
  460. </div>
  461. </div>
  462. <v-alert type="warning" class="mb-4">
  463. <strong>주의:</strong> 파트너십을 해지하면 협업 관계가 종료되며, 이 작업은
  464. 되돌릴 수 없습니다.
  465. </v-alert>
  466. <p>이 인플루언서와의 파트너십을 해지하시겠습니까?</p>
  467. <v-textarea
  468. v-model="terminateModal.terminateReason"
  469. label="해지 사유"
  470. placeholder="해지 사유를 입력해주세요..."
  471. rows="4"
  472. counter="500"
  473. maxlength="500"
  474. class="mt-4"
  475. required
  476. ></v-textarea>
  477. </div>
  478. </v-card-text>
  479. <v-card-actions>
  480. <v-spacer></v-spacer>
  481. <v-btn color="grey" variant="text" @click="closeTerminateModal">취소</v-btn>
  482. <v-btn
  483. class="btn-terminate-confirm"
  484. @click="confirmTerminate"
  485. :loading="processing"
  486. >
  487. <v-icon left>mdi-link-off</v-icon>
  488. 해지하기
  489. </v-btn>
  490. </v-card-actions>
  491. </v-card>
  492. </v-dialog>
  493. </div>
  494. </template>
  495. <script setup>
  496. import { ref, onMounted, computed } from "vue";
  497. import { useRouter } from "vue-router";
  498. /************************************************************************
  499. | 레이아웃
  500. ************************************************************************/
  501. definePageMeta({
  502. layout: "default",
  503. });
  504. /************************************************************************
  505. | 스토어 & 라우터
  506. ************************************************************************/
  507. const router = useRouter();
  508. const { $toast } = useNuxtApp();
  509. /************************************************************************
  510. | 반응형 데이터
  511. ************************************************************************/
  512. const pageId = ref("인플루언서 승인요청 관리");
  513. const loading = ref(false);
  514. const processing = ref(false);
  515. const error = ref(null);
  516. const currentPage = ref(1);
  517. // 검색 필터
  518. const searchFilter = ref({
  519. keyword: "",
  520. status: "",
  521. category: "",
  522. });
  523. // 정렬 옵션
  524. const sortOption = ref("latest");
  525. const sortOptions = ref([
  526. { title: "최신순", value: "latest" },
  527. { title: "오래된순", value: "oldest" },
  528. { title: "마감임박순", value: "expiring" },
  529. ]);
  530. // 상태 옵션
  531. const statusOptions = ref([
  532. { title: "전체", value: "" },
  533. { title: "대기중", value: "PENDING" },
  534. { title: "승인완료", value: "APPROVED" },
  535. { title: "거부됨", value: "REJECTED" },
  536. { title: "해지됨", value: "TERMINATED" },
  537. ]);
  538. // 카테고리 옵션
  539. const categoryOptions = ref([
  540. { title: "전체", value: "" },
  541. { title: "패션·뷰티", value: "FASHION_BEAUTY" },
  542. { title: "식품·건강", value: "FOOD_HEALTH" },
  543. { title: "라이프스타일", value: "LIFESTYLE" },
  544. { title: "테크·가전", value: "TECH_ELECTRONICS" },
  545. { title: "스포츠·레저", value: "SPORTS_LEISURE" },
  546. { title: "문화·엔터테인먼트", value: "CULTURE_ENTERTAINMENT" },
  547. ]);
  548. // 데이터
  549. const requests = ref([]);
  550. const stats = ref({
  551. pending: 0,
  552. approved: 0,
  553. rejected: 0,
  554. total: 0,
  555. });
  556. const pagination = ref({
  557. currentPage: 1,
  558. totalPages: 1,
  559. totalCount: 0,
  560. pageSize: 12,
  561. });
  562. // 승인 모달
  563. const approveModal = ref({
  564. show: false,
  565. request: null,
  566. approveMessage: "",
  567. });
  568. // 거부 모달
  569. const rejectModal = ref({
  570. show: false,
  571. request: null,
  572. rejectReason: "",
  573. });
  574. // 해지 모달
  575. const terminateModal = ref({
  576. show: false,
  577. request: null,
  578. terminateReason: "",
  579. });
  580. /************************************************************************
  581. | computed
  582. ************************************************************************/
  583. const currentUser = computed(() => {
  584. const authData = JSON.parse(localStorage.getItem("authStore"))?.auth || {};
  585. console.log("🔍 currentUser (벤더 대시보드):", authData);
  586. return authData;
  587. });
  588. /************************************************************************
  589. | 메서드
  590. ************************************************************************/
  591. const handleSearch = async () => {
  592. currentPage.value = 1;
  593. await loadRequests();
  594. };
  595. const handlePageChange = async (page) => {
  596. currentPage.value = page;
  597. await loadRequests();
  598. };
  599. const handleSort = async () => {
  600. currentPage.value = 1;
  601. await loadRequests();
  602. };
  603. const loadRequests = async () => {
  604. try {
  605. loading.value = true;
  606. error.value = null;
  607. const params = {
  608. vendorSeq: currentUser.value.seq,
  609. keyword: searchFilter.value.keyword,
  610. status: searchFilter.value.status,
  611. category: searchFilter.value.category,
  612. sortBy: sortOption.value,
  613. page: currentPage.value,
  614. size: pagination.value.pageSize,
  615. };
  616. console.log("🔍 loadRequests 호출됨:", params);
  617. useAxios()
  618. .post("/api/vendor-influencer/requests", params)
  619. .then((res) => {
  620. console.log("📥 API 응답:", res.data);
  621. if (res.data.success) {
  622. const items = res.data.data.items || []; // 빈 배열로 기본값 설정
  623. console.log("📋 받아온 요청 목록:", items.length, items);
  624. // SEQ 중복 확인
  625. if (items.length > 0) {
  626. const seqs = items.map((item) => item.SEQ);
  627. const uniqueSeqs = [...new Set(seqs)];
  628. if (seqs.length !== uniqueSeqs.length) {
  629. console.warn("⚠️ 중복된 SEQ 발견:", seqs);
  630. }
  631. }
  632. requests.value = items;
  633. pagination.value = {
  634. totalCount: res.data.data.total || 0,
  635. currentPage: res.data.data.page || 1,
  636. totalPages: res.data.data.totalPages || 1,
  637. pageSize: res.data.data.size || 20
  638. };
  639. stats.value = res.data.data.stats || {
  640. pending: 0,
  641. approved: 0,
  642. rejected: 0,
  643. total: 0
  644. };
  645. } else {
  646. error.value =
  647. res.data.message || "승인요청 목록을 불러오는 중 오류가 발생했습니다.";
  648. }
  649. })
  650. .catch((err) => {
  651. error.value = err.message || "승인요청 목록을 불러오는 중 오류가 발생했습니다.";
  652. })
  653. .finally(() => {
  654. loading.value = false;
  655. });
  656. } catch (err) {
  657. error.value = err.message || "승인요청 목록을 불러오는 중 오류가 발생했습니다.";
  658. loading.value = false;
  659. }
  660. };
  661. const handleApprove = (request) => {
  662. approveModal.value = {
  663. show: true,
  664. request: request,
  665. approveMessage: "",
  666. };
  667. };
  668. const closeApproveModal = () => {
  669. approveModal.value = {
  670. show: false,
  671. request: null,
  672. approveMessage: "",
  673. };
  674. };
  675. const confirmApprove = async () => {
  676. try {
  677. processing.value = true;
  678. const params = {
  679. mappingSeq: approveModal.value.request.SEQ,
  680. action: "APPROVE",
  681. processedBy: currentUser.value.seq,
  682. responseMessage: approveModal.value.approveMessage,
  683. };
  684. console.log("✅ 승인 처리 시작:", params);
  685. useAxios()
  686. .post("/api/vendor-influencer/approve", params)
  687. .then((res) => {
  688. console.log("📥 승인 처리 응답:", res.data);
  689. if (res.data.success) {
  690. $toast.success("승인요청이 승인되었습니다.");
  691. closeApproveModal();
  692. console.log("🔄 승인 후 목록 새로고침");
  693. loadRequests();
  694. } else {
  695. console.error("❌ 승인 처리 실패:", res.data);
  696. $toast.error(res.data.message || "승인 처리 중 오류가 발생했습니다.");
  697. }
  698. })
  699. .catch((err) => {
  700. $toast.error(err.message || "승인 처리 중 오류가 발생했습니다.");
  701. })
  702. .finally(() => {
  703. processing.value = false;
  704. });
  705. } catch (err) {
  706. $toast.error(err.message || "승인 처리 중 오류가 발생했습니다.");
  707. processing.value = false;
  708. }
  709. };
  710. const handleReject = (request) => {
  711. rejectModal.value = {
  712. show: true,
  713. request: request,
  714. rejectReason: "",
  715. };
  716. };
  717. const closeRejectModal = () => {
  718. rejectModal.value = {
  719. show: false,
  720. request: null,
  721. rejectReason: "",
  722. };
  723. };
  724. const confirmReject = async () => {
  725. if (!rejectModal.value.rejectReason.trim()) {
  726. $toast.error("거부 사유를 입력해주세요.");
  727. return;
  728. }
  729. try {
  730. processing.value = true;
  731. const params = {
  732. mappingSeq: rejectModal.value.request.SEQ,
  733. action: "REJECT",
  734. processedBy: currentUser.value.seq,
  735. responseMessage: rejectModal.value.rejectReason,
  736. };
  737. useAxios()
  738. .post("/api/vendor-influencer/approve", params)
  739. .then((res) => {
  740. if (res.data.success) {
  741. $toast.success("승인요청이 거부되었습니다.");
  742. closeRejectModal();
  743. loadRequests();
  744. } else {
  745. $toast.error(res.data.message || "거부 처리 중 오류가 발생했습니다.");
  746. }
  747. })
  748. .catch((err) => {
  749. $toast.error(err.message || "거부 처리 중 오류가 발생했습니다.");
  750. })
  751. .finally(() => {
  752. processing.value = false;
  753. });
  754. } catch (err) {
  755. $toast.error(err.message || "거부 처리 중 오류가 발생했습니다.");
  756. processing.value = false;
  757. }
  758. };
  759. const viewInfluencerDetail = (influencerSeq) => {
  760. router.push(`/view/influencer/${influencerSeq}`);
  761. };
  762. const viewRequestHistory = (requestSeq) => {
  763. router.push(`/view/vendor/request-history/${requestSeq}`);
  764. };
  765. /**
  766. * 파트너십 해지
  767. */
  768. const handleTerminate = (request) => {
  769. terminateModal.value = {
  770. show: true,
  771. request: request,
  772. terminateReason: "",
  773. };
  774. };
  775. const closeTerminateModal = () => {
  776. terminateModal.value = {
  777. show: false,
  778. request: null,
  779. terminateReason: "",
  780. };
  781. };
  782. const confirmTerminate = async () => {
  783. if (!terminateModal.value.terminateReason.trim()) {
  784. $toast.error("해지 사유를 입력해주세요.");
  785. return;
  786. }
  787. try {
  788. processing.value = true;
  789. const params = {
  790. mappingSeq: terminateModal.value.request.SEQ,
  791. terminateReason: terminateModal.value.terminateReason,
  792. terminatedBy: currentUser.value.seq,
  793. };
  794. console.log("🔗 파트너십 해지 처리 시작:", params);
  795. useAxios()
  796. .post("/api/vendor-influencer/terminate", params)
  797. .then((res) => {
  798. console.log("📥 해지 처리 응답:", res.data);
  799. if (res.data.success) {
  800. $toast.success("파트너십이 해지되었습니다.");
  801. closeTerminateModal();
  802. console.log("🔄 해지 후 목록 새로고침");
  803. loadRequests();
  804. } else {
  805. console.error("❌ 해지 처리 실패:", res.data);
  806. $toast.error(res.data.message || "해지 처리 중 오류가 발생했습니다.");
  807. }
  808. })
  809. .catch((err) => {
  810. $toast.error(err.message || "해지 처리 중 오류가 발생했습니다.");
  811. })
  812. .finally(() => {
  813. processing.value = false;
  814. });
  815. } catch (err) {
  816. $toast.error(err.message || "해지 처리 중 오류가 발생했습니다.");
  817. processing.value = false;
  818. }
  819. };
  820. // 유틸리티 함수들
  821. const getCategoryText = (category) => {
  822. const categoryMap = {
  823. FASHION_BEAUTY: "패션·뷰티",
  824. FOOD_HEALTH: "식품·건강",
  825. LIFESTYLE: "라이프스타일",
  826. TECH_ELECTRONICS: "테크·가전",
  827. SPORTS_LEISURE: "스포츠·레저",
  828. CULTURE_ENTERTAINMENT: "문화·엔터테인먼트",
  829. };
  830. return categoryMap[category] || category || "기타";
  831. };
  832. const getStatusText = (status) => {
  833. const statusMap = {
  834. PENDING: "대기중",
  835. APPROVED: "승인완료",
  836. REJECTED: "거절됨",
  837. TERMINATED: "해지됨",
  838. EXPIRED: "만료됨",
  839. };
  840. return statusMap[status] || status || "알 수 없음";
  841. };
  842. const getStatusColor = (status) => {
  843. const colorMap = {
  844. PENDING: "orange",
  845. APPROVED: "success",
  846. REJECTED: "error",
  847. TERMINATED: "warning",
  848. EXPIRED: "grey",
  849. };
  850. return colorMap[status] || "grey";
  851. };
  852. const getRequestStatusClass = (status) => {
  853. return `request-status-${status?.toLowerCase() || "unknown"}`;
  854. };
  855. const formatDate = (dateString) => {
  856. return new Date(dateString).toLocaleDateString("ko-KR");
  857. };
  858. const formatNumber = (num) => {
  859. if (!num) return "0";
  860. if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
  861. if (num >= 1000) return (num / 1000).toFixed(1) + "K";
  862. return num.toString();
  863. };
  864. const parseSnsChannels = (snsChannels) => {
  865. try {
  866. return JSON.parse(snsChannels);
  867. } catch (e) {
  868. return [];
  869. }
  870. };
  871. const getSnsIcon = (platform) => {
  872. const iconMap = {
  873. instagram: "mdi-instagram",
  874. youtube: "mdi-youtube",
  875. tiktok: "mdi-music-note",
  876. blog: "mdi-post",
  877. facebook: "mdi-facebook",
  878. twitter: "mdi-twitter",
  879. };
  880. return iconMap[platform.toLowerCase()] || "mdi-link";
  881. };
  882. /************************************************************************
  883. | 라이프사이클
  884. ************************************************************************/
  885. onMounted(async () => {
  886. console.log("🚀 influencer-requests 컴포넌트 마운트됨");
  887. await loadRequests();
  888. });
  889. </script>
  890. <style scoped>
  891. .stats--cards--wrap {
  892. display: grid;
  893. grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  894. gap: 20px;
  895. margin-bottom: 30px;
  896. }
  897. .stats--card {
  898. background: white;
  899. border-radius: 12px;
  900. padding: 20px;
  901. display: flex;
  902. align-items: center;
  903. gap: 16px;
  904. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  905. }
  906. .stats--icon {
  907. width: 50px;
  908. height: 50px;
  909. border-radius: 10px;
  910. display: flex;
  911. align-items: center;
  912. justify-content: center;
  913. color: white;
  914. }
  915. .stats--icon.pending {
  916. background: #ff9800;
  917. }
  918. .stats--icon.approved {
  919. background: #4caf50;
  920. }
  921. .stats--icon.rejected {
  922. background: #f44336;
  923. }
  924. .stats--icon.total {
  925. background: #2196f3;
  926. }
  927. .stats--content h3 {
  928. margin: 0;
  929. font-size: 24px;
  930. font-weight: 700;
  931. color: #333;
  932. }
  933. .stats--content p {
  934. margin: 0;
  935. font-size: 14px;
  936. color: #666;
  937. }
  938. .requests--list--wrap {
  939. margin-top: 20px;
  940. }
  941. .requests--grid {
  942. display: grid;
  943. grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
  944. gap: 20px;
  945. margin-bottom: 20px;
  946. }
  947. .request--card {
  948. background: white;
  949. border-radius: 12px;
  950. padding: 20px;
  951. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  952. transition: transform 0.2s, box-shadow 0.2s;
  953. border-left: 4px solid #e0e0e0;
  954. }
  955. .request--card:hover {
  956. transform: translateY(-2px);
  957. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  958. }
  959. .request--card.request-status-pending {
  960. border-left-color: #ff9800;
  961. }
  962. .request--card.request-status-approved {
  963. border-left-color: #4caf50;
  964. }
  965. .request--card.request-status-rejected {
  966. border-left-color: #f44336;
  967. }
  968. .request--card.request-status-terminated {
  969. border-left-color: #ff9800;
  970. }
  971. .request--card--header {
  972. display: flex;
  973. justify-content: space-between;
  974. align-items: flex-start;
  975. margin-bottom: 16px;
  976. }
  977. .influencer--info {
  978. display: flex;
  979. gap: 12px;
  980. flex: 1;
  981. }
  982. .influencer--avatar {
  983. width: 50px;
  984. height: 50px;
  985. border-radius: 50%;
  986. overflow: hidden;
  987. flex-shrink: 0;
  988. display: flex;
  989. align-items: center;
  990. justify-content: center;
  991. background: #f5f5f5;
  992. }
  993. .no-avatar {
  994. font-size: 20px;
  995. font-weight: bold;
  996. color: #666;
  997. }
  998. .influencer--details h4 {
  999. margin: 0 0 4px 0;
  1000. font-size: 16px;
  1001. font-weight: 600;
  1002. }
  1003. .influencer--category {
  1004. color: #666;
  1005. font-size: 14px;
  1006. margin: 0 0 8px 0;
  1007. }
  1008. .influencer--meta {
  1009. display: flex;
  1010. flex-direction: column;
  1011. gap: 2px;
  1012. }
  1013. .influencer--meta span {
  1014. font-size: 12px;
  1015. color: #888;
  1016. }
  1017. .request--status {
  1018. text-align: right;
  1019. flex-shrink: 0;
  1020. }
  1021. .request--date {
  1022. margin: 8px 0 0;
  1023. font-size: 12px;
  1024. color: #999;
  1025. }
  1026. .request--card--body {
  1027. margin-bottom: 16px;
  1028. }
  1029. .request--message,
  1030. .request--conditions {
  1031. margin-bottom: 12px;
  1032. }
  1033. .request--message h5,
  1034. .request--conditions h5 {
  1035. margin: 0 0 8px 0;
  1036. font-size: 14px;
  1037. font-weight: 600;
  1038. color: #333;
  1039. }
  1040. .request--message p {
  1041. margin: 0;
  1042. font-size: 14px;
  1043. color: #666;
  1044. font-style: italic;
  1045. }
  1046. .condition--item {
  1047. display: flex;
  1048. gap: 8px;
  1049. margin-bottom: 4px;
  1050. }
  1051. .condition--label {
  1052. font-size: 13px;
  1053. color: #666;
  1054. min-width: 80px;
  1055. }
  1056. .condition--value {
  1057. font-size: 13px;
  1058. color: #333;
  1059. font-weight: 500;
  1060. }
  1061. .expire--info {
  1062. display: flex;
  1063. align-items: center;
  1064. gap: 6px;
  1065. font-size: 12px;
  1066. color: #ff9800;
  1067. background: #fff8e1;
  1068. padding: 6px 10px;
  1069. border-radius: 6px;
  1070. }
  1071. .request--card--footer {
  1072. border-top: 1px solid #f0f0f0;
  1073. padding-top: 16px;
  1074. }
  1075. .card--actions {
  1076. display: flex;
  1077. justify-content: space-between;
  1078. align-items: center;
  1079. }
  1080. .approval--actions,
  1081. .approved--actions,
  1082. .terminated--actions {
  1083. display: flex;
  1084. gap: 8px;
  1085. }
  1086. .loading-wrap,
  1087. .error-wrap,
  1088. .no-data-wrap {
  1089. display: flex;
  1090. flex-direction: column;
  1091. align-items: center;
  1092. justify-content: center;
  1093. padding: 60px 20px;
  1094. }
  1095. .no-data {
  1096. text-align: center;
  1097. }
  1098. .no-data h3 {
  1099. margin: 16px 0 8px;
  1100. color: #666;
  1101. }
  1102. .no-data p {
  1103. color: #999;
  1104. }
  1105. .pagination-wrap {
  1106. display: flex;
  1107. justify-content: center;
  1108. margin-top: 20px;
  1109. }
  1110. .approve--content,
  1111. .reject--content,
  1112. .terminate--content {
  1113. padding: 8px 0;
  1114. }
  1115. .influencer--summary {
  1116. display: flex;
  1117. align-items: center;
  1118. gap: 12px;
  1119. padding: 12px;
  1120. background: #f8f9fa;
  1121. border-radius: 8px;
  1122. margin-bottom: 16px;
  1123. }
  1124. .influencer--avatar--small {
  1125. width: 40px;
  1126. height: 40px;
  1127. border-radius: 50%;
  1128. overflow: hidden;
  1129. flex-shrink: 0;
  1130. display: flex;
  1131. align-items: center;
  1132. justify-content: center;
  1133. background: #f5f5f5;
  1134. }
  1135. .no-avatar--small {
  1136. font-size: 16px;
  1137. font-weight: bold;
  1138. color: #666;
  1139. }
  1140. .result-count {
  1141. font-size: 14px;
  1142. color: #666;
  1143. font-weight: 500;
  1144. }
  1145. .influencer--contact {
  1146. margin: 4px 0;
  1147. }
  1148. .contact--item {
  1149. display: flex;
  1150. align-items: center;
  1151. gap: 6px;
  1152. font-size: 13px;
  1153. color: #666;
  1154. margin: 2px 0;
  1155. }
  1156. .contact--item .v-icon {
  1157. color: #999;
  1158. }
  1159. .influencer--header {
  1160. margin-bottom: 8px;
  1161. }
  1162. .influencer--contact {
  1163. margin: 8px 0;
  1164. padding: 8px;
  1165. background: #f8f9fa;
  1166. border-radius: 6px;
  1167. }
  1168. .contact--item {
  1169. display: flex;
  1170. align-items: center;
  1171. gap: 6px;
  1172. font-size: 13px;
  1173. color: #666;
  1174. margin: 4px 0;
  1175. }
  1176. .influencer--meta {
  1177. display: flex;
  1178. flex-wrap: wrap;
  1179. gap: 12px;
  1180. margin: 8px 0;
  1181. }
  1182. .meta--item {
  1183. display: flex;
  1184. align-items: center;
  1185. gap: 4px;
  1186. font-size: 13px;
  1187. color: #555;
  1188. background: #f0f0f0;
  1189. padding: 4px 8px;
  1190. border-radius: 4px;
  1191. }
  1192. .influencer--description {
  1193. margin: 8px 0;
  1194. font-size: 13px;
  1195. color: #666;
  1196. line-height: 1.4;
  1197. }
  1198. .influencer--sns {
  1199. display: flex;
  1200. flex-wrap: wrap;
  1201. gap: 8px;
  1202. margin-top: 8px;
  1203. }
  1204. .sns--item {
  1205. display: flex;
  1206. align-items: center;
  1207. gap: 4px;
  1208. font-size: 12px;
  1209. color: #555;
  1210. background: #eef2ff;
  1211. padding: 4px 8px;
  1212. border-radius: 4px;
  1213. }
  1214. /* 해지 버튼 전용 스타일 */
  1215. .btn-terminate {
  1216. background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
  1217. color: white;
  1218. border: none;
  1219. font-weight: 700;
  1220. min-width: 100px;
  1221. padding: 8px 16px;
  1222. box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3);
  1223. transition: all 0.3s ease;
  1224. }
  1225. .btn-terminate:hover {
  1226. transform: translateY(-2px);
  1227. box-shadow: 0 6px 20px rgba(229, 62, 62, 0.4);
  1228. background: linear-gradient(135deg, #c53030 0%, #9b2c2c 100%);
  1229. }
  1230. .btn-terminate:active {
  1231. transform: translateY(0);
  1232. }
  1233. .btn-terminate:disabled {
  1234. background: #e2e8f0;
  1235. color: #a0aec0;
  1236. box-shadow: none;
  1237. transform: none !important;
  1238. }
  1239. .btn-green {
  1240. background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
  1241. color: white;
  1242. border: none;
  1243. font-weight: 600;
  1244. min-width: 140px;
  1245. padding: 8px 16px;
  1246. box-shadow: 0 4px 12px rgba(56, 161, 105, 0.3);
  1247. transition: all 0.3s ease;
  1248. }
  1249. .btn-green:hover {
  1250. transform: translateY(-2px);
  1251. box-shadow: 0 6px 20px rgba(56, 161, 105, 0.4);
  1252. background: linear-gradient(135deg, #2f855a 0%, #276749 100%);
  1253. }
  1254. .btn-green:active {
  1255. transform: translateY(0);
  1256. }
  1257. .btn-green:disabled {
  1258. background: #e6fffa;
  1259. color: #38a169;
  1260. border: 1px solid #38a169;
  1261. box-shadow: none;
  1262. transform: none !important;
  1263. opacity: 0.7;
  1264. }
  1265. /* 해지 확인 모달 버튼 스타일 */
  1266. .btn-terminate-confirm {
  1267. background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
  1268. color: white !important;
  1269. font-weight: 700 !important;
  1270. font-size: 14px !important;
  1271. padding: 12px 24px !important;
  1272. border-radius: 8px !important;
  1273. border: none !important;
  1274. box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4) !important;
  1275. transition: all 0.3s ease !important;
  1276. text-transform: none !important;
  1277. letter-spacing: 0.5px !important;
  1278. min-width: 120px !important;
  1279. }
  1280. .btn-terminate-confirm:hover {
  1281. background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%) !important;
  1282. box-shadow: 0 6px 16px rgba(220, 38, 38, 0.5) !important;
  1283. transform: translateY(-2px) !important;
  1284. }
  1285. .btn-terminate-confirm:active {
  1286. transform: translateY(0) !important;
  1287. box-shadow: 0 3px 8px rgba(220, 38, 38, 0.4) !important;
  1288. }
  1289. .btn-terminate-confirm .v-icon {
  1290. color: white !important;
  1291. margin-right: 6px !important;
  1292. }
  1293. .btn-terminate-confirm:disabled {
  1294. background: #fca5a5 !important;
  1295. color: #9ca3af !important;
  1296. box-shadow: none !important;
  1297. transform: none !important;
  1298. }
  1299. .status--badges {
  1300. display: flex;
  1301. align-items: center;
  1302. gap: 8px;
  1303. flex-wrap: wrap;
  1304. }
  1305. .status--badges .v-chip {
  1306. font-weight: 500;
  1307. }
  1308. </style>