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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
// const body = await request.json();
|
|
||||||
|
|
||||||
// const { name, brand } = body;
|
|
||||||
// if (!name || !brand) {
|
|
||||||
// return NextResponse.json(
|
|
||||||
// { error: "Missing required fields" },
|
|
||||||
// { status: 400 },
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
const reviews = [
|
const reviews = [
|
||||||
{
|
{
|
||||||
productId: 2,
|
productId: 2,
|
||||||
|
modelId: 1,
|
||||||
content:
|
content:
|
||||||
"Laptop ini sangat ringan dan performanya cepat untuk kerja harian.",
|
"Laptop ini sangat ringan dan performanya cepat untuk kerja harian.",
|
||||||
keywords: ["ringan", "cepat", "kerja"],
|
keywords: ["ringan", "cepat", "kerja"],
|
||||||
|
|
@ -27,6 +18,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 3,
|
productId: 3,
|
||||||
|
modelId: 1,
|
||||||
content: "Baterainya awet, tapi harganya cukup mahal.",
|
content: "Baterainya awet, tapi harganya cukup mahal.",
|
||||||
keywords: ["baterai", "awet", "mahal"],
|
keywords: ["baterai", "awet", "mahal"],
|
||||||
sentiment: Sentiment.neutral,
|
sentiment: Sentiment.neutral,
|
||||||
|
|
@ -34,6 +26,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
productId: 4,
|
productId: 4,
|
||||||
|
modelId: 1,
|
||||||
content: "Performa kurang stabil dan sering panas.",
|
content: "Performa kurang stabil dan sering panas.",
|
||||||
keywords: ["performa", "panas", "stabil"],
|
keywords: ["performa", "panas", "stabil"],
|
||||||
sentiment: Sentiment.negative,
|
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";
|
"use client";
|
||||||
|
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
import {
|
import {
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
|
|
@ -28,7 +29,6 @@ export default function DashboardClient() {
|
||||||
positiveCount,
|
positiveCount,
|
||||||
negativeCount,
|
negativeCount,
|
||||||
neutralCount,
|
neutralCount,
|
||||||
filteredReviews,
|
|
||||||
selectedBrand,
|
selectedBrand,
|
||||||
loading,
|
loading,
|
||||||
modelData,
|
modelData,
|
||||||
|
|
@ -153,7 +153,7 @@ export default function DashboardClient() {
|
||||||
onSelect={setSelectedBrand}
|
onSelect={setSelectedBrand}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ReviewTable reviews={filteredReviews} />
|
<ReviewTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-12 border-t pt-8">
|
<footer className="mt-12 border-t pt-8">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -6,56 +8,152 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} 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 getSentimentBadge from "./SentimentBadge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { useReviewTable } from "@/src/hooks/useReviewTable";
|
||||||
|
|
||||||
export function ReviewTable({ reviews }: ReviewTableProps) {
|
export function ReviewTable() {
|
||||||
|
const { data, isLoading } = useReviewTable();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="w-50">Produk</TableHead>
|
<TableHead className="w-62.5">Produk</TableHead>
|
||||||
<TableHead className="min-w-75">Ulasan</TableHead>
|
<TableHead className="w-auto min-w-75">
|
||||||
<TableHead className="w-25">Rating</TableHead>
|
Ulasan & Kata Kunci
|
||||||
<TableHead className="w-25">Sentimen</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-25 text-right">Confidence</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{reviews.map((review, index) => (
|
{data.length === 0 ? (
|
||||||
<TableRow
|
<TableRow>
|
||||||
key={review.id}
|
<TableCell colSpan={5} className="h-75 text-center">
|
||||||
className="animate-fade-in"
|
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
<div className="rounded-full bg-muted p-4">
|
||||||
>
|
<Inbox className="h-8 w-8" />
|
||||||
<TableCell className="max-w-40 overflow-hidden">
|
</div>
|
||||||
<div className="max-w-40">
|
<p className="text-lg font-medium text-foreground">
|
||||||
<p className="font-medium text-foreground">{review.brand}</p>
|
Belum ada data
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
</p>
|
||||||
{review.product}
|
<p className="text-sm">
|
||||||
|
Belum ada ulasan yang dianalisis oleh sistem.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
<TableCell className="max-w-60 overflow-hidden">
|
) : (
|
||||||
<p className="text-sm line-clamp-2 wrap-break-word truncate">
|
data.map((review, index) => (
|
||||||
{review.review}
|
<TableRow
|
||||||
</p>
|
key={review.id || index}
|
||||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
className="group animate-in fade-in transition-colors hover:bg-muted/40"
|
||||||
{review.date}
|
style={{
|
||||||
</p>
|
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>
|
||||||
|
|
||||||
{/* <TableCell>{renderStars(review.rating)}</TableCell> */}
|
<TableCell className="align-top">
|
||||||
<TableCell>{getSentimentBadge(review.sentiment)}</TableCell>
|
<div className="flex flex-col gap-3">
|
||||||
<TableCell className="text-right">
|
<p className="text-sm leading-relaxed text-muted-foreground line-clamp-3 group-hover:text-foreground transition-colors">
|
||||||
<span className="font-medium">
|
{review.content}
|
||||||
{(review.confidence * 100).toFixed(1)}%
|
</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>
|
</span>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative w-full overflow-auto rounded-md">
|
||||||
data-slot="table-container"
|
|
||||||
className="relative w-full overflow-x-auto"
|
|
||||||
>
|
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
@ -20,29 +15,18 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
return (
|
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||||
<thead
|
|
||||||
data-slot="table-header"
|
|
||||||
className={cn("[&_tr]:border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
return (
|
return (
|
||||||
<tbody
|
<tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||||
data-slot="table-body"
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
return (
|
return (
|
||||||
<tfoot
|
<tfoot
|
||||||
data-slot="table-footer"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
className,
|
className,
|
||||||
|
|
@ -55,9 +39,8 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -68,9 +51,8 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -81,9 +63,8 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -97,8 +78,7 @@ function TableCaption({
|
||||||
}: React.ComponentProps<"caption">) {
|
}: React.ComponentProps<"caption">) {
|
||||||
return (
|
return (
|
||||||
<caption
|
<caption
|
||||||
data-slot="table-caption"
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
|
||||||
{...props}
|
{...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;
|
value: number;
|
||||||
delay?: 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