feat: integrate get review data endpoint to table
This commit is contained in:
parent
762de51baa
commit
bcb392f172
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue