diff --git a/src/app/api/product/route.ts b/src/app/api/product/route.ts new file mode 100644 index 0000000..d061941 --- /dev/null +++ b/src/app/api/product/route.ts @@ -0,0 +1,42 @@ +import prisma from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +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 products = [ + { name: "ZenBook 14", brand: "ASUS" }, + { name: "Swift 3", brand: "Acer" }, + { name: "Surface Laptop 5", brand: "Microsoft" }, + ]; + + const result = await prisma.product.createMany({ + data: products, + }); + + return NextResponse.json( + { + message: "Booking successful", + data: result, + }, + { status: 201 }, + ); + } catch (error: any) { + console.error("Create product Error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/review/route.ts b/src/app/api/review/route.ts new file mode 100644 index 0000000..babcd58 --- /dev/null +++ b/src/app/api/review/route.ts @@ -0,0 +1,62 @@ +import prisma from "@/lib/prisma"; +import { Sentiment } from "@prisma/client"; +import { NextResponse } from "next/server"; + +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, + content: + "Laptop ini sangat ringan dan performanya cepat untuk kerja harian.", + keywords: ["ringan", "cepat", "kerja"], + sentiment: Sentiment.positive, + confidenceScore: 0.92, + }, + { + productId: 3, + content: "Baterainya awet, tapi harganya cukup mahal.", + keywords: ["baterai", "awet", "mahal"], + sentiment: Sentiment.neutral, + confidenceScore: 0.75, + }, + { + productId: 4, + content: "Performa kurang stabil dan sering panas.", + keywords: ["performa", "panas", "stabil"], + sentiment: Sentiment.negative, + confidenceScore: 0.88, + }, + ]; + + const result = await prisma.review.createMany({ + data: reviews, + }); + + return NextResponse.json( + { + message: "Booking successful", + data: result, + }, + { status: 201 }, + ); + } catch (error: any) { + console.error("Create product Error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/review/sentiment-stats/route.ts b/src/app/api/review/sentiment-stats/route.ts new file mode 100644 index 0000000..e9d51a1 --- /dev/null +++ b/src/app/api/review/sentiment-stats/route.ts @@ -0,0 +1,29 @@ +// app/api/reviews/sentiment-stats/route.ts +import prisma from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET() { + try { + const grouped = await prisma.review.groupBy({ + by: ["sentiment"], + _count: { _all: true }, + }); + + const result = { + positive: 0, + negative: 0, + neutral: 0, + }; + + grouped.forEach((item) => { + if (item.sentiment === "positive") result.positive = item._count._all; + if (item.sentiment === "negative") result.negative = item._count._all; + if (item.sentiment === "neutral") result.neutral = item._count._all; + }); + + return NextResponse.json(result); + } catch (error) { + console.log(error); + return []; + } +} diff --git a/src/components/dashboards/DashboardClient.tsx b/src/components/dashboards/DashboardClient.tsx index 76db7fd..5128d4d 100644 --- a/src/components/dashboards/DashboardClient.tsx +++ b/src/components/dashboards/DashboardClient.tsx @@ -20,7 +20,7 @@ import { SentimentAnalyzer } from "./SentimentAnalyzer"; import { BrandFilter } from "./BrandFilter"; import { ReviewTable } from "./ReviewTable"; import { SentimentChart, TrendChart, WordCloud } from "@/src/utils/dImports"; -import { useDashboard } from "@/src/hooks/useDashboard"; +import { useDashboards } from "@/src/hooks/useDashboard"; export default function DashboardClient() { const { @@ -30,10 +30,11 @@ export default function DashboardClient() { neutralCount, filteredReviews, selectedBrand, - setSelectedBrand, loading, modelData, - } = useDashboard(); + setSelectedBrand, + percentage, + } = useDashboards(); return (
@@ -71,26 +72,29 @@ export default function DashboardClient() { trend={{ value: 12.5, isPositive: true }} delay={0} /> + + +
diff --git a/src/components/dashboards/ModelInfo.tsx b/src/components/dashboards/ModelInfo.tsx index f1f5f48..f9b7bd7 100644 --- a/src/components/dashboards/ModelInfo.tsx +++ b/src/components/dashboards/ModelInfo.tsx @@ -36,7 +36,7 @@ export function ModelInfo({ data }: { data: ModelDB[] }) { - + {data.map((model, index) => (

{title}

+
{displayValue.toLocaleString()} @@ -35,6 +36,7 @@ export function StatCard({ )}
+ {trend && (
)}
+
diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index 9ef66eb..df8ab6c 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -1,32 +1,44 @@ "use client"; -import { useEffect, useState } from "react"; -import { ModelDB } from "../types"; -import { reviewData, sentimentDistribution } from "../app/dashboard/lib/data"; + +import { useState, useEffect, useMemo } from "react"; +import { ModelDB, Review, StatCounts } from "@/src/types"; import { getClassificationReport } from "../app/dashboard/lib/actions"; -export const useDashboard = () => { +export const useDashboards = () => { const [selectedBrand, setSelectedBrand] = useState(null); const [loading, setLoading] = useState(true); const [modelData, setModelData] = useState([]); - - const totalReviews = sentimentDistribution.reduce( - (sum, s) => sum + s.value, - 0, - ); - - const positiveCount = - sentimentDistribution.find((s) => s.name === "Positif")?.value || 0; - const negativeCount = - sentimentDistribution.find((s) => s.name === "Negatif")?.value || 0; - const neutralCount = - sentimentDistribution.find((s) => s.name === "Netral")?.value || 0; - - const filteredReviews = selectedBrand - ? reviewData.filter((r) => r.brand === selectedBrand) - : reviewData; + const [reviews, setReviews] = useState([]); + const [stats, setStats] = useState({ + totalReviews: 0, + positive: 0, + negative: 0, + neutral: 0, + }); useEffect(() => { - async function fetchData() { + async function fetchStats() { + setLoading(true); + const res = await fetch("/api/review/sentiment-stats"); + const data = await res.json(); + + const total = data.positive + data.negative + data.neutral; + + setStats({ + totalReviews: total ?? 0, + positive: data.positive ?? 0, + negative: data.negative ?? 0, + neutral: data.neutral ?? 0, + }); + + setLoading(false); + } + + fetchStats(); + }, []); + + useEffect(() => { + async function fetchModelData() { try { const data = await getClassificationReport(); setModelData(data); @@ -36,18 +48,29 @@ export const useDashboard = () => { setLoading(false); } } - fetchData(); + + fetchModelData(); }, []); + const filteredReviews = useMemo(() => { + return selectedBrand + ? reviews.filter((r) => r.brand === selectedBrand) + : reviews; + }, [reviews, selectedBrand]); + + const percentage = (value: number, total: number) => + total > 0 ? ((value / total) * 100).toFixed(1) : "0.0"; + return { - totalReviews, - positiveCount, - negativeCount, - neutralCount, + totalReviews: stats.totalReviews, + positiveCount: stats.positive, + negativeCount: stats.negative, + neutralCount: stats.neutral, filteredReviews, selectedBrand, loading, modelData, setSelectedBrand, + percentage, }; }; diff --git a/src/hooks/useHeader.ts b/src/hooks/useHeader.ts index a9e7383..ffc4aba 100644 --- a/src/hooks/useHeader.ts +++ b/src/hooks/useHeader.ts @@ -1,15 +1,20 @@ import { useSession } from "next-auth/react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; export const useHeader = () => { const [isRefreshing, setIsRefreshing] = useState(false); const [open, setOpen] = useState(false); const session = useSession(); + const [mounted, setMounted] = useState(false); const handleRefresh = () => { setIsRefreshing(true); setTimeout(() => setIsRefreshing(false), 1500); }; - return { open, setOpen, session }; + useEffect(() => { + setMounted(true); + }, []); + + return { open, setOpen, session, isRefreshing, handleRefresh, mounted }; }; diff --git a/src/hooks/useStatCard.ts b/src/hooks/useStatCard.ts index 7ab6b2d..f5d8f15 100644 --- a/src/hooks/useStatCard.ts +++ b/src/hooks/useStatCard.ts @@ -1,50 +1,32 @@ import { useEffect, useState } from "react"; -import { StatCardProps } from "../types"; +import { UseStatCardProps } from "../types"; -export const useStatCard = ({ - title, - value, - suffix = "", - icon: Icon, - trend, - variant = "default", - delay = 0, -}: StatCardProps) => { - const [displayValue, setDisplayValue] = useState(0); +export function useStatCard({ value, delay = 0 }: UseStatCardProps) { const [isVisible, setIsVisible] = useState(false); + const [displayValue, setDisplayValue] = useState(0); useEffect(() => { - const timer = setTimeout(() => { + const timeout = setTimeout(() => { setIsVisible(true); + + let start = 0; + const duration = 800; + const stepTime = 16; + const increment = value / (duration / stepTime); + + const counter = setInterval(() => { + start += increment; + if (start >= value) { + setDisplayValue(value); + clearInterval(counter); + } else { + setDisplayValue(Math.floor(start)); + } + }, stepTime); }, delay); - return () => clearTimeout(timer); - }, [delay]); - useEffect(() => { - if (!isVisible) return; - - const duration = 1200; - const steps = 40; - const stepValue = value / steps; - let current = 0; - let step = 0; - - const timer = setInterval(() => { - step++; - const progress = step / steps; - const eased = 1 - Math.pow(1 - progress, 3); - current = value * eased; - - if (step >= steps) { - setDisplayValue(value); - clearInterval(timer); - } else { - setDisplayValue(Math.floor(current)); - } - }, duration / steps); - - return () => clearInterval(timer); - }, [value, isVisible]); + return () => clearTimeout(timeout); + }, [value, delay]); return { isVisible, displayValue }; -}; +} diff --git a/src/types/index.ts b/src/types/index.ts index 4dd9fc1..2b4b3d3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import { UserGender } from "@prisma/client"; +import { Sentiment, UserGender } from "@prisma/client"; import { LucideIcon } from "lucide-react"; export interface ModelDB { @@ -28,12 +28,12 @@ export interface BrandFilterProps { } export interface Review { - id: string; + id: number; product: string; brand: string; review: string; - rating: number; - sentiment: "positif" | "negatif" | "netral"; + rating?: number | null; + sentiment: Sentiment; date: string; confidence: number; } @@ -110,3 +110,15 @@ export interface WordCloudItemProps { maxValue: number; minValue: number; } + +export interface StatCounts { + totalReviews: number; + positive: number; + negative: number; + neutral: number; +} + +export interface UseStatCardProps { + value: number; + delay?: number; +}