search.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. <template>
  2. <div>
  3. <div class="inner--headers">
  4. <h2>벤더사 검색</h2>
  5. <div class="bread--crumbs--wrap">
  6. <span>홈</span>
  7. <span>벤더사 검색</span>
  8. </div>
  9. </div>
  10. <!-- 검색 및 필터 영역 -->
  11. <div class="search--modules type2">
  12. <div class="search--inner">
  13. <div class="form--cont--filter">
  14. <v-select
  15. v-model="searchFilter.category"
  16. :items="categoryOptions"
  17. variant="outlined"
  18. class="custom-select"
  19. label="카테고리"
  20. clearable
  21. >
  22. </v-select>
  23. </div>
  24. <div class="form--cont--filter">
  25. <v-select
  26. v-model="searchFilter.region"
  27. :items="regionOptions"
  28. variant="outlined"
  29. class="custom-select"
  30. label="지역"
  31. clearable
  32. >
  33. </v-select>
  34. </div>
  35. <div class="form--cont--text">
  36. <v-text-field
  37. v-model="searchFilter.keyword"
  38. class="custom-input mini"
  39. style="width: 100%"
  40. placeholder="벤더사명을 입력하세요"
  41. @keyup.enter="handleSearch"
  42. ></v-text-field>
  43. </div>
  44. </div>
  45. <v-btn
  46. class="custom-btn btn-blue mini sch--btn"
  47. @click="handleSearch"
  48. :loading="loading"
  49. >
  50. <v-icon>mdi-magnify</v-icon>
  51. 검색
  52. </v-btn>
  53. </div>
  54. <!-- 파트너십 상태 탭 -->
  55. <div class="partnership--tabs">
  56. <v-tabs v-model="activeTab" class="custom-tabs">
  57. <v-tab value="new">신규 벤더사</v-tab>
  58. <v-tab value="current">현재 파트너십</v-tab>
  59. <v-tab value="terminated">해지된 파트너십</v-tab>
  60. </v-tabs>
  61. </div>
  62. <!-- 검색 결과 -->
  63. <div class="vendor--grid">
  64. <div class="vendors--list">
  65. <!-- 로딩 상태 -->
  66. <div v-if="loading" class="loading-wrap">
  67. <v-progress-circular
  68. indeterminate
  69. color="primary"
  70. size="64"
  71. ></v-progress-circular>
  72. <p>검색 중...</p>
  73. </div>
  74. <!-- 검색 결과 없음 -->
  75. <div v-else-if="vendors.length === 0" class="no-results">
  76. <div class="no-data">
  77. <v-icon size="64" color="grey-lighten-1">mdi-office-building-outline</v-icon>
  78. <h3>검색 결과가 없습니다</h3>
  79. <p>다른 키워드로 검색해보세요</p>
  80. </div>
  81. </div>
  82. <!-- 벤더사 카드 리스트 -->
  83. <div v-else class="vendor--cards">
  84. <div
  85. v-for="vendor in vendors"
  86. :key="vendor.SEQ"
  87. class="vendor--card"
  88. :class="{ 'partnership-exists': vendor.PARTNERSHIP_STATUS }"
  89. >
  90. <!-- 벤더사 로고 -->
  91. <div class="vendor--logo">
  92. <v-img
  93. v-if="vendor.LOGO"
  94. :src="vendor.LOGO"
  95. :alt="vendor.COMPANY_NAME"
  96. width="80"
  97. height="80"
  98. cover
  99. ></v-img>
  100. <div v-else class="no-logo">
  101. {{ vendor.COMPANY_NAME?.charAt(0) || "V" }}
  102. </div>
  103. </div>
  104. <!-- 벤더사 정보 -->
  105. <div class="vendor--info">
  106. <h3 class="vendor--name">{{ vendor.COMPANY_NAME }}</h3>
  107. <div class="vendor--meta">
  108. <div v-if="vendor.CATEGORY" class="meta--item">
  109. <v-icon size="16">mdi-tag-outline</v-icon>
  110. <span>{{ getCategoryText(vendor.CATEGORY) }}</span>
  111. </div>
  112. <div v-if="vendor.REGION" class="meta--item">
  113. <v-icon size="16">mdi-map-marker-outline</v-icon>
  114. <span>{{ vendor.REGION }}</span>
  115. </div>
  116. <div class="meta--item">
  117. <v-icon size="16">mdi-handshake-outline</v-icon>
  118. <span>{{ vendor.PARTNERSHIP_COUNT || 0 }}개 파트너십</span>
  119. </div>
  120. </div>
  121. <p v-if="vendor.DESCRIPTION" class="vendor--description">
  122. {{ vendor.DESCRIPTION }}
  123. </p>
  124. <!-- 파트너십 상태 -->
  125. <div v-if="vendor.PARTNERSHIP_STATUS" class="partnership--status">
  126. <v-chip
  127. :color="getPartnershipColor(vendor.PARTNERSHIP_STATUS)"
  128. size="small"
  129. variant="tonal"
  130. >
  131. {{ getPartnershipText(vendor.PARTNERSHIP_STATUS) }}
  132. </v-chip>
  133. </div>
  134. </div>
  135. <!-- 액션 버튼 -->
  136. <div class="vendor--actions">
  137. <!-- 신규 벤더사 - 승인요청 -->
  138. <v-btn
  139. v-if="!vendor.PARTNERSHIP_STATUS"
  140. color="primary"
  141. variant="flat"
  142. size="small"
  143. @click="requestPartnership(vendor)"
  144. :loading="processing"
  145. >
  146. <v-icon left size="16">mdi-handshake</v-icon>
  147. 승인요청
  148. </v-btn>
  149. <!-- 해지된 파트너십 - 재승인요청 -->
  150. <v-btn
  151. v-else-if="vendor.PARTNERSHIP_STATUS === 'TERMINATED'"
  152. color="success"
  153. variant="flat"
  154. size="small"
  155. @click="requestReapply(vendor)"
  156. :loading="processing"
  157. >
  158. <v-icon left size="16">mdi-refresh</v-icon>
  159. 재승인요청
  160. </v-btn>
  161. <!-- 진행중인 파트너십 -->
  162. <v-btn
  163. v-else
  164. variant="outlined"
  165. size="small"
  166. @click="viewPartnership(vendor)"
  167. >
  168. 파트너십 보기
  169. </v-btn>
  170. <!-- 상세보기 버튼 -->
  171. <v-btn variant="text" size="small" @click="viewVendorDetail(vendor.SEQ)">
  172. 상세보기
  173. </v-btn>
  174. </div>
  175. </div>
  176. </div>
  177. </div>
  178. <!-- 페이지네이션 -->
  179. <div v-if="pagination.totalPages > 1" class="pagination-wrap">
  180. <v-pagination
  181. v-model="currentPage"
  182. :length="pagination.totalPages"
  183. :total-visible="5"
  184. @update:model-value="handlePageChange"
  185. ></v-pagination>
  186. </div>
  187. </div>
  188. <!-- 승인요청 모달 -->
  189. <v-dialog v-model="requestModal.show" max-width="600px" persistent>
  190. <v-card>
  191. <v-card-title class="d-flex align-center">
  192. <v-icon class="mr-3" color="primary">mdi-handshake</v-icon>
  193. {{ requestModal.isReapply ? "재승인요청" : "파트너십 승인요청" }}
  194. </v-card-title>
  195. <v-card-text>
  196. <div class="request--content">
  197. <div class="vendor--summary">
  198. <h4>{{ requestModal.vendor?.COMPANY_NAME }}</h4>
  199. <p>
  200. {{ getCategoryText(requestModal.vendor?.CATEGORY) }} ·
  201. {{ requestModal.vendor?.REGION }}
  202. </p>
  203. </div>
  204. <v-divider class="my-4"></v-divider>
  205. <v-textarea
  206. v-model="requestModal.message"
  207. label="요청 메시지"
  208. placeholder="파트너십을 원하는 이유나 제안사항을 입력해주세요"
  209. rows="4"
  210. variant="outlined"
  211. class="mb-4"
  212. ></v-textarea>
  213. <div class="form-row">
  214. <v-text-field
  215. v-model="requestModal.commissionRate"
  216. label="희망 수수료율 (%)"
  217. type="number"
  218. variant="outlined"
  219. class="mr-2"
  220. :disabled="requestModal.isReapply"
  221. ></v-text-field>
  222. <v-text-field
  223. v-model="requestModal.specialConditions"
  224. label="특별 조건"
  225. variant="outlined"
  226. :disabled="requestModal.isReapply"
  227. ></v-text-field>
  228. </div>
  229. <div v-if="requestModal.isReapply" class="reapply--info">
  230. <v-alert type="info" variant="tonal" class="mb-3">
  231. 재승인요청 시 이전 계약 조건이 자동으로 적용됩니다.
  232. </v-alert>
  233. </div>
  234. </div>
  235. </v-card-text>
  236. <v-card-actions>
  237. <v-spacer></v-spacer>
  238. <v-btn variant="text" @click="closeRequestModal">취소</v-btn>
  239. <v-btn
  240. color="primary"
  241. variant="flat"
  242. @click="submitRequest"
  243. :loading="processing"
  244. :disabled="!requestModal.message.trim()"
  245. >
  246. {{ requestModal.isReapply ? "재승인요청" : "승인요청" }}
  247. </v-btn>
  248. </v-card-actions>
  249. </v-card>
  250. </v-dialog>
  251. </div>
  252. </template>
  253. <script setup>
  254. import { ref, computed, onMounted } from "vue";
  255. definePageMeta({
  256. layout: "default",
  257. });
  258. const { $toast } = useNuxtApp();
  259. const authStore = useAuthStore();
  260. // 반응형 데이터
  261. const loading = ref(false);
  262. const processing = ref(false);
  263. const vendors = ref([]);
  264. const currentPage = ref(1);
  265. const activeTab = ref("new");
  266. const searchFilter = ref({
  267. keyword: "",
  268. category: "",
  269. region: "",
  270. });
  271. const pagination = ref({
  272. currentPage: 1,
  273. totalPages: 1,
  274. totalCount: 0,
  275. pageSize: 12,
  276. });
  277. const requestModal = ref({
  278. show: false,
  279. vendor: null,
  280. message: "",
  281. commissionRate: "",
  282. specialConditions: "",
  283. isReapply: false,
  284. });
  285. // 옵션 데이터
  286. const categoryOptions = [
  287. { title: "패션·뷰티", value: "FASHION_BEAUTY" },
  288. { title: "식품·건강", value: "FOOD_HEALTH" },
  289. { title: "라이프스타일", value: "LIFESTYLE" },
  290. { title: "테크·가전", value: "TECH_ELECTRONICS" },
  291. { title: "스포츠·레저", value: "SPORTS_LEISURE" },
  292. { title: "문화·엔터테인먼트", value: "CULTURE_ENTERTAINMENT" },
  293. ];
  294. const regionOptions = [
  295. { title: "서울", value: "SEOUL" },
  296. { title: "경기", value: "GYEONGGI" },
  297. { title: "인천", value: "INCHEON" },
  298. { title: "부산", value: "BUSAN" },
  299. { title: "대구", value: "DAEGU" },
  300. { title: "대전", value: "DAEJEON" },
  301. { title: "광주", value: "GWANGJU" },
  302. { title: "울산", value: "ULSAN" },
  303. { title: "기타", value: "OTHER" },
  304. ];
  305. // 현재 사용자 SEQ
  306. const currentUserSeq = computed(() => authStore.getUserSeq);
  307. // 필터링된 벤더사 목록
  308. const filteredVendors = computed(() => {
  309. if (activeTab.value === "new") {
  310. return vendors.value.filter((v) => !v.PARTNERSHIP_STATUS);
  311. } else if (activeTab.value === "current") {
  312. return vendors.value.filter((v) =>
  313. ["PENDING", "APPROVED"].includes(v.PARTNERSHIP_STATUS)
  314. );
  315. } else if (activeTab.value === "terminated") {
  316. return vendors.value.filter((v) => v.PARTNERSHIP_STATUS === "TERMINATED");
  317. }
  318. return vendors.value;
  319. });
  320. // 메서드들
  321. const handleSearch = async () => {
  322. loading.value = true;
  323. currentPage.value = 1;
  324. try {
  325. const params = {
  326. keyword: searchFilter.value.keyword,
  327. category: searchFilter.value.category,
  328. region: searchFilter.value.region,
  329. sortBy: "latest",
  330. page: currentPage.value,
  331. size: pagination.value.pageSize,
  332. influencerSeq: currentUserSeq.value,
  333. };
  334. useAxios()
  335. .post("/api/vendor-influencer/search-vendors", params)
  336. .then((res) => {
  337. if (res.data.success) {
  338. vendors.value = res.data.data.items || [];
  339. pagination.value = res.data.data.pagination || {};
  340. } else {
  341. $toast.error(res.data.message || "검색에 실패했습니다.");
  342. }
  343. })
  344. .catch((err) => {
  345. $toast.error("검색 중 오류가 발생했습니다.");
  346. console.error("Search error:", err);
  347. })
  348. .finally(() => {
  349. loading.value = false;
  350. });
  351. } catch (err) {
  352. $toast.error("검색 중 오류가 발생했습니다.");
  353. loading.value = false;
  354. }
  355. };
  356. const handlePageChange = (page) => {
  357. currentPage.value = page;
  358. handleSearch();
  359. };
  360. // 파트너십 요청
  361. const requestPartnership = (vendor) => {
  362. requestModal.value = {
  363. show: true,
  364. vendor: vendor,
  365. message: "",
  366. commissionRate: "",
  367. specialConditions: "",
  368. isReapply: false,
  369. };
  370. };
  371. // 재승인요청
  372. const requestReapply = (vendor) => {
  373. requestModal.value = {
  374. show: true,
  375. vendor: vendor,
  376. message: "",
  377. commissionRate: vendor.COMMISSION_RATE || "",
  378. specialConditions: vendor.SPECIAL_CONDITIONS || "",
  379. isReapply: true,
  380. };
  381. };
  382. const submitRequest = async () => {
  383. try {
  384. processing.value = true;
  385. const endpoint = requestModal.value.isReapply
  386. ? "/api/vendor-influencer/reapply-request"
  387. : "/api/vendor-influencer/create-request";
  388. const params = {
  389. vendorSeq: requestModal.value.vendor.SEQ,
  390. influencerSeq: currentUserSeq.value,
  391. requestMessage: requestModal.value.message,
  392. requestedBy: currentUserSeq.value,
  393. ...(requestModal.value.isReapply
  394. ? {}
  395. : {
  396. commissionRate: requestModal.value.commissionRate,
  397. specialConditions: requestModal.value.specialConditions,
  398. }),
  399. };
  400. useAxios()
  401. .post(endpoint, params)
  402. .then((res) => {
  403. if (res.data.success) {
  404. $toast.success(res.data.message);
  405. closeRequestModal();
  406. handleSearch(); // 리스트 새로고침
  407. } else {
  408. $toast.error(res.data.message || "요청 처리에 실패했습니다.");
  409. }
  410. })
  411. .catch((err) => {
  412. $toast.error("요청 처리 중 오류가 발생했습니다.");
  413. console.error("Request error:", err);
  414. })
  415. .finally(() => {
  416. processing.value = false;
  417. });
  418. } catch (err) {
  419. $toast.error("요청 처리 중 오류가 발생했습니다.");
  420. processing.value = false;
  421. }
  422. };
  423. const closeRequestModal = () => {
  424. requestModal.value = {
  425. show: false,
  426. vendor: null,
  427. message: "",
  428. commissionRate: "",
  429. specialConditions: "",
  430. isReapply: false,
  431. };
  432. };
  433. const viewPartnership = (vendor) => {
  434. navigateTo(`/view/influencer/partnerships`);
  435. };
  436. const viewVendorDetail = (vendorSeq) => {
  437. navigateTo(`/view/vendor/${vendorSeq}`);
  438. };
  439. // 유틸리티 함수들
  440. const getCategoryText = (category) => {
  441. const categoryMap = {
  442. FASHION_BEAUTY: "패션·뷰티",
  443. FOOD_HEALTH: "식품·건강",
  444. LIFESTYLE: "라이프스타일",
  445. TECH_ELECTRONICS: "테크·가전",
  446. SPORTS_LEISURE: "스포츠·레저",
  447. CULTURE_ENTERTAINMENT: "문화·엔터테인먼트",
  448. };
  449. return categoryMap[category] || category || "기타";
  450. };
  451. const getPartnershipColor = (status) => {
  452. const colorMap = {
  453. PENDING: "warning",
  454. APPROVED: "success",
  455. REJECTED: "error",
  456. TERMINATED: "grey",
  457. CANCELLED: "grey",
  458. };
  459. return colorMap[status] || "grey";
  460. };
  461. const getPartnershipText = (status) => {
  462. const textMap = {
  463. PENDING: "승인 대기중",
  464. APPROVED: "파트너십 진행중",
  465. REJECTED: "승인 거부",
  466. TERMINATED: "파트너십 해지됨",
  467. CANCELLED: "요청 취소됨",
  468. };
  469. return textMap[status] || status || "알 수 없음";
  470. };
  471. // 라이프사이클
  472. onMounted(() => {
  473. handleSearch();
  474. });
  475. </script>
  476. <style scoped>
  477. .partnership--tabs {
  478. margin: 24px 0;
  479. }
  480. .vendor--grid {
  481. margin-top: 24px;
  482. }
  483. .vendor--cards {
  484. display: grid;
  485. grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
  486. gap: 24px;
  487. }
  488. .vendor--card {
  489. background: white;
  490. border-radius: 12px;
  491. padding: 24px;
  492. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  493. transition: transform 0.2s, box-shadow 0.2s;
  494. display: flex;
  495. gap: 20px;
  496. }
  497. .vendor--card:hover {
  498. transform: translateY(-4px);
  499. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  500. }
  501. .vendor--card.partnership-exists {
  502. border-left: 4px solid #4caf50;
  503. }
  504. .vendor--logo {
  505. width: 80px;
  506. height: 80px;
  507. border-radius: 8px;
  508. overflow: hidden;
  509. flex-shrink: 0;
  510. background: #f5f5f5;
  511. display: flex;
  512. align-items: center;
  513. justify-content: center;
  514. }
  515. .no-logo {
  516. font-size: 32px;
  517. font-weight: bold;
  518. color: #666;
  519. }
  520. .vendor--info {
  521. flex: 1;
  522. }
  523. .vendor--name {
  524. margin: 0 0 12px 0;
  525. font-size: 18px;
  526. font-weight: 600;
  527. color: #333;
  528. }
  529. .vendor--meta {
  530. display: flex;
  531. flex-wrap: wrap;
  532. gap: 12px;
  533. margin-bottom: 12px;
  534. }
  535. .meta--item {
  536. display: flex;
  537. align-items: center;
  538. gap: 4px;
  539. color: #666;
  540. font-size: 14px;
  541. }
  542. .vendor--description {
  543. font-size: 14px;
  544. line-height: 1.5;
  545. color: #666;
  546. margin: 0 0 16px 0;
  547. display: -webkit-box;
  548. -webkit-line-clamp: 2;
  549. -webkit-box-orient: vertical;
  550. overflow: hidden;
  551. }
  552. .partnership--status {
  553. margin-bottom: 16px;
  554. }
  555. .vendor--actions {
  556. display: flex;
  557. flex-direction: column;
  558. gap: 8px;
  559. flex-shrink: 0;
  560. }
  561. .request--content {
  562. padding: 8px 0;
  563. }
  564. .vendor--summary h4 {
  565. margin: 0 0 4px 0;
  566. font-size: 16px;
  567. font-weight: 600;
  568. }
  569. .vendor--summary p {
  570. margin: 0;
  571. color: #666;
  572. font-size: 14px;
  573. }
  574. .form-row {
  575. display: flex;
  576. gap: 12px;
  577. }
  578. .reapply--info {
  579. margin-top: 16px;
  580. }
  581. .loading-wrap {
  582. display: flex;
  583. flex-direction: column;
  584. align-items: center;
  585. justify-content: center;
  586. padding: 60px 20px;
  587. }
  588. .loading-wrap p {
  589. margin-top: 16px;
  590. color: #666;
  591. }
  592. .no-results {
  593. display: flex;
  594. justify-content: center;
  595. padding: 60px 20px;
  596. }
  597. .no-data {
  598. text-align: center;
  599. }
  600. .no-data h3 {
  601. margin: 16px 0 8px;
  602. color: #666;
  603. }
  604. .no-data p {
  605. color: #999;
  606. }
  607. .pagination-wrap {
  608. display: flex;
  609. justify-content: center;
  610. margin-top: 32px;
  611. }
  612. @media (max-width: 768px) {
  613. .vendor--cards {
  614. grid-template-columns: 1fr;
  615. }
  616. .vendor--card {
  617. flex-direction: column;
  618. text-align: center;
  619. }
  620. .vendor--actions {
  621. flex-direction: row;
  622. justify-content: center;
  623. }
  624. .form-row {
  625. flex-direction: column;
  626. }
  627. }
  628. </style>