refactor: implement custom hooks on all relevant components that require them.

This commit is contained in:
Mahen 2026-02-07 10:50:43 +07:00
parent d2bcba7eb1
commit 0c03517965
27 changed files with 891 additions and 570 deletions

View File

@ -0,0 +1,145 @@
-- CreateEnum
CREATE TYPE "UserGender" AS ENUM ('male', 'female', 'other');
-- CreateEnum
CREATE TYPE "Sentiment" AS ENUM ('positive', 'negative', 'neutral');
-- CreateTable
CREATE TABLE "Account" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" SERIAL NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"gender" "UserGender",
"productReference" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Analysis" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"reviewId" INTEGER NOT NULL,
"productId" INTEGER NOT NULL,
"modelId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Analysis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"brand" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL NOT NULL,
"productId" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"keywords" TEXT[],
"sentiment" "Sentiment" NOT NULL,
"confidenceScore" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Model" (
"id" SERIAL NOT NULL,
"modelName" TEXT NOT NULL,
"description" TEXT NOT NULL,
"accuracy" DOUBLE PRECISION NOT NULL,
"macroF1" DOUBLE PRECISION NOT NULL,
"f1Negative" DOUBLE PRECISION NOT NULL,
"f1Neutral" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Model_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,18 +1,11 @@
import { cn } from "@/lib/utils";
import { BrandFilterProps } from "@/src/types";
interface Brand {
name: string;
count: number;
logo?: string;
}
interface BrandFilterProps {
brands: Brand[];
selectedBrand: string | null;
onSelect: (brand: string | null) => void;
}
export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProps) {
export function BrandFilter({
brands,
selectedBrand,
onSelect,
}: BrandFilterProps) {
return (
<div className="flex flex-wrap gap-2">
<button
@ -21,7 +14,7 @@ export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProp
"rounded-lg border px-4 py-2 text-sm font-medium transition-all",
selectedBrand === null
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground",
)}
>
Semua ({brands.reduce((sum, b) => sum + b.count, 0).toLocaleString()})
@ -34,7 +27,7 @@ export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProp
"rounded-lg border px-4 py-2 text-sm font-medium transition-all",
selectedBrand === brand.name
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground",
)}
>
{brand.name} ({brand.count.toLocaleString()})

View File

@ -0,0 +1,29 @@
const renderCustomLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}: any) => {
if (percent < 0.05) return null;
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor="middle"
dominantBaseline="central"
className="text-sm font-semibold"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
export default renderCustomLabel;

View File

@ -0,0 +1,28 @@
import { CustomTooltipProps } from "@/src/types";
import React from "react";
const CustomTooltip: React.FC<CustomTooltipProps> = ({
active,
payload,
total,
}) => {
if (active && payload && payload.length) {
const item = payload[0].payload;
const percentage = ((item.value / total) * 100).toFixed(1);
return (
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg">
<p className="font-semibold" style={{ color: item.color }}>
{item.name}
</p>
<p className="text-sm text-muted-foreground">
{item.value.toLocaleString()} ulasan ({percentage}%)
</p>
</div>
);
}
return null;
};
export default CustomTooltip;

View File

@ -1,5 +1,4 @@
"use client";
import { useEffect, useState } from "react";
import { Header } from "./Header";
import {
MessageSquareText,
@ -9,70 +8,32 @@ import {
TrendingUp,
} from "lucide-react";
import { StatCard } from "./StatCard";
import { ModelDB } from "@/src/types";
import {
brandData,
reviewData,
sentimentDistribution,
trendData,
wordCloudData,
} from "@/src/app/dashboard/lib/data";
import { getClassificationReport } from "@/src/app/dashboard/lib/actions";
import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton";
import { ModelInfo } from "./ModelInfo";
import { SentimentAnalyzer } from "./SentimentAnalyzer";
import { BrandFilter } from "./BrandFilter";
import { ReviewTable } from "./ReviewTable";
import dynamic from "next/dynamic";
const TrendChart = dynamic(
() => import("./TrendChart").then((mod) => ({ default: mod.TrendChart })),
{ ssr: false },
);
const WordCloud = dynamic(
() => import("./WordCloud").then((mod) => ({ default: mod.WordCloud })),
{ ssr: false },
);
const SentimentChart = dynamic(
() =>
import("./SentimentChart").then((mod) => ({ default: mod.SentimentChart })),
{ ssr: false },
);
import { SentimentChart, TrendChart, WordCloud } from "@/src/utils/dImports";
import { useDashboard } from "@/src/hooks/useDashboard";
export default function DashboardClient() {
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [modelData, setModelData] = useState<ModelDB[]>([]);
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;
useEffect(() => {
async function fetchData() {
try {
const data = await getClassificationReport();
setModelData(data);
} catch (error) {
console.error("Failed to fetch model data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
const {
totalReviews,
positiveCount,
negativeCount,
neutralCount,
filteredReviews,
selectedBrand,
setSelectedBrand,
loading,
modelData,
} = useDashboard();
return (
<div className="min-h-screen bg-background">
@ -102,7 +63,6 @@ export default function DashboardClient() {
</div>
</div>
{/* Stats Grid */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Ulasan"
@ -137,7 +97,6 @@ export default function DashboardClient() {
/>
</div>
{/* Charts Section */}
<div className="mb-8 grid gap-6 lg:grid-cols-3">
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
<h3 className="mb-4 text-lg font-semibold">
@ -151,9 +110,7 @@ export default function DashboardClient() {
</div>
</div>
{/* Word Cloud & Model Info */}
<div className="mb-8 grid gap-6 lg:grid-cols-2">
{/* Slot Kata Kunci */}
<div className="rounded-xl border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Kata Kunci Populer</h3>
<p className="mb-4 text-sm text-muted-foreground">
@ -174,12 +131,10 @@ export default function DashboardClient() {
)}
</div>
{/* Sentiment Analyzer */}
<div className="mb-8">
<SentimentAnalyzer />
</div>
{/* Reviews Section */}
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
@ -197,7 +152,6 @@ export default function DashboardClient() {
<ReviewTable reviews={filteredReviews} />
</div>
{/* Footer */}
<footer className="mt-12 border-t pt-8">
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
<div>

View File

@ -4,12 +4,9 @@ import {
Database,
Laptop,
LogOut,
Smile,
User,
UserCircle,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
@ -18,19 +15,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { signOut, useSession } from "next-auth/react";
import { signOut } from "next-auth/react";
import Link from "next/link";
import { useHeader } from "@/src/hooks/useHeader";
export function Header() {
const [isRefreshing, setIsRefreshing] = useState(false);
const [open, setOpen] = useState(false);
const session = useSession();
const handleRefresh = () => {
setIsRefreshing(true);
setTimeout(() => setIsRefreshing(false), 1500);
};
const { open, setOpen, session } = useHeader();
return (
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50">
<div className="container mx-auto px-4 py-4">
@ -59,21 +49,14 @@ export function Header() {
<Database className="h-4 w-4" />
<span>12,450 Ulasan</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Smile className="h-4 w-4" />
<span>{`Hi, ${session.data?.user?.name || "Guest"}`}</span>
</div>
</div>
<div onMouseEnter={() => setOpen(true)}>
<div onClick={() => setOpen(true)}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
size="sm"
className="gap-2 focus-visible:ring-0 border-border hover:bg-secondary hover:text-white transition-colors"
>
<div className="flex items-center gap-2 text-muted-foreground cursor-pointer">
<span>{`Hi, ${session.data?.user?.name || "Guest"}`}</span>
<User className={cn("h-4 w-4 text-muted-foreground")} />
<span className="hidden sm:inline">Profile</span>
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@ -4,14 +4,9 @@ import { Button } from "../ui/button";
import { ArrowLeft, Pencil } from "lucide-react";
import { Separator } from "../ui/separator";
import { useSession } from "next-auth/react";
import { UserGender } from "@prisma/client";
import Link from "next/link";
import { motion } from "framer-motion";
interface ProfileClientProps {
gender?: UserGender;
productReference?: string;
}
import { ProfileClientProps } from "@/src/types";
export default function ProfileClient({
gender,
@ -27,13 +22,15 @@ export default function ProfileClient({
transition={{ duration: 0.4, ease: "easeInOut" }}
className="container mx-auto px-4 py-8"
>
<Link
href="/"
className="flex items-center gap-2 text-md text-primary max-w-xl mx-auto"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to Dashboard</span>
</Link>
<div className="flex max-w-xl mx-auto">
<Link
href="/"
className="flex items-center gap-2 text-md text-primary max-w-xl w-max mr-auto hover:text-primary/80 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to Dashboard</span>
</Link>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}

View File

@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
import { Star } from "lucide-react";
const renderStars = (rating: number) => {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
"h-4 w-4",
i < rating
? "fill-sentiment-neutral text-sentiment-neutral"
: "fill-muted text-muted",
)}
/>
))}
</div>
);
};
export default renderStars;

View File

@ -6,77 +6,21 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Badge } from "../../components/ui/badge";
import { cn } from "@/lib/utils";
import { Star } from "lucide-react";
interface Review {
id: string;
product: string;
brand: string;
review: string;
rating: number;
sentiment: "positif" | "negatif" | "netral";
date: string;
confidence: number;
}
interface ReviewTableProps {
reviews: Review[];
}
import { ReviewTableProps } from "@/src/types";
import getSentimentBadge from "./SentimentBadge";
import renderStars from "./RenderStars";
export function ReviewTable({ reviews }: ReviewTableProps) {
const getSentimentBadge = (sentiment: Review["sentiment"]) => {
const styles = {
positif: "sentiment-positive",
negatif: "sentiment-negative",
netral: "sentiment-neutral",
};
const labels = {
positif: "Positif",
negatif: "Negatif",
netral: "Netral",
};
return (
<Badge
variant="secondary"
className={cn("font-medium", styles[sentiment])}
>
{labels[sentiment]}
</Badge>
);
};
const renderStars = (rating: number) => {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
"h-4 w-4",
i < rating
? "fill-sentiment-neutral text-sentiment-neutral"
: "fill-muted text-muted",
)}
/>
))}
</div>
);
};
return (
<div className="rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">Produk</TableHead>
<TableHead className="min-w-[300px]">Ulasan</TableHead>
<TableHead className="w-[100px]">Rating</TableHead>
<TableHead className="w-[100px]">Sentimen</TableHead>
<TableHead className="w-[100px] text-right">Confidence</TableHead>
<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>
</TableRow>
</TableHeader>
<TableBody>
@ -88,9 +32,7 @@ export function ReviewTable({ reviews }: ReviewTableProps) {
>
<TableCell className="max-w-40 overflow-hidden">
<div className="max-w-40">
<p className="font-medium text-foreground">
{review.brand}
</p>
<p className="font-medium text-foreground">{review.brand}</p>
<p className="text-sm text-muted-foreground truncate">
{review.product}
</p>

View File

@ -1,26 +1,10 @@
import { useMemo, useState } from "react";
import { Button } from "../../components/ui/button";
import { Textarea } from "../../components/ui/textarea";
import { Badge } from "../../components/ui/badge";
import { cn } from "@/lib/utils";
import {
Loader2,
Send,
Sparkles,
ThumbsUp,
ThumbsDown,
Minus,
} from "lucide-react";
import { Loader2, Send, Sparkles } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Combobox,
ComboboxContent,
@ -30,157 +14,25 @@ import {
ComboboxList,
} from "../ui/combobox";
import { Item, ItemContent, ItemDescription, ItemTitle } from "../ui/item";
interface AnalysisResult {
sentiment: "positif" | "negatif" | "netral";
confidence: number;
keywords: string[];
}
import { useSentiment } from "@/src/hooks/useSentiment";
export function SentimentAnalyzer() {
const [text, setText] = useState("");
const [laptopName, setLaptopName] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [selectedModel, setSelectedModel] = useState<
(typeof models)[number] | null
>(null);
const [searchQuery, setSearchQuery] = useState("");
const isFormValid =
text.trim() !== "" && laptopName.trim() !== "" && selectedModel !== null;
const analyzeText = async () => {
if (!text.trim()) return;
setIsAnalyzing(true);
setResult(null);
await new Promise((resolve) => setTimeout(resolve, 1500));
const positiveWords = [
"bagus",
"cepat",
"aman",
"baik",
"mulus",
"moga",
"awet",
"mantap",
"sangat",
"fungsi",
];
const negativeWords = [
"lebih",
"jual",
"baru",
"lalu",
"tahun",
"masalah",
"rusak",
"garansi",
"layar",
"kecewa",
];
const lowerText = text.toLowerCase();
let positiveScore = 0;
let negativeScore = 0;
const foundKeywords: string[] = [];
positiveWords.forEach((word) => {
if (lowerText.includes(word)) {
positiveScore++;
foundKeywords.push(word);
}
});
negativeWords.forEach((word) => {
if (lowerText.includes(word)) {
negativeScore++;
foundKeywords.push(word);
}
});
let sentiment: AnalysisResult["sentiment"];
let confidence: number;
if (positiveScore > negativeScore) {
sentiment = "positif";
confidence = 0.75 + Math.random() * 0.2;
} else if (negativeScore > positiveScore) {
sentiment = "negatif";
confidence = 0.75 + Math.random() * 0.2;
} else {
sentiment = "netral";
confidence = 0.6 + Math.random() * 0.2;
}
setResult({
sentiment,
confidence,
keywords: foundKeywords,
});
setIsAnalyzing(false);
};
const getSentimentDisplay = (sentiment: AnalysisResult["sentiment"]) => {
const config = {
positif: {
icon: ThumbsUp,
label: "Positif",
bgClass: "bg-sentiment-positive-light",
textClass: "text-sentiment-positive",
borderClass: "border-sentiment-positive/30",
},
negatif: {
icon: ThumbsDown,
label: "Negatif",
bgClass: "bg-sentiment-negative-light",
textClass: "text-sentiment-negative",
borderClass: "border-sentiment-negative/30",
},
netral: {
icon: Minus,
label: "Netral",
bgClass: "bg-sentiment-neutral-light",
textClass: "text-sentiment-neutral",
borderClass: "border-sentiment-neutral/30",
},
};
return config[sentiment];
};
const models = [
{
code: "none",
value: "xgboost",
label: "XGBoost (Baseline)",
desc: "Model 1",
},
{
code: "Grid Search",
value: "xgboost",
label: "XGBoost (Tuned)",
desc: "Model 2",
},
{
code: "recommended",
value: "xgboost",
label: "XGBoost (Fully Optimized)",
desc: "Model 3",
},
];
const filteredItems = useMemo(() => {
if (!searchQuery) return models;
return models.filter(
(model) =>
model.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.code.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
const {
selectedModel,
setSelectedModel,
text,
setText,
laptopName,
setLaptopName,
isAnalyzing,
analyzeText,
result,
getSentimentDisplay,
searchQuery,
setSearchQuery,
filteredItems,
isFormValid,
} = useSentiment();
return (
<div className="rounded-xl border bg-card p-6">

View File

@ -0,0 +1,25 @@
import { Review } from "@/src/types";
import { Badge } from "../ui/badge";
import { cn } from "@/lib/utils";
const getSentimentBadge = (sentiment: Review["sentiment"]) => {
const styles = {
positif: "sentiment-positive",
negatif: "sentiment-negative",
netral: "sentiment-neutral",
};
const labels = {
positif: "Positif",
negatif: "Negatif",
netral: "Netral",
};
return (
<Badge variant="secondary" className={cn("font-medium", styles[sentiment])}>
{labels[sentiment]}
</Badge>
);
};
export default getSentimentBadge;

View File

@ -1,3 +1,4 @@
import { SentimentChartProps } from "@/src/types";
import {
PieChart,
Pie,
@ -6,68 +7,14 @@ import {
Legend,
Tooltip,
} from "recharts";
interface SentimentData {
name: string;
value: number;
color: string;
}
interface SentimentChartProps {
data: SentimentData[];
}
import renderCustomLabel from "./CustomLabel";
import CustomTooltip from "./CustomToolTip";
export function SentimentChart({ data }: SentimentChartProps) {
const total = data.reduce((sum, item) => sum + item.value, 0);
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const item = payload[0].payload;
const percentage = ((item.value / total) * 100).toFixed(1);
return (
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg">
<p className="font-semibold" style={{ color: item.color }}>
{item.name}
</p>
<p className="text-sm text-muted-foreground">
{item.value.toLocaleString()} ulasan ({percentage}%)
</p>
</div>
);
}
return null;
};
const renderCustomLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}: any) => {
if (percent < 0.05) return null;
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor="middle"
dominantBaseline="central"
className="text-sm font-semibold"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
return (
<div className="h-[300px] w-full">
<div className="h-75 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
@ -91,7 +38,7 @@ export function SentimentChart({ data }: SentimentChartProps) {
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomTooltip total={total} />} />
<Legend
verticalAlign="bottom"
height={36}

View File

@ -1,19 +1,7 @@
import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
interface StatCardProps {
title: string;
value: number;
suffix?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
variant?: "default" | "positive" | "negative" | "neutral";
delay?: number;
}
import { useStatCard } from "@/src/hooks/useStatCard";
import { StatCardProps } from "@/src/types";
import { iconStyles, variantStyles } from "@/src/utils/styleType";
export function StatCard({
title,
@ -24,63 +12,14 @@ export function StatCard({
variant = "default",
delay = 0,
}: StatCardProps) {
const [displayValue, setDisplayValue] = useState(0);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, 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++;
// Easing function for smooth animation
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]);
const variantStyles = {
default: "bg-card border-border",
positive: "bg-sentiment-positive-light border-sentiment-positive/20",
negative: "bg-sentiment-negative-light border-sentiment-negative/20",
neutral: "bg-sentiment-neutral-light border-sentiment-neutral/20",
};
const iconStyles = {
default: "bg-primary/10 text-primary",
positive: "bg-sentiment-positive/10 text-sentiment-positive",
negative: "bg-sentiment-negative/10 text-sentiment-negative",
neutral: "bg-sentiment-neutral/10 text-sentiment-neutral",
};
const { isVisible, displayValue } = useStatCard({ title, value, icon: Icon });
return (
<div
className={cn(
"relative overflow-hidden rounded-xl border p-6 card-elevated transition-all duration-500",
variantStyles[variant],
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4",
)}
>
<div className="flex items-start justify-between">
@ -101,10 +40,13 @@ export function StatCard({
<span
className={cn(
"font-medium",
trend.isPositive ? "text-sentiment-positive" : "text-sentiment-negative"
trend.isPositive
? "text-sentiment-positive"
: "text-sentiment-negative",
)}
>
{trend.isPositive ? "+" : "-"}{Math.abs(trend.value)}%
{trend.isPositive ? "+" : "-"}
{Math.abs(trend.value)}%
</span>
<span className="text-muted-foreground">dari periode lalu</span>
</div>

View File

@ -1,56 +1,24 @@
"use client";
import { useEffect, useState } from "react";
import { useTrendChart } from "@/src/hooks/useTrendChart";
import { TrendChartProps } from "@/src/types";
import {
AreaChart,
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
interface TrendData {
date: string;
positif: number;
negatif: number;
netral: number;
}
interface TrendChartProps {
data: TrendData[];
}
import TrendChartTooltip from "./TrendChartToolTip";
export function TrendChart({ data }: TrendChartProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg">
<p className="mb-2 font-semibold text-foreground">{label}</p>
{payload.map((item: any, index: number) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-muted-foreground">{item.name}:</span>
<span className="font-medium">{item.value}</span>
</div>
))}
</div>
);
}
return null;
};
const { isMounted } = useTrendChart();
if (!isMounted) {
return <div className="h-[350px] w-full bg-transparent" />;
return <div className="h-87.5 w-full bg-transparent" />;
}
return (
@ -112,7 +80,7 @@ export function TrendChart({ data }: TrendChartProps) {
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<TrendChartTooltip />} />
<Legend
verticalAlign="top"
height={36}

View File

@ -0,0 +1,30 @@
import { TrendChartTooltipProps } from "@/src/types";
import React from "react";
const TrendChartTooltip: React.FC<TrendChartTooltipProps> = ({
active,
payload,
label,
}) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg">
<p className="mb-2 font-semibold text-foreground">{label}</p>
{payload.map((item: any, index: number) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-muted-foreground">{item.name}:</span>
<span className="font-medium">{item.value}</span>
</div>
))}
</div>
);
}
return null;
};
export default TrendChartTooltip;

View File

@ -1,73 +1,30 @@
"use client";
import { cn } from "@/lib/utils";
import { useState, useEffect, useMemo } from "react";
interface WordItem {
text: string;
value: number;
sentiment: "positive" | "negative" | "neutral";
}
interface WordCloudProps {
words: WordItem[];
}
import { WordCloudProps } from "@/src/types";
import WordCloudItem from "./WordCloudItem";
import { useWordCloud } from "@/src/hooks/useWordCloud";
export function WordCloud({ words }: WordCloudProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const maxValue = Math.max(...words.map((w) => w.value), 1);
const minValue = Math.min(...words.map((w) => w.value), 0);
const getSize = (value: number) => {
if (maxValue === minValue) return 1.5;
const normalized = (value - minValue) / (maxValue - minValue);
return 0.75 + normalized * 1.5;
};
const getColor = (sentiment: WordItem["sentiment"]) => {
switch (sentiment) {
case "positive":
return "text-sentiment-positive hover:bg-sentiment-positive-light";
case "negative":
return "text-sentiment-negative hover:bg-sentiment-negative-light";
default:
return "text-sentiment-neutral hover:bg-sentiment-neutral-light";
}
};
const shuffledWords = useMemo(() => {
return [...words].sort(() => Math.random() - 0.5);
}, [words]);
const { mounted, maxValue, minValue, shuffledWords } = useWordCloud({
words,
});
if (!mounted) {
return (
<div className="flex flex-wrap items-center justify-center gap-2 p-4 min-h-[150px]" />
<div className="flex flex-wrap items-center justify-center gap-2 p-4 min-h-37.5" />
);
}
return (
<div className="flex flex-wrap items-center justify-center gap-2 p-4">
{shuffledWords.map((word, index) => (
<span
<WordCloudItem
key={`${word.text}-${index}`}
className={cn(
"cursor-default rounded-lg px-2 py-1 font-medium transition-all duration-200 animate-in fade-in zoom-in",
getColor(word.sentiment),
)}
style={{
fontSize: `${getSize(word.value)}rem`,
animationDelay: `${index * 50}ms`,
animationFillMode: "both",
}}
title={`${word.text}: ${word.value} kemunculan`}
>
{word.text}
</span>
word={word}
index={index}
maxValue={maxValue}
minValue={minValue}
/>
))}
</div>
);

View File

@ -0,0 +1,47 @@
"use client";
import { cn } from "@/lib/utils";
import { WordCloudItemProps, WordItem } from "@/src/types";
const WordCloudItem: React.FC<WordCloudItemProps> = ({
word,
index,
maxValue,
minValue,
}) => {
const getSize = (value: number) => {
if (maxValue === minValue) return 1.5;
const normalized = (value - minValue) / (maxValue - minValue);
return 0.75 + normalized * 1.5;
};
const getColor = (sentiment: WordItem["sentiment"]) => {
switch (sentiment) {
case "positive":
return "text-sentiment-positive hover:bg-sentiment-positive-light";
case "negative":
return "text-sentiment-negative hover:bg-sentiment-negative-light";
default:
return "text-sentiment-neutral hover:bg-sentiment-neutral-light";
}
};
return (
<span
className={cn(
"cursor-default rounded-lg px-2 py-1 font-medium transition-all duration-200 animate-in fade-in zoom-in",
getColor(word.sentiment),
)}
style={{
fontSize: `${getSize(word.value)}rem`,
animationDelay: `${index * 50}ms`,
animationFillMode: "both",
}}
title={`${word.text}: ${word.value} kemunculan`}
>
{word.text}
</span>
);
};
export default WordCloudItem;

53
src/hooks/useDashboard.ts Normal file
View File

@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { ModelDB } from "../types";
import { reviewData, sentimentDistribution } from "../app/dashboard/lib/data";
import { getClassificationReport } from "../app/dashboard/lib/actions";
export const useDashboard = () => {
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [modelData, setModelData] = useState<ModelDB[]>([]);
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;
useEffect(() => {
async function fetchData() {
try {
const data = await getClassificationReport();
setModelData(data);
} catch (error) {
console.error("Failed to fetch model data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
return {
totalReviews,
positiveCount,
negativeCount,
neutralCount,
filteredReviews,
selectedBrand,
loading,
modelData,
setSelectedBrand,
};
};

15
src/hooks/useHeader.ts Normal file
View File

@ -0,0 +1,15 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
export const useHeader = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [open, setOpen] = useState(false);
const session = useSession();
const handleRefresh = () => {
setIsRefreshing(true);
setTimeout(() => setIsRefreshing(false), 1500);
};
return { open, setOpen, session };
};

166
src/hooks/useSentiment.ts Normal file
View File

@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import { AnalysisResult } from "../types";
import { Minus, ThumbsDown, ThumbsUp } from "lucide-react";
export const useSentiment = () => {
const [text, setText] = useState("");
const [laptopName, setLaptopName] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [selectedModel, setSelectedModel] = useState<
(typeof models)[number] | null
>(null);
const [searchQuery, setSearchQuery] = useState("");
const isFormValid =
text.trim() !== "" && laptopName.trim() !== "" && selectedModel !== null;
const analyzeText = async () => {
if (!text.trim()) return;
setIsAnalyzing(true);
setResult(null);
await new Promise((resolve) => setTimeout(resolve, 1500));
const positiveWords = [
"bagus",
"cepat",
"aman",
"baik",
"mulus",
"moga",
"awet",
"mantap",
"sangat",
"fungsi",
];
const negativeWords = [
"lebih",
"jual",
"baru",
"lalu",
"tahun",
"masalah",
"rusak",
"garansi",
"layar",
"kecewa",
];
const lowerText = text.toLowerCase();
let positiveScore = 0;
let negativeScore = 0;
const foundKeywords: string[] = [];
positiveWords.forEach((word) => {
if (lowerText.includes(word)) {
positiveScore++;
foundKeywords.push(word);
}
});
negativeWords.forEach((word) => {
if (lowerText.includes(word)) {
negativeScore++;
foundKeywords.push(word);
}
});
let sentiment: AnalysisResult["sentiment"];
let confidence: number;
if (positiveScore > negativeScore) {
sentiment = "positif";
confidence = 0.75 + Math.random() * 0.2;
} else if (negativeScore > positiveScore) {
sentiment = "negatif";
confidence = 0.75 + Math.random() * 0.2;
} else {
sentiment = "netral";
confidence = 0.6 + Math.random() * 0.2;
}
setResult({
sentiment,
confidence,
keywords: foundKeywords,
});
setIsAnalyzing(false);
};
const getSentimentDisplay = (sentiment: AnalysisResult["sentiment"]) => {
const config = {
positif: {
icon: ThumbsUp,
label: "Positif",
bgClass: "bg-sentiment-positive-light",
textClass: "text-sentiment-positive",
borderClass: "border-sentiment-positive/30",
},
negatif: {
icon: ThumbsDown,
label: "Negatif",
bgClass: "bg-sentiment-negative-light",
textClass: "text-sentiment-negative",
borderClass: "border-sentiment-negative/30",
},
netral: {
icon: Minus,
label: "Netral",
bgClass: "bg-sentiment-neutral-light",
textClass: "text-sentiment-neutral",
borderClass: "border-sentiment-neutral/30",
},
};
return config[sentiment];
};
const models = [
{
code: "none",
value: "xgboost",
label: "XGBoost (Baseline)",
desc: "Model 1",
},
{
code: "Grid Search",
value: "xgboost",
label: "XGBoost (Tuned)",
desc: "Model 2",
},
{
code: "recommended",
value: "xgboost",
label: "XGBoost (Fully Optimized)",
desc: "Model 3",
},
];
const filteredItems = useMemo(() => {
if (!searchQuery) return models;
return models.filter(
(model) =>
model.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.code.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
return {
selectedModel,
setSelectedModel,
text,
setText,
laptopName,
setLaptopName,
isAnalyzing,
analyzeText,
result,
getSentimentDisplay,
searchQuery,
setSearchQuery,
filteredItems,
isFormValid,
};
};

50
src/hooks/useStatCard.ts Normal file
View File

@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { StatCardProps } from "../types";
export const useStatCard = ({
title,
value,
suffix = "",
icon: Icon,
trend,
variant = "default",
delay = 0,
}: StatCardProps) => {
const [displayValue, setDisplayValue] = useState(0);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, 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 { isVisible, displayValue };
};

View File

@ -0,0 +1,9 @@
import { useEffect, useState } from "react";
export const useTrendChart = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return { isMounted, setIsMounted };
};

19
src/hooks/useWordCloud.ts Normal file
View File

@ -0,0 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { WordCloudProps } from "../types";
export const useWordCloud = ({ words }: WordCloudProps) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const maxValue = Math.max(...words.map((w) => w.value), 1);
const minValue = Math.min(...words.map((w) => w.value), 0);
const shuffledWords = useMemo(() => {
return [...words].sort(() => Math.random() - 0.5);
}, [words]);
return { mounted, maxValue, minValue, shuffledWords };
};

View File

@ -1,3 +1,6 @@
import { UserGender } from "@prisma/client";
import { LucideIcon } from "lucide-react";
export interface ModelDB {
modelName: string;
description: string;
@ -6,3 +9,104 @@ export interface ModelDB {
f1Negative: number;
f1Neutral: number;
}
export interface ProfileClientProps {
gender?: UserGender;
productReference?: string;
}
interface Brand {
name: string;
count: number;
logo?: string;
}
export interface BrandFilterProps {
brands: Brand[];
selectedBrand: string | null;
onSelect: (brand: string | null) => void;
}
export interface Review {
id: string;
product: string;
brand: string;
review: string;
rating: number;
sentiment: "positif" | "negatif" | "netral";
date: string;
confidence: number;
}
export interface ReviewTableProps {
reviews: Review[];
}
export interface AnalysisResult {
sentiment: "positif" | "negatif" | "netral";
confidence: number;
keywords: string[];
}
interface SentimentData {
name: string;
value: number;
color: string;
}
export interface SentimentChartProps {
data: SentimentData[];
}
export interface CustomTooltipProps {
active?: boolean;
payload?: any[];
total: number;
}
export interface StatCardProps {
title: string;
value: number;
suffix?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
variant?: "default" | "positive" | "negative" | "neutral";
delay?: number;
}
interface TrendData {
date: string;
positif: number;
negatif: number;
netral: number;
}
export interface TrendChartProps {
data: TrendData[];
}
export interface TrendChartTooltipProps {
active?: boolean;
payload?: any[];
label?: string;
}
export interface WordItem {
text: string;
value: number;
sentiment: "positive" | "negative" | "neutral";
}
export interface WordCloudProps {
words: WordItem[];
}
export interface WordCloudItemProps {
word: WordItem;
index: number;
maxValue: number;
minValue: number;
}

25
src/utils/dImports.ts Normal file
View File

@ -0,0 +1,25 @@
import dynamic from "next/dynamic";
const TrendChart = dynamic(
() =>
import("../components/dashboards/TrendChart").then((mod) => ({
default: mod.TrendChart,
})),
{ ssr: false },
);
const WordCloud = dynamic(
() =>
import("../components/dashboards/WordCloud").then((mod) => ({
default: mod.WordCloud,
})),
{ ssr: false },
);
const SentimentChart = dynamic(
() =>
import("../components/dashboards/SentimentChart").then((mod) => ({
default: mod.SentimentChart,
})),
{ ssr: false },
);
export { TrendChart, WordCloud, SentimentChart };

16
src/utils/styleType.ts Normal file
View File

@ -0,0 +1,16 @@
const variantStyles = {
default: "bg-card border-border",
positive: "bg-sentiment-positive-light border-sentiment-positive/20",
negative: "bg-sentiment-negative-light border-sentiment-negative/20",
neutral: "bg-sentiment-neutral-light border-sentiment-neutral/20",
};
const iconStyles = {
default: "bg-primary/10 text-primary",
positive: "bg-sentiment-positive/10 text-sentiment-positive",
negative: "bg-sentiment-negative/10 text-sentiment-negative",
neutral: "bg-sentiment-neutral/10 text-sentiment-neutral",
};
export { variantStyles, iconStyles };