Merge pull request #20 from ATLAS-PJK-GM007/selly/frontend

Selly/frontend
This commit is contained in:
Selly Supriyatin
2026-06-06 03:04:50 +07:00
committed by GitHub
18 changed files with 632 additions and 303 deletions
+24 -36
View File
@@ -5,7 +5,7 @@ import {
Navigate,
} from "react-router-dom";
import { AuthInitializer } from "@/components/auth-initializer";
import { AuthGuard } from "@/components/auth-guard";
// import { AuthGuard } from "@/components/auth-guard";
import { DashboardPage } from "@/pages/dashboard-page";
import { ScanPage } from "@/pages/scan-page";
import { LibraryPage } from "@/pages/library-page";
@@ -14,74 +14,62 @@ import { DiseaseDetailPage } from "@/pages/disease-detail-page";
import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
import { DiagnosesPage } from "@/pages/diagnoses-page";
import { LoginPage } from "@/pages/login-page";
import { RegisterPage } from "@/pages/register-page";
// import { LoginPage } from "@/pages/login-page";
// import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/login" replace /> },
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ path: "/", element: <Navigate to="/dashboard" replace /> },
// { path: "/login", element: <LoginPage /> },
// { path: "/register", element: <RegisterPage /> },
{
path: "/dashboard",
element: (
<AuthGuard>
<MainLayout>
<DashboardPage />
</MainLayout>
</AuthGuard>
<MainLayout>
<DashboardPage />
</MainLayout>
),
},
{
path: "/scan",
element: (
<AuthGuard>
<MainLayout>
<ScanPage />
</MainLayout>
</AuthGuard>
<MainLayout>
<ScanPage />
</MainLayout>
),
},
{
path: "/library",
element: (
<AuthGuard>
<MainLayout>
<LibraryPage />
</MainLayout>
</AuthGuard>
<MainLayout>
<LibraryPage />
</MainLayout>
),
},
{
path: "/diagnoses",
element: (
<AuthGuard>
<MainLayout>
<DiagnosesPage />
</MainLayout>
</AuthGuard>
<MainLayout>
<DiagnosesPage />
</MainLayout>
),
},
{
path: "/diagnoses/:id",
element: (
<AuthGuard>
<MainLayout>
<DiagnosisDetailPage />
</MainLayout>
</AuthGuard>
<MainLayout>
<DiagnosisDetailPage />
</MainLayout>
),
},
{
path: "/expert/reviews",
element: (
<AuthGuard requireExpert>
<MainLayout>
<ExpertReviewsPage />
</MainLayout>
</AuthGuard>
<MainLayout>
<ExpertReviewsPage />
</MainLayout>
),
},
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

@@ -8,7 +8,7 @@ export function MainLayout({ children }: Props) {
return (
<div className="min-h-screen flex flex-col bg-[#ECF4E8]">
<Navbar />
<main className="mx-auto w-full max-w-6xl flex-1 px-6 py-8">
<main className="mx-auto w-full max-w-6xl flex-1 px-4 sm:px-6 py-6 sm:py-8">
{children}
</main>
<Footer />
@@ -0,0 +1,94 @@
import { createPortal } from "react-dom";
import { Link, useLocation } from "react-router-dom";
import { X, Leaf } from "lucide-react";
type Props = {
open: boolean;
onClose: () => void;
};
const NAV_ITEMS = [
{ path: "/dashboard", label: "Dashboard" },
{ path: "/scan", label: "Scan Tanaman" },
{ path: "/diagnoses", label: "Diagnosa" },
{ path: "/catalog", label: "Pustaka", altPath: "/library" },
];
export function MobileNav({ open, onClose }: Props) {
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
const navContent = (
<div
// 2. Ubah z-50 menjadi z-[999] agar levelnya mentok paling atas
className={`fixed inset-0 z-[999] lg:hidden transition-opacity duration-300 ease-in-out ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
aria-hidden={!open}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`fixed inset-y-0 right-0 z-[999] flex w-full max-w-sm flex-col bg-[#306D29] shadow-2xl transition-transform duration-300 ease-in-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 h-20 border-b border-white/10 shrink-0">
<div className="flex items-center gap-3 font-semibold text-white">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#48A111]">
<Link to="/dashboard" onClick={onClose}>
<Leaf className="h-5 w-5" />
</Link>
</div>
<div className="leading-tight">
<div className="text-lg font-bold">ZeaVis Edu</div>
<div className="text-[13px] font-normal text-[#9AD872]">
Smart AI for Corn Disease Detection
</div>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Tutup menu"
>
<X className="h-6 w-6" />
</button>
</div>
{/* Nav items */}
<nav className="flex flex-col gap-2 overflow-y-auto px-4 py-6 flex-1">
{NAV_ITEMS.map((item) => {
const active =
isActive(item.path) || (item.altPath && isActive(item.altPath));
return (
<Link
key={item.path}
to={item.path}
onClick={onClose}
className={`rounded-full px-4 py-3 text-[16px] font-medium transition-colors ${
active
? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white"
}`}
>
{item.label}
</Link>
);
})}
</nav>
</div>
</div>
);
return typeof document !== "undefined"
? createPortal(navContent, document.body)
: navContent;
}
+41 -44
View File
@@ -1,16 +1,12 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Leaf } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/store/auth-store";
import { apiClient } from "@/lib/api-client";
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Leaf, Menu } from "lucide-react";
import { MobileNav } from "./mobile-nav";
export function Navbar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
const user = useAuthStore((state) => state.user);
//
const isActive = (path: string) => location.pathname === path;
@@ -22,17 +18,6 @@ export function Navbar() {
: "text-white/85 hover:bg-white/10 hover:text-white",
].join(" ");
const logoutMutation = useMutation({
mutationFn: async () => {
await apiClient.logout();
},
onSuccess: () => {
setUser(null);
queryClient.clear();
navigate("/login");
},
});
return (
<header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
@@ -50,38 +35,50 @@ export function Navbar() {
</div>
</div>
<nav className="flex items-center gap-2">
<Link to="/dashboard" className={navLinkClassName(isActive("/dashboard"))}>
<nav className="hidden lg:flex items-center gap-2">
<Link
to="/dashboard"
className={navLinkClassName(isActive("/dashboard"))}
>
Dashboard
</Link>
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
Scan
Scan Tanaman
</Link>
<Link to="/diagnoses" className={navLinkClassName(isActive("/diagnoses"))}>
<Link
to="/diagnoses"
className={navLinkClassName(isActive("/diagnoses"))}
>
Diagnosa
</Link>
<Link to="/catalog" className={navLinkClassName(isActive("/catalog") || isActive("/library"))}>
<Link
to="/catalog"
className={navLinkClassName(
isActive("/catalog") || isActive("/library"),
)}
>
Pustaka
</Link>
{user?.role === "expert" && (
<Link
to="/expert/reviews"
className={navLinkClassName(isActive("/expert/reviews"))}
>
Review
</Link>
)}
{user && (
<Button
variant="outline"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
className="ml-2 bg-white/10 border-white/20 text-white hover:bg-white/20"
>
{logoutMutation.isPending ? "Keluar..." : "Keluar"}
</Button>
)}
<Link
to="/expert/reviews"
className={navLinkClassName(isActive("/expert/reviews"))}
>
Review
</Link>
</nav>
<button
onClick={() => setMobileMenuOpen(true)}
className="lg:hidden rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Buka menu navigasi"
>
<Menu className="h-6 w-6" />
</button>
<MobileNav
open={mobileMenuOpen}
onClose={() => setMobileMenuOpen(false)}
/>
</div>
</header>
);
+1 -1
View File
@@ -63,7 +63,7 @@ export function Modal({
aria-modal="true"
tabIndex={-1}
ref={containerRef}
className={`relative z-10 w-full ${sizeClass[size]} rounded-lg bg-white p-6 shadow-lg ${className ?? ""}`}
className={`relative z-10 w-full ${sizeClass[size]} mx-4 max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-lg ${className ?? ""}`}
>
<ModalHeader right={headerRight}>
{title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
+13 -1
View File
@@ -15,6 +15,11 @@ import type {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
export interface ApiError extends Error {
status: number;
source?: 'uploader' | 'model-service' | 'unknown';
}
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${apiBaseUrl}${endpoint}`;
const response = await fetch(url, {
@@ -25,14 +30,21 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
let source: 'uploader' | 'model-service' | 'unknown' | undefined;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
if (errorData.source) {
source = errorData.source;
}
} catch {
}
throw new Error(errorMessage);
const error = new Error(errorMessage) as ApiError;
error.status = response.status;
error.source = source;
throw error;
}
return response.json();
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './app';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
+5 -4
View File
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { AlertCircle } from 'lucide-react';
import type { RiskLevel } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';
import { apiClient } from '@/lib/api-client';
@@ -43,10 +42,12 @@ export function CatalogPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1>
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
<p className="text-gray-500 mt-1 text-md">
Referensi lengkap penyakit dan kondisi daun jagung yang dapat dideteksi oleh sistem AI ZeaVis Edu
</p>
</div>
</div>
+10 -10
View File
@@ -77,13 +77,13 @@ export function DashboardPage() {
style={{ backgroundImage: `url(${bg})` }}
>
<div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-10">
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-6 md:p-10">
<div className="space-y-3 w-full md:w-2/3 text-white">
<span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold">
AI FOR SMART EDUCATION
</span>
<h1 className="text-4xl font-extrabold">Selamat Datang di</h1>
<h2 className="text-4xl font-extrabold tracking-tight text-[#9AD872]">
<h1 className="text-2xl md:text-4xl font-extrabold">Selamat Datang di</h1>
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tight text-[#9AD872]">
ZeaVis Edu
</h2>
<p className="mt-3 max-w-xl text-white/90">
@@ -91,11 +91,11 @@ export function DashboardPage() {
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
akurat.
</p>
<div className="mt-6 flex items-center gap-4">
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:gap-4">
<Button
asChild
variant="outline"
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-6 py-6 text-lg font-semibold text-white"
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
@@ -105,7 +105,7 @@ export function DashboardPage() {
<Button
asChild
variant="outline"
className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
className="px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white hover:bg-[#1E8A2A]"
>
<Link
to="/catalog"
@@ -149,7 +149,7 @@ export function DashboardPage() {
}
>
<div className="md:col-span-4 text-xl font-bold">
<h3 className="text-[24px] font-extrabold text-[#214B11]">
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Proyek Urgensi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
@@ -231,7 +231,7 @@ export function DashboardPage() {
{/* Mission section */}
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<div className="space-y-1">
<h3 className="text-[24px] font-extrabold text-[#214B11]">
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Misi Platform
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
@@ -272,7 +272,7 @@ export function DashboardPage() {
<section className="space-y-4">
<div className="mb-2 flex items-start justify-between">
<div>
<h3 className="text-[24px] font-extrabold text-[#214B11]">
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Penyakit yang Dapat Dideteksi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
@@ -322,7 +322,7 @@ export function DashboardPage() {
</section>
{/* Scan quick access */}
<div className="mt-12 flex items-center gap-6 bg-[#1E8A2A] rounded-3xl p-6">
<div className="mt-12 flex flex-col sm:flex-row items-center gap-4 sm:gap-6 bg-[#1E8A2A] rounded-3xl p-5 sm:p-6">
<div className="flex-1 text-white">
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
<p className="text-sm text-[#9AD872] font-normal mt-2">
+61 -65
View File
@@ -75,10 +75,10 @@ export function DiagnosesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-primary">
Manajemen Diagnosis
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
<p className="text-gray-500 mt-1 text-md">
Lihat hasil diagnosa dari scan yang telah dilakukan
</p>
<h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
</div>
<div className="flex items-center gap-3">
<Button asChild variant="outline">
@@ -87,72 +87,70 @@ export function DiagnosesPage() {
</div>
</div>
<Card>
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label>
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label>
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<label className="text-sm font-medium">Risiko</label>
<select
value={riskFilter}
onChange={(e) => handleRiskChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{RISK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<label className="text-sm font-medium">Risiko</label>
<select
value={riskFilter}
onChange={(e) => handleRiskChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{RISK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div className="ml-auto text-sm text-muted-foreground">
Total: {filtered.length}
</div>
</div>
<div className="w-full sm:w-auto sm:ml-auto text-sm text-muted-foreground">
Total: {filtered.length}
</div>
</div>
{query.isLoading ? (
<div className="py-12 text-center text-muted-foreground">
Memuat diagnosis...
</div>
) : query.isError ? (
<div className="py-12 text-center text-red-600">
Gagal memuat diagnosis
</div>
) : filtered.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Tidak ada diagnosis sesuai filter
</p>
{diagnoses.length === 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Mulai dengan{" "}
<Link to="/scan" className="text-primary underline">
melakukan scan daun
</Link>
.
</p>
)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{filtered.map((d) => (
<Card key={d.id} className="h-full">
<CardContent className="flex gap-4 p-4 items-start">
{query.isLoading ? (
<div className="py-12 text-center text-muted-foreground">
Memuat diagnosis...
</div>
) : query.isError ? (
<div className="py-12 text-center text-red-600">
Gagal memuat diagnosis
</div>
) : filtered.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Tidak ada diagnosis sesuai filter
</p>
{diagnoses.length === 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Mulai dengan{" "}
<Link to="/scan" className="text-primary underline">
melakukan scan daun
</Link>
.
</p>
)}
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{filtered.map((d) => (
<Card key={d.id} className="h-full">
<CardContent className="flex gap-4 p-4 items-start">
<img
src={d.imageUrl}
alt="Daun"
className="h-24 w-24 rounded-md object-cover bg-muted"
className="h-16 md:h-24 w-16 md:w-24 rounded-md object-cover bg-muted shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
@@ -192,8 +190,6 @@ export function DiagnosesPage() {
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+8 -10
View File
@@ -14,23 +14,22 @@ export function DiagnosisDetailPage() {
enabled: Boolean(id),
});
if (query.isLoading) return <main className="p-8 text-center text-muted-foreground">Memuat diagnosis...</main>;
if (query.error || !query.data) return <main className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</main>;
if (query.isLoading) return <div className="p-8 text-center text-muted-foreground">Memuat diagnosis...</div>;
if (query.error || !query.data) return <div className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</div>;
const diagnosis = query.data;
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-primary">Detail Diagnosis</p>
<h1 className="text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
<h1 className="text-xl md:text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
</div>
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
</div>
<section className="grid gap-6 lg:grid-cols-[360px_1fr]">
<section className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="p-4">
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
@@ -99,6 +98,5 @@ export function DiagnosisDetailPage() {
</Card>
)}
</div>
</main>
);
}
);
}
+2 -2
View File
@@ -78,7 +78,7 @@ export function DiseaseDetailPage() {
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-4xl font-bold tracking-tight">{disease.commonName}</h1>
<h1 className="text-2xl md:text-4xl font-bold tracking-tight">{disease.commonName}</h1>
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
</div>
<RiskBadge level={disease.riskLevel} />
@@ -131,7 +131,7 @@ export function DiseaseDetailPage() {
</CardContent>
</Card>
<div className="flex gap-3 pt-4">
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<Button asChild className="flex-1">
<Link to="/catalog">Lihat Katalog Lengkap</Link>
</Button>
+126 -57
View File
@@ -1,24 +1,35 @@
import { FormEvent, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
import type { DiseaseSlug } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
import { apiClient } from '@/lib/api-client';
import { FormEvent, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DiseaseSlug } from "@zeavis/shared";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { apiClient } from "@/lib/api-client";
export function ExpertReviewsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('diagnosis');
const [verdict, setVerdict] = useState<'verified' | 'corrected'>('verified');
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<DiseaseSlug | ''>('');
const [notes, setNotes] = useState('');
const selectedId = searchParams.get("diagnosis");
const [verdict, setVerdict] = useState<"verified" | "corrected">("verified");
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<
DiseaseSlug | ""
>("");
const [notes, setNotes] = useState("");
const queryClient = useQueryClient();
const [reviewsQuery, diseasesQuery] = useQueries({
queries: [
{ queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() },
{ queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() },
{
queryKey: ["expert-reviews"],
queryFn: () => apiClient.getExpertReviews(),
},
{ queryKey: ["diseases"], queryFn: () => apiClient.getDiseases() },
],
});
@@ -33,15 +44,18 @@ export function ExpertReviewsPage() {
mutationFn: () =>
apiClient.reviewDiagnosis(selected!.id, {
verdict,
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined,
correctedDiseaseSlug:
verdict === "corrected"
? correctedDiseaseSlug || undefined
: undefined,
notes,
}),
onSuccess: () => {
setNotes('');
setVerdict('verified');
setCorrectedDiseaseSlug('');
queryClient.invalidateQueries({ queryKey: ['expert-reviews'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
setNotes("");
setVerdict("verified");
setCorrectedDiseaseSlug("");
queryClient.invalidateQueries({ queryKey: ["expert-reviews"] });
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
},
});
@@ -55,10 +69,13 @@ export function ExpertReviewsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-primary">Review Pakar</p>
<h1 className="text-3xl font-bold">Diagnosis Menunggu Review</h1>
<h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
<p className="text-gray-500 mt-1 text-md">
Tinjau hasil diagnosa dari scan yang telah dilakukan dan berikan
feedback untuk meningkatkan akurasi sistem AI ZeaVis Edu
</p>
</div>
</div>
@@ -67,14 +84,20 @@ export function ExpertReviewsPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Daftar Diagnosis</h2>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
{reviews.length}
</span>
</div>
<div className="space-y-2">
{isLoading ? (
<p className="text-sm text-muted-foreground">Memuat diagnosis...</p>
<p className="text-sm text-muted-foreground">
Memuat diagnosis...
</p>
) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">Tidak ada diagnosis yang menunggu review</p>
<p className="text-sm text-muted-foreground">
Tidak ada diagnosis yang menunggu review
</p>
) : (
reviews.map((review) => (
<button
@@ -83,26 +106,35 @@ export function ExpertReviewsPage() {
onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${
selected?.id === review.id
? 'ring-2 ring-primary'
: 'hover:border-primary'
? "ring-2 ring-primary"
: "hover:border-primary"
}`}
>
<Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3">
<CardContent className="flex gap-3 p-3 items-start">
<img
src={review.imageUrl}
alt="Daun jagung"
className="h-20 w-20 rounded-lg object-cover"
className="h-14 md:h-20 w-14 md:w-20 rounded-lg object-cover shrink-0"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3>
<DiagnosisStatusBadge status={review.status} className="text-xs" />
<h3 className="text-sm font-semibold">
{review.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<DiagnosisStatusBadge
status={review.status}
className="text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
{review.confidence === null ? 'Tidak ada confidence' : `${(review.confidence * 100).toFixed(1)}%`}
{review.confidence === null
? "Tidak ada confidence"
: `${(review.confidence * 100).toFixed(1)}%`}
</p>
<p className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleString("id-ID")}
</p>
<p className="text-xs text-muted-foreground">{new Date(review.createdAt).toLocaleString('id-ID')}</p>
</div>
</CardContent>
</Card>
@@ -125,16 +157,18 @@ export function ExpertReviewsPage() {
{/* Detail card */}
<Card>
<CardHeader>
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
<CardTitle>
{selected.disease?.commonName ?? "Diagnosis gagal"}
</CardTitle>
<CardDescription>
{new Date(selected.createdAt).toLocaleString('id-ID')}
{new Date(selected.createdAt).toLocaleString("id-ID")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<img
src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`}
className="h-64 w-full rounded-lg object-cover"
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? "diagnosis"}`}
className="h-48 md:h-64 w-full rounded-lg object-cover"
/>
<div className="space-y-2">
@@ -145,7 +179,9 @@ export function ExpertReviewsPage() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span>
<span className="text-sm">
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`}
{selected.confidence === null
? "N/A"
: `${(selected.confidence * 100).toFixed(1)}%`}
</span>
</div>
</div>
@@ -155,9 +191,16 @@ export function ExpertReviewsPage() {
<h4 className="text-sm font-medium">Prediksi Teratas</h4>
<div className="space-y-1">
{selected.predictions.map((pred) => (
<div key={pred.id} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{pred.modelLabel}</span>
<span className="font-medium">{(pred.confidence * 100).toFixed(1)}%</span>
<div
key={pred.id}
className="flex items-center justify-between text-sm"
>
<span className="text-muted-foreground">
{pred.modelLabel}
</span>
<span className="font-medium">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div>
))}
</div>
@@ -175,30 +218,42 @@ export function ExpertReviewsPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Keputusan</label>
<div className="flex gap-3">
<label htmlFor="verdict-verified" className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row gap-3">
<label
htmlFor="verdict-verified"
className="flex items-center gap-2"
>
<input
id="verdict-verified"
type="radio"
name="verdict"
value="verified"
checked={verdict === 'verified'}
checked={verdict === "verified"}
onChange={(e) => {
setVerdict(e.target.value as 'verified' | 'corrected');
setCorrectedDiseaseSlug('');
setVerdict(
e.target.value as "verified" | "corrected",
);
setCorrectedDiseaseSlug("");
}}
className="h-4 w-4"
/>
<span className="text-sm">Terverifikasi</span>
</label>
<label htmlFor="verdict-corrected" className="flex items-center gap-2">
<label
htmlFor="verdict-corrected"
className="flex items-center gap-2"
>
<input
id="verdict-corrected"
type="radio"
name="verdict"
value="corrected"
checked={verdict === 'corrected'}
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
checked={verdict === "corrected"}
onChange={(e) =>
setVerdict(
e.target.value as "verified" | "corrected",
)
}
className="h-4 w-4"
/>
<span className="text-sm">Dikoreksi</span>
@@ -206,16 +261,23 @@ export function ExpertReviewsPage() {
</div>
</div>
{verdict === 'corrected' && (
{verdict === "corrected" && (
<div className="space-y-2">
<label htmlFor="disease" className="text-sm font-medium">
<label
htmlFor="disease"
className="text-sm font-medium"
>
Penyakit yang Benar
</label>
<select
id="disease"
value={correctedDiseaseSlug}
onChange={(e) => setCorrectedDiseaseSlug(e.target.value as DiseaseSlug | '')}
required={verdict === 'corrected'}
onChange={(e) =>
setCorrectedDiseaseSlug(
e.target.value as DiseaseSlug | "",
)
}
required={verdict === "corrected"}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="">Pilih penyakit...</option>
@@ -245,15 +307,22 @@ export function ExpertReviewsPage() {
<Button
type="submit"
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
disabled={
mutation.isPending ||
(verdict === "corrected" && !correctedDiseaseSlug) ||
!notes.trim()
}
className="w-full"
>
{mutation.isPending ? 'Mengirim...' : 'Kirim Review'}
{mutation.isPending ? "Mengirim..." : "Kirim Review"}
</Button>
{mutation.isError && (
<p className="text-sm text-red-500">
Error: {mutation.error instanceof Error ? mutation.error.message : 'Terjadi kesalahan'}
Error:{" "}
{mutation.error instanceof Error
? mutation.error.message
: "Terjadi kesalahan"}
</p>
)}
</form>
+5 -3
View File
@@ -26,10 +26,12 @@ export function LibraryPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div>
<p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
<h1 className="text-3xl font-bold">Pustaka Penyakit</h1>
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
<p className="text-gray-500 mt-1 text-md">
Referensi lengkap penyakit daun jagung yang dapat dideteksi oleh sistem AI
</p>
</div>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ export function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full space-y-4">
<div className="w-full max-w-sm md:max-w-md space-y-4">
<AuthForm
mode="login"
isSubmitting={mutation.isPending}
+1 -1
View File
@@ -28,7 +28,7 @@ export function RegisterPage() {
return (
<main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full space-y-4">
<div className="w-full max-w-sm md:max-w-md space-y-4">
<AuthForm
mode="register"
isSubmitting={mutation.isPending}
+238 -66
View File
@@ -1,7 +1,17 @@
import { useRef, useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { Camera, Upload, X, CheckCircle, ImageOff } from "lucide-react";
import {
Camera,
Upload,
X,
ImageOff,
CircleAlert,
ZoomIn,
Sun,
AlignCenter,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
@@ -12,7 +22,9 @@ import { apiClient } from "@/lib/api-client";
export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -35,7 +47,15 @@ export function ScanPage() {
return;
}
setFileName(f.name);
setPreviewUrl(URL.createObjectURL(f));
const url = URL.createObjectURL(f);
setPreviewUrl(url);
// Detect image dimensions
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
};
img.src = url;
};
const handleUpload = () => {
@@ -56,10 +76,13 @@ export function ScanPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div>
<p className="text-sm font-medium text-primary">Deteksi Penyakit</p>
<h1 className="text-3xl font-bold">Scan Tanaman</h1>
<h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1>
<p className="text-gray-500 mt-1 text-md">
Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara
real-time.
</p>
</div>
<Button asChild variant="outline">
<Link to="/diagnoses">Riwayat Diagnosis</Link>
@@ -68,37 +91,103 @@ export function ScanPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Card>
<CardContent className="p-6">
<Card className="w-full lg:h-117 py-3">
<CardContent className="px-6 py-4 h-full flex flex-col">
{/* Left Sidebar */}
<div className="text-black flex items-center gap-2 mb-3 text-lg font-semibold">
<Camera className="text-green-500" size={25} />
Area Unggah Gambar
</div>
{/* Upload area */}
{!previewUrl ? (
<button
type="button"
className="w-full border-2 border-dashed border-green-300 rounded-xl p-12 text-center cursor-pointer hover:border-green-500 hover:bg-green-50/50 transition-colors group"
onClick={() => inputRef.current?.click()}
>
<Camera className="mx-auto h-12 w-12 text-green-400 group-hover:text-green-500 transition-colors" />
<div className="mt-4 text-green-600 text-lg font-medium">
Unggah Gambar Daun
<div className="space-y-3">
<div
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
onClick={() => inputRef.current?.click()}
>
<Upload className="mx-auto text-green-500 mb-3" size={48} />
<h3 className="font-semibold text-base text-gray-800 mb-1">
Seret & Lepas Foto Daun
</h3>
<p className="text-xs text-gray-500 mb-3">
atau klik untuk memilih file berkas dari perangkat Anda
</p>
<div className="flex flex-wrap gap-2 justify-center">
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
<Check size={14} />
PNG, JPG, JPEG
</div>
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
<Check size={14} />
Maks. 5 MB
</div>
</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
Seret & Lepas atau klik untuk memilih file (PNG/JPG, maks 5MB)
</div>
</button>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Upload size={18} />
Pilih Berkas
</button>
</div>
) : (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-muted">
<div className="relative rounded-xl overflow-hidden bg-muted w-full">
<img
ref={imageRef}
src={previewUrl}
alt="Preview"
className="w-full max-h-80 object-contain"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
}}
/>
{/* Scanner area detection overlay */}
{imageDimensions && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{/* Outer dark overlay */}
<div className="absolute inset-0 bg-black/20" />
{/* Scan area frame - responsive to image */}
<div
className="relative flex items-center justify-center"
style={{
width: `${Math.min(imageDimensions.width * 0.7, 280)}px`,
height: `${Math.min(imageDimensions.height * 0.7, 320)}px`,
}}
>
{/* Corner markers */}
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-lime-300" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-lime-300" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-lime-300" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-lime-300" />
{/* Center text */}
<div className="text-white text-center flex flex-col gap-1">
<span className="text-xs font-semibold tracking-widest">AREA SCAN</span>
<span className="text-[10px] text-lime-200 font-medium">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
</div>
</div>
)}
<button
type="button"
className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors"
className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors z-10"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
setImageDimensions(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
@@ -109,12 +198,13 @@ export function ScanPage() {
<p className="text-sm text-muted-foreground">
File: <span className="font-medium">{fileName}</span>
</p>
<div className="flex gap-3">
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
setImageDimensions(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
@@ -157,28 +247,112 @@ export function ScanPage() {
</Card>
</div>
<aside className="space-y-4">
<Card>
<CardContent className="p-4">
<h2 className="font-medium mb-3">Panduan Pengambilan Foto</h2>
<ul className="text-sm space-y-3 text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
Jarak 1530 cm dari daun
{/* Right Sidebar */}
<aside className="lg:col-span-1 flex flex-col gap-5">
{/* Card 1: Panduan Pengambilan Foto */}
<Card className="w-full h-max">
<CardContent className="px-6 py-4">
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-full">
<CircleAlert className="text-emerald-600" size={20} />
</div>
<h2 className="font-bold text-emerald-800">
Panduan Pengambilan Foto
</h2>
</div>
<ul className="space-y-3">
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<ZoomIn className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Jarak 1530 cm dari daun
</div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
Pencahayaan cukup, hindari blur
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<Sun className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Pencahayaan cukup merata
</div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
Daun memenuhi bingkai
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<AlignCenter className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Posisi daun mengisi bingkai dengan jelas
</div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
Hindari bayangan dan background berantakan
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<Camera className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Hindari bayangan atau pantulan pada daun & blur pada gambar
</div>
</li>
</ul>
<div className="mt-4">
<div className="rounded-lg overflow-hidden mt-4">
<div className="relative h-36 overflow-hidden bg-[url('/src/assets/images/scan-guide-bg.png')] bg-cover bg-center text-white md:h-44">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-0 bg-linear-to-b from-black/5 via-transparent to-black/35" />
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-5 text-center">
<div className="relative flex h-full w-30 items-center justify-center mt-8">
<span className="absolute left-0 top-0 h-4 w-4 border-l-2 border-t-2 border-l-lime-300 border-t-lime-300" />
<span className="absolute right-0 top-0 h-4 w-4 border-r-2 border-t-2 border-r-lime-300 border-t-lime-300" />
<span className="absolute bottom-0 left-0 h-4 w-4 border-b-2 border-l-2 border-b-lime-300 border-l-lime-300" />
<span className="absolute bottom-0 right-0 h-4 w-4 border-b-2 border-r-2 border-b-lime-300 border-r-lime-300" />
<span className="relative z-10 text-xs font-semibold tracking-[0.28em] text-white">
AREA SCAN
</span>
</div>
<div className="text-[11px] font-medium text-lime-100 bg-black/30 w-full py-1 rounded">
Posisi ideal: daun memenuhi bingkai
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Card 2: Persyaratan Berkas */}
<Card className="w-full h-max">
<CardContent className="px-6 py-4">
<h3 className="font-bold text-emerald-800 flex items-center gap-3 mb-4">
Persyaratan Berkas
</h3>
<div className="flex flex-col gap-2">
{[
{ label: "Format", value: "PNG, JPG, JPEG" },
{ label: "Ukuran Maks.", value: "5 MB" },
{ label: "Resolusi Min.", value: "512 × 512 px" },
{ label: "Objek Foto", value: "Daun jagung tunggal" },
].map((r, i) => (
<div
key={i}
className="flex items-center justify-between py-1.5"
style={{
borderBottom: i < 3 ? "1px solid #f0f0f0" : "none",
}}
>
<span
className="text-gray-400"
style={{ fontSize: "0.75rem" }}
>
{r.label}
</span>
<span className="text-sm font-medium text-gray-700">
{r.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</aside>
@@ -275,36 +449,34 @@ export function ScanPage() {
)}
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
<div className="space-y-2">
{(diagnosesQuery.data ?? [])
.slice(0, 5)
.map((d) => (
<div
key={d.id}
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
>
<div className="flex items-center gap-3">
<img
src={d.imageUrl}
alt="thumb"
className="h-10 w-10 rounded object-cover bg-muted"
/>
<div className="text-sm">
<div className="font-medium">
{d.disease?.commonName ?? "Unknown"}
</div>
<div className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
{(diagnosesQuery.data ?? []).slice(0, 5).map((d) => (
<div
key={d.id}
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
>
<div className="flex items-center gap-3">
<img
src={d.imageUrl}
alt="thumb"
className="h-10 w-10 rounded object-cover bg-muted"
/>
<div className="text-sm">
<div className="font-medium">
{d.disease?.commonName ?? "Unknown"}
</div>
<div className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div>
))}
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div>
))}
</div>
)}
</div>