influencer-requests.vue 38 KB

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