feat: integrate get review data endpoint to table

This commit is contained in:
Mahen 2026-02-09 08:50:55 +07:00
parent 762de51baa
commit bcb392f172
6 changed files with 229 additions and 76 deletions

View File

@ -6,19 +6,10 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
// const body = await request.json();
// const { name, brand } = body;
// if (!name || !brand) {
// return NextResponse.json(
// { error: "Missing required fields" },
// { status: 400 },
// );
// }
const reviews = [
{
productId: 2,
modelId: 1,
content:
"Laptop ini sangat ringan dan performanya cepat untuk kerja harian.",
keywords: ["ringan", "cepat", "kerja"],
@ -27,6 +18,7 @@ export async function POST(request: Request) {
},
{
productId: 3,
modelId: 1,
content: "Baterainya awet, tapi harganya cukup mahal.",
keywords: ["baterai", "awet", "mahal"],
sentiment: Sentiment.neutral,
@ -34,6 +26,7 @@ export async function POST(request: Request) {
},
{
productId: 4,
modelId: 1,
content: "Performa kurang stabil dan sering panas.",
keywords: ["performa", "panas", "stabil"],
sentiment: Sentiment.negative,
@ -60,3 +53,38 @@ export async function POST(request: Request) {
);
}
}
export async function GET() {
try {
const review = await prisma.review.findMany({
orderBy: {
createdAt: "desc",
},
select: {
id: true,
createdAt: true,
confidenceScore: true,
sentiment: true,
content: true,
keywords: true,
product: {
select: {
name: true,
brand: true,
},
},
},
});
return NextResponse.json(
{
message: "Review data successfuly retrivied",
data: review,
},
{ status: 200 },
);
} catch (error) {
console.log(error);
return NextResponse.json({ message: "Error", data: [] }, { status: 500 });
}
}

View File

@ -1,4 +1,5 @@
"use client";
import { Header } from "./Header";
import {
MessageSquareText,
@ -28,7 +29,6 @@ export default function DashboardClient() {
positiveCount,
negativeCount,
neutralCount,
filteredReviews,
selectedBrand,
loading,
modelData,
@ -153,7 +153,7 @@ export default function DashboardClient() {
onSelect={setSelectedBrand}
/>
</div>
<ReviewTable reviews={filteredReviews} />
<ReviewTable />
</div>
<footer className="mt-12 border-t pt-8">

View File

@ -1,3 +1,5 @@
"use client";
import {
Table,
TableBody,
@ -6,56 +8,152 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { ReviewTableProps } from "@/src/types";
import { Badge } from "../../components/ui/badge";
import { EllipsisVertical, Inbox, Loader2, Pencil, Trash } from "lucide-react";
import getSentimentBadge from "./SentimentBadge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useReviewTable } from "@/src/hooks/useReviewTable";
export function ReviewTable() {
const { data, isLoading } = useReviewTable();
if (isLoading) {
return (
<div className="flex h-75 w-full flex-col items-center justify-center gap-2 rounded-xl border bg-card text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm">Memuat data ulasan...</p>
</div>
);
}
export function ReviewTable({ reviews }: ReviewTableProps) {
return (
<div className="rounded-xl border bg-card">
<div className="rounded-xl border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-50">Produk</TableHead>
<TableHead className="min-w-75">Ulasan</TableHead>
<TableHead className="w-25">Rating</TableHead>
<TableHead className="w-25">Sentimen</TableHead>
<TableHead className="w-25 text-right">Confidence</TableHead>
<TableHead className="w-62.5">Produk</TableHead>
<TableHead className="w-auto min-w-75">
Ulasan & Kata Kunci
</TableHead>
<TableHead className="w-30 whitespace-nowrap">Tanggal</TableHead>
<TableHead className="w-30">Sentimen</TableHead>
<TableHead className="w-50">Confidence Score</TableHead>
<TableHead className="w-25">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reviews.map((review, index) => (
<TableRow
key={review.id}
className="animate-fade-in"
style={{ animationDelay: `${index * 50}ms` }}
>
<TableCell className="max-w-40 overflow-hidden">
<div className="max-w-40">
<p className="font-medium text-foreground">{review.brand}</p>
<p className="text-sm text-muted-foreground truncate">
{review.product}
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-75 text-center">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<div className="rounded-full bg-muted p-4">
<Inbox className="h-8 w-8" />
</div>
<p className="text-lg font-medium text-foreground">
Belum ada data
</p>
<p className="text-sm">
Belum ada ulasan yang dianalisis oleh sistem.
</p>
</div>
</TableCell>
<TableCell className="max-w-60 overflow-hidden">
<p className="text-sm line-clamp-2 wrap-break-word truncate">
{review.review}
</p>
<p className="mt-1 text-xs text-muted-foreground truncate">
{review.date}
</p>
</TableCell>
{/* <TableCell>{renderStars(review.rating)}</TableCell> */}
<TableCell>{getSentimentBadge(review.sentiment)}</TableCell>
<TableCell className="text-right">
<span className="font-medium">
{(review.confidence * 100).toFixed(1)}%
</span>
</TableCell>
</TableRow>
))}
) : (
data.map((review, index) => (
<TableRow
key={review.id || index}
className="group animate-in fade-in transition-colors hover:bg-muted/40"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: "backwards",
}}
>
<TableCell className="align-top">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary ring-1 ring-inset ring-primary/20">
{review.product?.brand || "Generic"}
</span>
</div>
<span className="text-sm font-medium leading-tight text-foreground line-clamp-2">
{review.product?.name || "Unknown Product"}
</span>
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col gap-3">
<p className="text-sm leading-relaxed text-muted-foreground line-clamp-3 group-hover:text-foreground transition-colors">
{review.content}
</p>
{review.keywords && review.keywords.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{review.keywords.slice(0, 5).map((k, i) => (
<Badge
key={i}
variant="secondary"
className="h-5 px-1.5 text-[10px] font-normal text-muted-foreground border-border bg-muted group-hover:bg-background transition-all"
>
{k}
</Badge>
))}
</div>
)}
</div>
</TableCell>
<TableCell className="align-top whitespace-nowrap">
<span className="text-xs text-muted-foreground font-medium">
{review.createdAt
? new Date(review.createdAt).toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
})
: "-"}
</span>
</TableCell>
<TableCell className="align-top">
{getSentimentBadge(review.sentiment as any)}
</TableCell>
<TableCell className="align-top">
<span className="font-mono text-sm font-semibold text-foreground">
{review.confidenceScore
? `${(review.confidenceScore * 100).toFixed(1)}%`
: "-"}
</span>
</TableCell>
<TableCell className="align-top ">
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<EllipsisVertical className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className={`w-max bg-card border-border shadow-md `}
>
<DropdownMenuItem className="cursor-pointer gap-2 focus:bg-sentiment-neutral-light focus:text-sentiment-neutral transition-colors hover:text-primary">
<Pencil />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem className="flex cursor-pointer gap-2 text-destructive focus:bg-red-500 focus:text-white transition-colors">
<Trash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>

View File

@ -1,17 +1,12 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<div className="relative w-full overflow-auto rounded-md">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
@ -20,29 +15,18 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
<tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
@ -55,9 +39,8 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
@ -68,9 +51,8 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@ -81,9 +63,8 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"p-4 align-middle [&:has([role=checkbox])]:pr-0", // Dihapus: whitespace-nowrap agar text panjang bisa wrap
className,
)}
{...props}
@ -97,8 +78,7 @@ function TableCaption({
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);

View File

@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import { ApiResponse, ReviewItem } from "../types";
export const useReviewTable = () => {
const [data, setData] = useState<ReviewItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const getReviewData = async () => {
try {
const req = await fetch("/api/review");
const res: ApiResponse = await req.json();
if (res.data && Array.isArray(res.data)) {
setData(res.data);
} else {
setData([]);
}
} catch (error) {
console.error("Gagal fetch data:", error);
} finally {
setIsLoading(false);
}
};
getReviewData();
}, []);
return { data, isLoading };
};

View File

@ -122,3 +122,21 @@ export interface UseStatCardProps {
value: number;
delay?: number;
}
export interface ReviewItem {
id: number;
content: string;
sentiment: string;
confidenceScore: number;
createdAt: string;
keywords: string[];
product: {
name: string;
brand?: string;
} | null;
}
export interface ApiResponse {
message: string;
data: ReviewItem[];
}