diff --git a/app/components/content/sep/list.vue b/app/components/content/sep/list.vue index 529725e7..2fc1a7b4 100644 --- a/app/components/content/sep/list.vue +++ b/app/components/content/sep/list.vue @@ -26,7 +26,7 @@ import type { VclaimSepData } from '~/models/vclaim' // Libraries import { getFormatDateId } from '~/lib/date' -import { downloadCsv } from '~/lib/download' +import { downloadCsv, downloadXls } from '~/lib/download' // Constants import { serviceTypes } from '~/lib/constants.vclaim' @@ -184,9 +184,20 @@ function exportCsv() { downloadCsv(headers, data.value, filename) } -function exportExcel() { - console.log('Ekspor Excel dipilih') - // tambahkan logic untuk generate Excel +async function exportExcel() { + if (!data.value || data.value.length === 0) { + toast({ title: 'Kosong', description: 'Tidak ada data untuk diekspor', variant: 'destructive' }) + return + } + + try { + const headers = Object.keys(data.value[0] || {}) + const filename = `file-sep-${getFormatDateId(today)}.xlsx` + await downloadXls(headers, data.value, filename, 'SEP Data') + } catch (err: any) { + console.error('exportExcel error', err) + toast({ title: 'Gagal', description: err?.message || 'Gagal mengekspor data ke Excel', variant: 'destructive' }) + } } async function handleRemove() { diff --git a/app/lib/download.ts b/app/lib/download.ts index 03757d75..905f9fb3 100644 --- a/app/lib/download.ts +++ b/app/lib/download.ts @@ -6,16 +6,14 @@ * @param filename - optional file name to use for downloaded file * @param delimiter - csv delimiter (default is comma) * @param addBOM - add UTF-8 BOM to the file to make Excel detect UTF-8 correctly + * Usage examples: + * 1) With headers and array of objects + * downloadCsv(['name', 'age'], [{name: 'Alice', age: 25}, {name: 'Bob', age: 30}], 'people.csv'); + * 2) Without headers (automatically uses object keys) + * downloadCsv(null, [{name: 'Alice', age: 25}, {name: 'Bob', age: 30}], 'people.csv'); + * 3) With array-of-arrays + * downloadCsv(['col1', 'col2'], [['a', 'b'], ['c', 'd']], 'matrix.csv'); */ -/* - Usage examples: - // 1) With headers and array of objects - // downloadCsv(['name', 'age'], [{name: 'Alice', age: 25}, {name: 'Bob', age: 30}], 'people.csv'); - // 2) Without headers (automatically uses object keys) - // downloadCsv(null, [{name: 'Alice', age: 25}, {name: 'Bob', age: 30}], 'people.csv'); - // 3) With array-of-arrays - // downloadCsv(['col1', 'col2'], [['a', 'b'], ['c', 'd']], 'matrix.csv'); - */ export function downloadCsv( headers: string[] | null, data: Array | any[]>, @@ -79,3 +77,74 @@ export function downloadCsv( link.click() document.body.removeChild(link) } + +/** + * Download data as XLS (Excel) file using xlsx library. + * + * @param headers - Array of header names. If omitted and data is array of objects, keys will be taken from first object. + * @param data - Array of rows. Each row can be either an object (key -> value) or an array of values. + * @param filename - optional file name to use for downloaded file (default: 'data.xlsx') + * @param sheetName - optional sheet name in workbook (default: 'Sheet1') + * Usage examples: + * 1) With headers and array of objects + * await downloadXls(['name', 'age'], [{name: 'Alice', age: 25}, {name: 'Bob', age: 30}], 'people.xlsx'); + * 2) Without headers (automatically uses object keys) + * await downloadXls(null, [{name: 'Alice', age: 25}, {name: 'Bob', age: 30}], 'people.xlsx'); + * 3) With custom sheet name + * await downloadXls(['col1', 'col2'], [['a', 'b'], ['c', 'd']], 'matrix.xlsx', 'MyData'); + */ +export async function downloadXls( + headers: string[] | null, + data: Array | any[]>, + filename = 'data.xlsx', + sheetName = 'Sheet1', +) { + // Dynamically import xlsx to avoid server-side issues + const { utils, write } = await import('xlsx') + const { saveAs } = await import('file-saver') + + if (!Array.isArray(data) || data.length === 0) { + // Create empty sheet with headers only + const ws = utils.aoa_to_sheet(headers ? [headers] : [[]]) + const wb = utils.book_new() + utils.book_append_sheet(wb, ws, sheetName) + const wbout = write(wb, { bookType: 'xlsx', type: 'array' }) + saveAs(new Blob([wbout], { type: 'application/octet-stream' }), filename) + return + } + + // if headers not provided and rows are objects, take keys from first object + let _headers: string[] | null = headers + if (!_headers) { + const firstRow = data[0] + if (typeof firstRow === 'object' && !Array.isArray(firstRow)) { + _headers = Object.keys(firstRow) + } else if (Array.isArray(firstRow)) { + _headers = null + } + } + + // Convert data rows to 2D array + const rows: any[][] = data.map((row) => { + if (Array.isArray(row)) { + return row + } + // object row - map using headers if available, otherwise use object values + if (_headers && Array.isArray(_headers)) { + return _headers.map((h) => (row as Record)[h] ?? '') + } + return Object.values(row) + }) + + // Combine headers and rows for sheet + const sheetData = _headers ? [_headers, ...rows] : rows + + // Create worksheet and workbook + const ws = utils.aoa_to_sheet(sheetData) + const wb = utils.book_new() + utils.book_append_sheet(wb, ws, sheetName) + + // Write and save file + const wbout = write(wb, { bookType: 'xlsx', type: 'array' }) + saveAs(new Blob([wbout], { type: 'application/octet-stream' }), filename) +} diff --git a/package.json b/package.json index d415abde..08b0d87c 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ "date-fns": "^4.1.0", "embla-carousel": "^8.5.2", "embla-carousel-vue": "^8.5.2", + "file-saver": "^2.0.5", "h3": "^1.15.4", "pinia": "^3.0.3", "pinia-plugin-persistedstate": "^4.4.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "xlsx": "^0.18.5" }, "devDependencies": { "@antfu/eslint-config": "^4.10.1", @@ -36,6 +38,7 @@ "@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/tailwindcss": "6.14.0", "@pinia/nuxt": "^0.11.2", + "@types/file-saver": "^2.0.7", "@unocss/eslint-plugin": "^66.0.0", "@unocss/nuxt": "^66.0.0", "@vee-validate/zod": "^4.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae777c3..565ff313 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ dependencies: embla-carousel-vue: specifier: ^8.5.2 version: 8.6.0(vue@3.5.21) + file-saver: + specifier: ^2.0.5 + version: 2.0.5 h3: specifier: ^1.15.4 version: 1.15.4 @@ -44,6 +47,9 @@ dependencies: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@antfu/eslint-config': @@ -67,6 +73,9 @@ devDependencies: '@pinia/nuxt': specifier: ^0.11.2 version: 0.11.2(pinia@3.0.3) + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 '@unocss/eslint-plugin': specifier: ^66.0.0 version: 66.5.1(eslint@9.36.0)(typescript@5.9.2) @@ -3182,6 +3191,10 @@ packages: /@types/estree@1.0.8: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + /@types/file-saver@2.0.7: + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + dev: true + /@types/geojson@7946.0.16: resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} dev: false @@ -4588,6 +4601,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + dev: false + /agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -4796,12 +4814,12 @@ packages: dev: true optional: true - /my-ui64-js@1.5.1: + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: true - /my-uiline-browser-mapping@2.8.6: - resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} + /baseline-browser-mapping@2.8.29: + resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} hasBin: true dev: true @@ -4845,7 +4863,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - baseline-browser-mapping: 2.8.6 + baseline-browser-mapping: 2.8.29 caniuse-lite: 1.0.30001743 electron-to-chromium: 1.5.222 node-releases: 2.0.21 @@ -4966,6 +4984,14 @@ packages: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} dev: true + /cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + dev: false + /chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -5084,6 +5110,11 @@ packages: engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} dev: true + /codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + dev: false + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -5244,7 +5275,6 @@ packages: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} hasBin: true - dev: true /crc32-stream@6.0.0: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} @@ -6754,6 +6784,10 @@ packages: flat-cache: 4.0.1 dev: true + /file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + dev: false + /file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} dev: true @@ -6814,6 +6848,11 @@ packages: engines: {node: '>=0.4.x'} dev: true + /frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + dev: false + /fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: true @@ -10219,6 +10258,13 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + /ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + dependencies: + frac: 1.1.2 + dev: false + /stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -11603,11 +11649,21 @@ packages: stackback: 0.0.2 dev: true + /wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + dev: false + /word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} dev: true + /word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + dev: false + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -11648,6 +11704,20 @@ packages: is-wsl: 3.1.0 dev: true + /xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + dev: false + /xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'}