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;
+ },
+};