index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. <template>
  2. <div class="admin--sales-list">
  3. <!-- 검색 영역 -->
  4. <div class="admin--search-box admin--search-box-large">
  5. <div class="admin--search-filters">
  6. <div class="admin--filter-row">
  7. <label class="admin--filter-label">전시장</label>
  8. <select v-model="filters.showroom" class="admin--form-select">
  9. <option value="">전체</option>
  10. <option v-for="showroom in showrooms" :key="showroom.id" :value="showroom.id">
  11. {{ showroom.name }}
  12. </option>
  13. </select>
  14. <label class="admin--filter-label">팀</label>
  15. <select v-model="filters.team" class="admin--form-select">
  16. <option value="">전체</option>
  17. <option v-for="team in teams" :key="team.id" :value="team.id">
  18. {{ team.name }}
  19. </option>
  20. </select>
  21. <label class="admin--filter-label">직책</label>
  22. <select v-model="filters.position" class="admin--form-select">
  23. <option value="">전체</option>
  24. <option value="10">팀장</option>
  25. <option value="15">마스터</option>
  26. <option value="20">차장</option>
  27. <option value="30">과장</option>
  28. <option value="40">대리</option>
  29. <option value="60">사원</option>
  30. </select>
  31. </div>
  32. <div class="admin--filter-row">
  33. <label class="admin--filter-label">검색어</label>
  34. <input
  35. v-model="filters.keyword"
  36. type="text"
  37. class="admin--form-input"
  38. placeholder="이름으로 검색"
  39. @keyup.enter="handleSearch"
  40. />
  41. <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">
  42. 검색
  43. </button>
  44. <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
  45. 초기화
  46. </button>
  47. </div>
  48. </div>
  49. <div class="admin--search-actions">
  50. <button class="admin--btn-small admin--btn-small-excel" @click="handleExcelDownload">
  51. 엑셀 다운로드
  52. </button>
  53. <button class="admin--btn-small admin--btn-small-secondary" @click="handleA2Print">
  54. A2 출력하기
  55. </button>
  56. <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">
  57. + 사원 등록
  58. </button>
  59. </div>
  60. </div>
  61. <!-- 테이블 -->
  62. <div class="admin--table-wrapper">
  63. <table class="admin--table admin--table-sales">
  64. <thead>
  65. <tr>
  66. <th>NO</th>
  67. <th>사진</th>
  68. <th>전시장</th>
  69. <th>팀</th>
  70. <th>이름</th>
  71. <th>직책</th>
  72. <th>대표번호</th>
  73. <th>핸드폰</th>
  74. <th>이메일</th>
  75. <th>관리</th>
  76. </tr>
  77. </thead>
  78. <tbody>
  79. <tr v-if="!salesList || salesList.length === 0">
  80. <td colspan="10" class="admin--table-empty">등록된 영업사원이 없습니다.</td>
  81. </tr>
  82. <tr v-else v-for="(sales, index) in salesList" :key="sales.id">
  83. <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
  84. <td>
  85. <div class="admin--table-photo">
  86. <img v-if="sales.photo_url" :src="getImageUrl(sales.photo_url)" :alt="sales.name" />
  87. <div v-else class="admin--table-photo-empty">사진없음</div>
  88. </div>
  89. </td>
  90. <td>{{ getShowroomName(sales.showroom) }}</td>
  91. <td>{{ getTeamName(sales.team) }}</td>
  92. <td class="admin--table-title">{{ sales.name }}</td>
  93. <td>{{ getPositionName(sales.position) }}</td>
  94. <td>{{ sales.main_phone }}</td>
  95. <td>{{ sales.direct_phone }}</td>
  96. <td>{{ sales.email }}</td>
  97. <td>
  98. <div class="admin--table-actions admin--table-actions-col">
  99. <button
  100. class="admin--btn-small admin--btn-small-primary"
  101. @click="goToEdit(sales.id)"
  102. >
  103. 수정
  104. </button>
  105. <button
  106. class="admin--btn-small admin--btn-small-danger"
  107. @click="handleDelete(sales.id)"
  108. >
  109. 삭제
  110. </button>
  111. <button
  112. class="admin--btn-small admin--btn-small-secondary"
  113. @click="handlePrint(sales.id)"
  114. >
  115. 프린트
  116. </button>
  117. <button
  118. class="admin--btn-small admin--btn-small-excel"
  119. @click="handleExcelExport(sales.id)"
  120. >
  121. 엑셀출력
  122. </button>
  123. </div>
  124. </td>
  125. </tr>
  126. </tbody>
  127. </table>
  128. </div>
  129. <!-- 페이지네이션 -->
  130. <div v-if="totalPages > 1" class="admin--pagination">
  131. <button
  132. class="admin--pagination-btn"
  133. :disabled="currentPage === 1"
  134. @click="changePage(currentPage - 1)"
  135. >
  136. 이전
  137. </button>
  138. <button
  139. v-for="page in visiblePages"
  140. :key="page"
  141. class="admin--pagination-btn"
  142. :class="{ 'is-active': page === currentPage }"
  143. @click="changePage(page)"
  144. >
  145. {{ page }}
  146. </button>
  147. <button
  148. class="admin--pagination-btn"
  149. :disabled="currentPage === totalPages"
  150. @click="changePage(currentPage + 1)"
  151. >
  152. 다음
  153. </button>
  154. </div>
  155. </div>
  156. </template>
  157. <script setup>
  158. import { ref, computed, onMounted } from "vue";
  159. import { useRouter } from "vue-router";
  160. definePageMeta({
  161. layout: "admin",
  162. middleware: ["auth"],
  163. });
  164. const router = useRouter();
  165. const { get, del } = useApi();
  166. const { getImageUrl } = useImage();
  167. const salesList = ref([]);
  168. const showrooms = ref([]);
  169. // 영업팀 수동 리스트 (0번째는 마스터팀)
  170. const teams = ref([
  171. { id: 0, name: '마스터팀' },
  172. { id: 1, name: '1팀' },
  173. { id: 2, name: '2팀' },
  174. { id: 3, name: '3팀' },
  175. { id: 4, name: '4팀' },
  176. { id: 5, name: '5팀' },
  177. { id: 6, name: '6팀' },
  178. { id: 7, name: '7팀' },
  179. { id: 8, name: '8팀' },
  180. { id: 9, name: '9팀' },
  181. { id: 10, name: '10팀' }
  182. ]);
  183. const filters = ref({
  184. showroom: "",
  185. team: "",
  186. position: "",
  187. keyword: "",
  188. });
  189. const currentPage = ref(1);
  190. const perPage = ref(10);
  191. const totalCount = ref(0);
  192. const totalPages = ref(0);
  193. // 보이는 페이지 번호 계산
  194. const visiblePages = computed(() => {
  195. const pages = [];
  196. const maxVisible = 5;
  197. let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
  198. let end = Math.min(totalPages.value, start + maxVisible - 1);
  199. if (end - start < maxVisible - 1) {
  200. start = Math.max(1, end - maxVisible + 1);
  201. }
  202. for (let i = start; i <= end; i++) {
  203. pages.push(i);
  204. }
  205. return pages;
  206. });
  207. // 필터 데이터 로드
  208. const loadFilters = async () => {
  209. // 전시장 리스트 (지점 목록)
  210. const { data: branchData, error: branchError } = await get("/branch/list", {
  211. per_page: 1000,
  212. });
  213. console.log("[SalesList] 전시장(지점) API 응답:", { data: branchData, error: branchError });
  214. if (branchData?.success && branchData?.data) {
  215. showrooms.value = branchData.data.items || [];
  216. console.log("[SalesList] 전시장(지점) 로드 성공");
  217. }
  218. };
  219. // 데이터 로드
  220. const loadSales = async () => {
  221. const params = {
  222. page: currentPage.value,
  223. per_page: perPage.value,
  224. ...filters.value,
  225. };
  226. const { data, error } = await get("/staff/sales", params);
  227. console.log("[SalesList] 영업사원 목록 API 응답:", { data, error });
  228. if (data?.success && data?.data) {
  229. salesList.value = data.data.items || [];
  230. totalCount.value = data.data.total || 0;
  231. totalPages.value = Math.ceil(totalCount.value / perPage.value);
  232. console.log("[SalesList] 영업사원 목록 로드 성공");
  233. }
  234. };
  235. // 검색
  236. const handleSearch = () => {
  237. currentPage.value = 1;
  238. loadSales();
  239. };
  240. // 초기화
  241. const handleReset = () => {
  242. filters.value = {
  243. showroom: "",
  244. team: "",
  245. position: "",
  246. keyword: "",
  247. };
  248. currentPage.value = 1;
  249. loadSales();
  250. };
  251. // 전시장 이름 가져오기
  252. const getShowroomName = (showroomId) => {
  253. const showroom = showrooms.value.find(s => s.id === showroomId);
  254. return showroom ? showroom.name : '-';
  255. };
  256. // 팀 이름 가져오기
  257. const getTeamName = (teamId) => {
  258. if (teamId === 0 || teamId === '0') {
  259. return '마스터팀';
  260. }
  261. return teamId ? `${teamId}팀` : '-';
  262. };
  263. // 직책 이름 가져오기
  264. const getPositionName = (positionCode) => {
  265. const positions = {
  266. 10: '팀장',
  267. 15: '마스터',
  268. 20: '차장',
  269. 30: '과장',
  270. 40: '대리',
  271. 60: '사원'
  272. };
  273. return positions[positionCode] || '-';
  274. };
  275. // 페이지 변경
  276. const changePage = (page) => {
  277. if (page < 1 || page > totalPages.value) return;
  278. currentPage.value = page;
  279. loadSales();
  280. };
  281. // 엑셀 다운로드 (전체)
  282. const handleExcelDownload = async () => {
  283. // 전체 데이터 가져오기 (페이지네이션 없이)
  284. const params = {
  285. ...filters.value,
  286. per_page: 10000 // 충분히 큰 값으로 전체 가져오기
  287. };
  288. const { data } = await get("/staff/sales", params);
  289. if (!data?.success || !data?.data) {
  290. alert('데이터를 가져올 수 없습니다.');
  291. return;
  292. }
  293. const allStaff = data.data.items || [];
  294. if (allStaff.length === 0) {
  295. alert('다운로드할 데이터가 없습니다.');
  296. return;
  297. }
  298. // HTML 테이블 생성
  299. let tableRows = '';
  300. allStaff.forEach((staff, index) => {
  301. const showroomName = getShowroomName(staff.showroom);
  302. const teamName = getTeamName(staff.team);
  303. const positionName = getPositionName(staff.position);
  304. const photoUrl = staff.photo_url ? getImageUrl(staff.photo_url) : '';
  305. tableRows += `
  306. <tr style="height: 110px;">
  307. <td style="text-align: center; vertical-align: middle;">${index + 1}</td>
  308. <td style="text-align: center; padding: 5px; vertical-align: middle;">
  309. ${photoUrl ? `<img src="${photoUrl}" height="100" style="display: block; margin: 0 auto; max-width: 100%;" />` : '사진없음'}
  310. </td>
  311. <td style="vertical-align: middle;">${showroomName}</td>
  312. <td style="vertical-align: middle;">${teamName}</td>
  313. <td style="vertical-align: middle;">${staff.name}</td>
  314. <td style="vertical-align: middle;">${positionName}</td>
  315. <td style="vertical-align: middle;">${staff.main_phone || ''}</td>
  316. <td style="vertical-align: middle;">${staff.direct_phone || ''}</td>
  317. <td style="vertical-align: middle;">${staff.email || ''}</td>
  318. </tr>
  319. `;
  320. });
  321. const html = `
  322. <html xmlns:x="urn:schemas-microsoft-com:office:excel">
  323. <head>
  324. <meta charset="UTF-8">
  325. <style>
  326. body {
  327. font-family: "Malgun Gothic", "맑은 고딕", Arial, sans-serif;
  328. }
  329. table {
  330. border-collapse: collapse;
  331. width: 100%;
  332. }
  333. th, td {
  334. border: 1px solid #ddd;
  335. padding: 10px;
  336. text-align: left;
  337. }
  338. th {
  339. background-color: #f5f5f5;
  340. font-weight: bold;
  341. text-align: center;
  342. }
  343. .title {
  344. font-size: 18pt;
  345. font-weight: bold;
  346. margin-bottom: 20px;
  347. text-align: center;
  348. }
  349. </style>
  350. </head>
  351. <body>
  352. <div class="title">영업사원 목록</div>
  353. <table>
  354. <thead>
  355. <tr>
  356. <th>NO</th>
  357. <th>사진</th>
  358. <th>전시장</th>
  359. <th>팀</th>
  360. <th>이름</th>
  361. <th>직책</th>
  362. <th>대표번호</th>
  363. <th>핸드폰</th>
  364. <th>이메일</th>
  365. </tr>
  366. </thead>
  367. <tbody>
  368. ${tableRows}
  369. </tbody>
  370. </table>
  371. </body>
  372. </html>`;
  373. // Blob 생성 및 다운로드
  374. const blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel;charset=utf-8;' });
  375. const link = document.createElement('a');
  376. const url = URL.createObjectURL(blob);
  377. link.setAttribute('href', url);
  378. link.setAttribute('download', `sales_staff_list_${new Date().toISOString().split('T')[0]}.xls`);
  379. link.style.visibility = 'hidden';
  380. document.body.appendChild(link);
  381. link.click();
  382. document.body.removeChild(link);
  383. };
  384. // A2 출력
  385. const handleA2Print = () => {
  386. window.open('/admin/staff/sales/print-a2', '_blank');
  387. };
  388. // 개별 프린트
  389. const handlePrint = (id) => {
  390. window.open(`/admin/staff/sales/print/${id}`, "_blank");
  391. };
  392. // 개별 엑셀 출력
  393. const handleExcelExport = async (id) => {
  394. // 해당 사원 데이터 가져오기
  395. const { data } = await get(`/staff/sales/${id}`);
  396. if (!data?.success || !data?.data) {
  397. alert('데이터를 가져올 수 없습니다.');
  398. return;
  399. }
  400. const staff = data.data;
  401. // 전시장 이름
  402. const showroomName = getShowroomName(staff.showroom);
  403. // 팀 이름
  404. const teamName = getTeamName(staff.team);
  405. // 직책 이름
  406. const positionName = getPositionName(staff.position);
  407. // 사진 URL
  408. const photoUrl = staff.photo_url ? getImageUrl(staff.photo_url) : '';
  409. // HTML 생성 (엑셀 호환 테이블 레이아웃)
  410. const html = `
  411. <html xmlns:x="urn:schemas-microsoft-com:office:excel">
  412. <head>
  413. <meta charset="UTF-8">
  414. <style>
  415. body {
  416. font-family: "Malgun Gothic", "맑은 고딕", Arial, sans-serif;
  417. }
  418. table {
  419. border-collapse: collapse;
  420. width: 100%;
  421. }
  422. .header-table {
  423. width: 100%;
  424. margin-bottom: 40px;
  425. border-bottom: 2px solid #000;
  426. padding-bottom: 10px;
  427. }
  428. .header-table td {
  429. border: none;
  430. padding: 10px;
  431. }
  432. .header-title {
  433. font-size: 20pt;
  434. font-weight: bold;
  435. text-align: left;
  436. }
  437. .header-logo {
  438. text-align: right;
  439. font-size: 10pt;
  440. color: #666;
  441. }
  442. .content-table {
  443. width: 100%;
  444. border: 1px solid #ddd;
  445. margin-top: 20px;
  446. }
  447. .content-table td {
  448. border: 1px solid #ddd;
  449. vertical-align: top;
  450. padding: 10px;
  451. }
  452. .info-wrapper {
  453. padding-top: 0px;
  454. }
  455. .photo-cell {
  456. width: 200px;
  457. text-align: center;
  458. vertical-align: top;
  459. }
  460. .photo-cell img {
  461. width: 200px;
  462. height: auto;
  463. display: block;
  464. }
  465. .photo-placeholder {
  466. width: 200px;
  467. height: 300px;
  468. border: 1px solid #ddd;
  469. background-color: #f9f9f9;
  470. display: inline-block;
  471. line-height: 300px;
  472. text-align: center;
  473. color: #999;
  474. }
  475. .info-table {
  476. width: 100%;
  477. border-collapse: collapse;
  478. border: none;
  479. }
  480. .info-table tr {
  481. border: none;
  482. }
  483. .info-table td {
  484. padding: 10px;
  485. border: 1px solid #ddd;
  486. }
  487. .info-label {
  488. width: 100px;
  489. font-weight: bold;
  490. color: #333;
  491. font-size: 13pt;
  492. background-color: #f5f5f5;
  493. }
  494. .info-value {
  495. color: #555;
  496. font-size: 13pt;
  497. }
  498. .info-value-name {
  499. color: #000;
  500. font-size: 16pt;
  501. font-weight: bold;
  502. }
  503. </style>
  504. </head>
  505. <body>
  506. <table class="content-table" style="table-layout: fixed;">
  507. <colgroup>
  508. <col style="width: 200px;">
  509. <col style="width: 100px;">
  510. <col style="width: auto;">
  511. </colgroup>
  512. <tr>
  513. <td class="photo-cell" rowspan="6" style="padding: 0;">
  514. ${photoUrl ? `<img src="${photoUrl}" width="200" style="display: block;" />` : '<div class="photo-placeholder">사진없음</div>'}
  515. </td>
  516. <td class="info-label" style="height: 44.5px;">전시장</td>
  517. <td class="info-value">${showroomName}</td>
  518. </tr>
  519. <tr>
  520. <td class="info-label" style="height: 44.5px;">팀</td>
  521. <td class="info-value">${teamName}</td>
  522. </tr>
  523. <tr>
  524. <td class="info-label" style="height: 44.5px;">이름</td>
  525. <td class="info-value-name">${staff.name}</td>
  526. </tr>
  527. <tr>
  528. <td class="info-label" style="height: 44.5px;">직책</td>
  529. <td class="info-value">${positionName}</td>
  530. </tr>
  531. <tr>
  532. <td class="info-label" style="height: 44.5px;">핸드폰</td>
  533. <td class="info-value">${staff.direct_phone || ''}</td>
  534. </tr>
  535. <tr>
  536. <td class="info-label" style="height: 44.5px;">이메일</td>
  537. <td class="info-value">${staff.email || ''}</td>
  538. </tr>
  539. </table>
  540. </body>
  541. </html>`;
  542. // Blob 생성 및 다운로드
  543. const blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel;charset=utf-8;' });
  544. const link = document.createElement('a');
  545. const url = URL.createObjectURL(blob);
  546. link.setAttribute('href', url);
  547. link.setAttribute('download', `sales_staff_${staff.name}_${new Date().toISOString().split('T')[0]}.xls`);
  548. link.style.visibility = 'hidden';
  549. document.body.appendChild(link);
  550. link.click();
  551. document.body.removeChild(link);
  552. };
  553. // 등록 페이지로 이동
  554. const goToCreate = () => {
  555. router.push("/admin/staff/sales/create");
  556. };
  557. // 수정 페이지로 이동
  558. const goToEdit = (id) => {
  559. router.push(`/admin/staff/sales/edit/${id}`);
  560. };
  561. // 삭제
  562. const handleDelete = async (id) => {
  563. if (!confirm("정말 삭제하시겠습니까?")) return;
  564. const { error } = await del(`/staff/sales/${id}`);
  565. if (error) {
  566. alert("삭제에 실패했습니다.");
  567. } else {
  568. alert("삭제되었습니다.");
  569. loadSales();
  570. }
  571. };
  572. onMounted(() => {
  573. loadFilters();
  574. loadSales();
  575. });
  576. </script>
  577. <style scoped>
  578. /* 필터 영역 input/select 테두리 스타일 */
  579. .admin--filter-row .admin--form-input,
  580. .admin--filter-row .admin--form-select {
  581. border: 1px solid var(--admin-border-color);
  582. border-radius: 4px;
  583. height: 33px;
  584. padding: 6px 14px;
  585. font-size: 13px;
  586. }
  587. /* 버튼 영역 간격 조정 */
  588. .admin--search-actions {
  589. display: flex;
  590. gap: 12px;
  591. flex-shrink: 0;
  592. white-space: nowrap;
  593. }
  594. /* 상단 버튼들 세로 크기 */
  595. .admin--search-actions .admin--btn-small {
  596. height: 78px;
  597. display: flex;
  598. align-items: center;
  599. justify-content: center;
  600. }
  601. </style>