From a5c3737524afbf703e4b9882f46cbcfe28d27bf0 Mon Sep 17 00:00:00 2001 From: Mahen Date: Thu, 19 Mar 2026 08:12:48 +0700 Subject: [PATCH] feat: add export data to excel cta --- package-lock.json | 104 +++ package.json | 1 + src/components/dashboards/DashboardClient.tsx | 6 +- src/components/dashboards/ExportExcel.tsx | 23 + src/components/dashboards/ReviewTable.tsx | 658 ++++++++++++------ src/hooks/useReviewTable.ts | 10 +- src/services/report.service.ts | 52 +- src/services/review.service.ts | 18 +- 8 files changed, 646 insertions(+), 226 deletions(-) create mode 100644 src/components/dashboards/ExportExcel.tsx diff --git a/package-lock.json b/package-lock.json index c84ab29..5ae4529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "tailwindcss-animate": "^1.0.7", "ts-node": "^10.9.2", "tsx": "^4.21.0", + "xlsx": "^0.18.5", "zod": "^4.3.6" }, "devDependencies": { @@ -6335,6 +6336,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", @@ -6979,6 +6989,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7116,6 +7139,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7199,6 +7231,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -8653,6 +8697,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/framer-motion": { "version": "12.33.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.33.0.tgz", @@ -12669,6 +12722,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -13657,6 +13722,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -13711,6 +13794,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 8785bac..6f3f6c8 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "tailwindcss-animate": "^1.0.7", "ts-node": "^10.9.2", "tsx": "^4.21.0", + "xlsx": "^0.18.5", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/components/dashboards/DashboardClient.tsx b/src/components/dashboards/DashboardClient.tsx index 998fb32..d848af8 100644 --- a/src/components/dashboards/DashboardClient.tsx +++ b/src/components/dashboards/DashboardClient.tsx @@ -20,6 +20,7 @@ import { WordCloud } from "./WordCloud"; import AnalysisClient from "./AnalysisClient"; import Footer from "./Footer"; import { Button } from "../ui/button"; +import ExportExcel from "./ExportExcel"; export default function DashboardClient() { const { @@ -151,7 +152,10 @@ export default function DashboardClient() { Hasil klasifikasi sentimen ulasan produk laptop

- +
+ + +
diff --git a/src/components/dashboards/ExportExcel.tsx b/src/components/dashboards/ExportExcel.tsx new file mode 100644 index 0000000..1e2a39b --- /dev/null +++ b/src/components/dashboards/ExportExcel.tsx @@ -0,0 +1,23 @@ +import { Button } from "../ui/button"; +import { Download } from "lucide-react"; +import { useReviewTable } from "@/src/hooks/useReviewTable"; +import { useSearchParams } from "next/navigation"; +import { downloadAllData } from "@/src/services/report.service"; + +export default function ExportExcel() { + const searchParams = useSearchParams(); + const selectedBrand = searchParams.get("brand"); + const { isLoading, data } = useReviewTable(10, selectedBrand); + + return ( + + ); +} diff --git a/src/components/dashboards/ReviewTable.tsx b/src/components/dashboards/ReviewTable.tsx index ad8d63e..3423b76 100644 --- a/src/components/dashboards/ReviewTable.tsx +++ b/src/components/dashboards/ReviewTable.tsx @@ -1,3 +1,288 @@ +// "use client"; + +// import { +// Table, +// TableBody, +// TableCell, +// TableHead, +// TableHeader, +// TableRow, +// } from "../../components/ui/table"; +// import { Badge } from "../../components/ui/badge"; +// import { Inbox, Loader2 } from "lucide-react"; +// import getSentimentBadge from "./SentimentBadge"; +// import { useReviewTable } from "@/src/hooks/useReviewTable"; +// import { +// Pagination, +// PaginationContent, +// PaginationEllipsis, +// PaginationItem, +// PaginationLink, +// PaginationNext, +// PaginationPrevious, +// } from "../ui/pagination"; +// import { useSearchParams } from "next/navigation"; +// import { getVisiblePages } from "@/src/utils/datas"; +// import { exportToExcel } from "@/src/services/report.service"; + +// export function ReviewTable() { +// const searchParams = useSearchParams(); +// const selectedBrand = searchParams.get("brand"); +// const { currentData, isLoading, pagination } = useReviewTable( +// 10, +// selectedBrand, +// ); +// const { currentPage, totalPages, nextPage, prevPage, goToPage } = pagination; +// const visiblePage = getVisiblePages({ totalPages, currentPage }); + +// if (isLoading) { +// return ( +//
+// +//

Memuat data ulasan...

+//
+// ); +// } + +// return ( +//
+// +// +// +// Produk +// +// Ulasan & Kata Kunci +// +// Tanggal +// Sentimen +// Confidence Score +// +// +// +// {currentData.length === 0 ? ( +// +// +//
+//
+// +//
+//

+// Belum ada data +//

+//

+// Belum ada ulasan yang dianalisis oleh sistem. +//

+//
+//
+//
+// ) : ( +// currentData.map((review, index) => ( +// +// +//
+//
+// +// {/* Tambahkan .name di sini */} +// {review.product?.brand?.name || "Generic"} +// +//
+// +// {review.product?.name || "Unknown Product"} +// +//
+//
+ +// +//
+//

+// {review.content} +//

+ +// {review.keywords && review.keywords.length > 0 && ( +//
+// {review.keywords.slice(0, 5).map((k, i) => ( +// +// {k} +// +// ))} +//
+// )} +//
+//
+ +// +// +// {review.createdAt +// ? new Date(review.createdAt).toLocaleDateString("id-ID", { +// day: "numeric", +// month: "short", +// year: "numeric", +// }) +// : "-"} +// +// + +// +// {getSentimentBadge(review.sentiment ?? null)} +// + +// +// +// {review.confidenceScore +// ? `${(review.confidenceScore * 100).toFixed(1)}%` +// : "-"} +// +// +// {/* +// +// +// +// +// +// +// +// Edit +// +// +// +// Delete +// +// +// +// */} +//
+// )) +// )} +//
+//
+ +// {/* {totalPages > 1 && ( +//
+// +// +// +// { +// e.preventDefault(); +// prevPage(); +// }} +// className={ +// currentPage === 1 +// ? "pointer-events-none opacity-50" +// : "cursor-pointer" +// } +// /> +// + +// {[...Array(totalPages)].map((_, i) => ( +// +// { +// e.preventDefault(); +// goToPage(i + 1); +// }} +// isActive={currentPage === i + 1} +// > +// {i + 1} +// +// +// ))} + +// +// { +// e.preventDefault(); +// nextPage(); +// }} +// className={ +// currentPage === totalPages +// ? "pointer-events-none opacity-50" +// : "cursor-pointer" +// } +// /> +// +// +// +//
+// )} */} + +// {totalPages > 1 && ( +//
+// +// +// +// { +// e.preventDefault(); +// prevPage(); +// }} +// className={ +// currentPage === 1 +// ? "pointer-events-none opacity-50" +// : "cursor-pointer hover:bg-[#F8FBFF] hover:text-primary" +// } +// /> +// + +// {visiblePage.map((page, index) => ( +// +// {page === "..." ? ( +// +// ) : ( +// { +// e.preventDefault(); +// goToPage(page as number); +// }} +// isActive={currentPage === page} +// > +// {page} +// +// )} +// +// ))} + +// +// { +// e.preventDefault(); +// nextPage(); +// }} +// className={ +// currentPage === totalPages +// ? "pointer-events-none opacity-50" +// : "cursor-pointer hover:bg-primary hover:text-card" +// } +// /> +// +// +// +//
+// )} +//
+// ); +// } + "use client"; import { @@ -31,7 +316,7 @@ export function ReviewTable() { 10, selectedBrand, ); - const { currentPage, totalPages, nextPage, prevPage, goToPage } = pagination; + const { currentPage, totalPages } = pagination; const visiblePage = getVisiblePages({ totalPages, currentPage }); if (isLoading) { @@ -44,240 +329,173 @@ export function ReviewTable() { } return ( -
- - - - Produk - - Ulasan & Kata Kunci - - Tanggal - Sentimen - Confidence Score - - - - {currentData.length === 0 ? ( - - -
-
- -
-

- Belum ada data -

-

- Belum ada ulasan yang dianalisis oleh sistem. -

-
-
+
+
+
+ + + Produk + + Ulasan & Kata Kunci + + Tanggal + Sentimen + + Confidence Score + - ) : ( - currentData.map((review, index) => ( - - -
-
- - {/* Tambahkan .name di sini */} - {review.product?.brand?.name || "Generic"} + + + {currentData.length === 0 ? ( + + +
+
+ +
+

+ Belum ada data +

+

+ Belum ada ulasan yang dianalisis oleh sistem. +

+
+
+
+ ) : ( + currentData.map((review, index) => ( + + +
+
+ + {review.product?.brand?.name || "Generic"} + +
+ + {review.product?.name || "Unknown Product"}
- - {review.product?.name || "Unknown Product"} +
+ + +
+

+ {review.content} +

+ + {review.keywords && review.keywords.length > 0 && ( +
+ {review.keywords.slice(0, 5).map((k, i) => ( + + {k} + + ))} +
+ )} +
+
+ + + + {review.createdAt + ? new Date(review.createdAt).toLocaleDateString( + "id-ID", + { + day: "numeric", + month: "short", + year: "numeric", + }, + ) + : "-"} -
- + - -
-

- {review.content} -

+ + {getSentimentBadge(review.sentiment ?? null)} + - {review.keywords && review.keywords.length > 0 && ( -
- {review.keywords.slice(0, 5).map((k, i) => ( - - {k} - - ))} -
- )} -
-
+ + + {review.confidenceScore + ? `${(review.confidenceScore * 100).toFixed(1)}%` + : "-"} + + + + )) + )} + +
- - - {review.createdAt - ? new Date(review.createdAt).toLocaleDateString("id-ID", { - day: "numeric", - month: "short", - year: "numeric", - }) - : "-"} - - - - - {getSentimentBadge(review.sentiment ?? null)} - - - - - {review.confidenceScore - ? `${(review.confidenceScore * 100).toFixed(1)}%` - : "-"} - - - {/* - - - - - - - - Edit - - - - Delete - - - - */} - - )) - )} - - - - {/* {totalPages > 1 && ( -
- - - - { - e.preventDefault(); - prevPage(); - }} - className={ - currentPage === 1 - ? "pointer-events-none opacity-50" - : "cursor-pointer" - } - /> - - - {[...Array(totalPages)].map((_, i) => ( - - 1 && ( +
+ + + + { e.preventDefault(); - goToPage(i + 1); + pagination.prevPage(); }} - isActive={currentPage === i + 1} - > - {i + 1} - + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer hover:bg-[#F8FBFF] hover:text-primary" + } + /> - ))} - - { - e.preventDefault(); - nextPage(); - }} - className={ - currentPage === totalPages - ? "pointer-events-none opacity-50" - : "cursor-pointer" - } - /> - - - -
- )} */} + {visiblePage.map((page, index) => ( + + {page === "..." ? ( + + ) : ( + { + e.preventDefault(); + pagination.goToPage(page as number); + }} + isActive={currentPage === page} + > + {page} + + )} + + ))} - {totalPages > 1 && ( -
- - - - { - e.preventDefault(); - prevPage(); - }} - className={ - currentPage === 1 - ? "pointer-events-none opacity-50" - : "cursor-pointer hover:bg-[#F8FBFF] hover:text-primary" - } - /> - - - {visiblePage.map((page, index) => ( - - {page === "..." ? ( - - ) : ( - { - e.preventDefault(); - goToPage(page as number); - }} - isActive={currentPage === page} - > - {page} - - )} + + { + e.preventDefault(); + pagination.nextPage(); + }} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer hover:bg-primary hover:text-card" + } + /> - ))} - - - { - e.preventDefault(); - nextPage(); - }} - className={ - currentPage === totalPages - ? "pointer-events-none opacity-50" - : "cursor-pointer hover:bg-primary hover:text-card" - } - /> - - - -
- )} +
+
+
+ )} +
); } diff --git a/src/hooks/useReviewTable.ts b/src/hooks/useReviewTable.ts index ba82fbf..f6e2514 100644 --- a/src/hooks/useReviewTable.ts +++ b/src/hooks/useReviewTable.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useMemo } from "react"; import { ApiResponse, ReviewItem } from "../types"; +import { PaginationService } from "../services/review.service"; export const useReviewTable = ( itemsPerPage: number = 10, @@ -57,16 +58,19 @@ export const useReviewTable = ( }, [data, currentPage, itemsPerPage, selectedBrand]); const nextPage = () => { - if (currentPage < totalPages) setCurrentPage((prev) => prev + 1); + setCurrentPage((prev) => PaginationService.getNextPage(prev, totalPages)); }; + const prevPage = () => { - if (currentPage > 1) setCurrentPage((prev) => prev - 1); + setCurrentPage((prev) => PaginationService.getPrevPage(prev)); }; + const goToPage = (pageNumber: number) => { - if (pageNumber >= 1 && pageNumber <= totalPages) setCurrentPage(pageNumber); + setCurrentPage(PaginationService.getValidPage(pageNumber, totalPages)); }; return { + data, currentData, isLoading, pagination: { diff --git a/src/services/report.service.ts b/src/services/report.service.ts index 1d09aae..dea5e20 100644 --- a/src/services/report.service.ts +++ b/src/services/report.service.ts @@ -1,5 +1,7 @@ import prisma from "@/lib/prisma"; import { notFound } from "next/navigation"; +import * as XLSX from "xlsx"; +import { ReviewItem } from "../types"; export const reportService = async () => { const response = await prisma.model.findMany({ @@ -20,6 +22,54 @@ export const reportService = async () => { if (!response || response.length === 0) { return notFound(); } - + return response; }; + +export const exportToExcel = (data: ReviewItem[], fileName: string) => { + const worksheet = XLSX.utils.json_to_sheet(data); + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Sentiment Analysis"); + + XLSX.writeFile(workbook, `${fileName}.xlsx`); +}; + +export const downloadAllData = (data: ReviewItem[]) => { + if (data.length === 0) return; + + const headers = [ + "ID", + "Product Name", + "Brand", + "Review Text", + "Sentiment", + "Rating", + "Date", + ]; + + const csvRows = data.map((item) => [ + item.id, + `"${item.product?.name || ""}"`, + item.product?.brand?.name || "", + `"${item.content?.replace(/"/g, '""') || ""}"`, + item.sentiment || "", + item.confidenceScore || 0, + item.createdAt ? new Date(item.createdAt).toLocaleDateString() : "", + ]); + + const csvContent = [ + headers.join(","), + ...csvRows.map((row) => row.join(",")), + ].join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `all_reviews_${new Date().getTime()}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; diff --git a/src/services/review.service.ts b/src/services/review.service.ts index c3b67c5..764c9f3 100644 --- a/src/services/review.service.ts +++ b/src/services/review.service.ts @@ -37,6 +37,22 @@ export const getReviewService = async (email: string) => { }, }, }); - + return review; }; + +export const PaginationService = { + getNextPage: (currentPage: number, totalPages: number): number => { + return currentPage < totalPages ? currentPage + 1 : currentPage; + }, + + getPrevPage: (currentPage: number): number => { + return currentPage > 1 ? currentPage - 1 : currentPage; + }, + + getValidPage: (pageNumber: number, totalPages: number): number => { + if (pageNumber < 1) return 1; + if (pageNumber > totalPages) return totalPages; + return pageNumber; + }, +};