fix(frontend): align pages with DB schema, fix auth, and improve UX

- Fix auth system: remove redirect bugs in login/register pages, register
  routes, wrap protected routes with AuthGuard, add AuthInitializer
- Add missing navbar links (Diagnosa, Pustaka, expert Review)
- Fix DiagnosesPage status filter to match DB DiagnosisStatus enum,
  support URL params (?status=, ?risk=) from dashboard links
- Replace hardcoded disease data on Dashboard with API-driven data,
  fix layout bugs (invalid gap-57 class, empty Button)
- Rewrite LibraryPage to use API instead of mock-diseases.ts
- Indonesian-ize Expert Reviews page labels and text
- Wrap DiagnosisDetailPage with MainLayout for consistent layout
- Improve ScanPage UX: image preview before upload, lucide icons,
  proper disease names in result modal, file size validation
- Delete dead code: mock-diseases.ts, unused form components,
  tailwind.config.ts (Tailwind v4 does not read v3 config)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Asep Haryana Saputra
2026-06-02 07:12:49 +00:00
co-authored by Claude Opus 4.7
parent a28d06cb02
commit ba24cf1a05
15 changed files with 852 additions and 1068 deletions
+46 -29
View File
@@ -4,6 +4,8 @@ import {
RouterProvider, RouterProvider,
Navigate, Navigate,
} from "react-router-dom"; } from "react-router-dom";
import { AuthInitializer } from "@/components/auth-initializer";
import { AuthGuard } from "@/components/auth-guard";
import { DashboardPage } from "@/pages/dashboard-page"; import { DashboardPage } from "@/pages/dashboard-page";
import { ScanPage } from "@/pages/scan-page"; import { ScanPage } from "@/pages/scan-page";
import { LibraryPage } from "@/pages/library-page"; import { LibraryPage } from "@/pages/library-page";
@@ -12,59 +14,73 @@ import { DiseaseDetailPage } from "@/pages/disease-detail-page";
import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page"; import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
import { ExpertReviewsPage } from "@/pages/expert-reviews-page"; import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
import { DiagnosesPage } from "@/pages/diagnoses-page"; import { DiagnosesPage } from "@/pages/diagnoses-page";
import { LoginPage } from "@/pages/login-page";
import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout"; import { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/login" replace /> },
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ {
path: "/", path: "/dashboard",
element: <Navigate to="/dashboard" replace />, element: (
}, <AuthGuard>
{ <MainLayout>
path: "/", <DashboardPage />
element: <Navigate to="/dashboard" replace />, </MainLayout>
</AuthGuard>
),
}, },
{ {
path: "/scan", path: "/scan",
element: ( element: (
<MainLayout> <AuthGuard>
<ScanPage /> <MainLayout>
</MainLayout> <ScanPage />
</MainLayout>
</AuthGuard>
), ),
}, },
{ {
path: "/library", path: "/library",
element: ( element: (
<MainLayout> <AuthGuard>
<LibraryPage /> <MainLayout>
</MainLayout> <LibraryPage />
</MainLayout>
</AuthGuard>
), ),
}, },
{
path: "/dashboard",
element: (
<MainLayout>
<DashboardPage />
</MainLayout>
),
},
{
path: "/diagnoses/:id",
element: <DiagnosisDetailPage />,
},
{ {
path: "/diagnoses", path: "/diagnoses",
element: ( element: (
<MainLayout> <AuthGuard>
<DiagnosesPage /> <MainLayout>
</MainLayout> <DiagnosesPage />
</MainLayout>
</AuthGuard>
),
},
{
path: "/diagnoses/:id",
element: (
<AuthGuard>
<MainLayout>
<DiagnosisDetailPage />
</MainLayout>
</AuthGuard>
), ),
}, },
{ {
path: "/expert/reviews", path: "/expert/reviews",
element: <ExpertReviewsPage />, element: (
<AuthGuard requireExpert>
<ExpertReviewsPage />
</AuthGuard>
),
}, },
{ path: "/catalog", element: <CatalogPage /> }, { path: "/catalog", element: <CatalogPage /> },
{ path: "/catalog/:slug", element: <DiseaseDetailPage /> }, { path: "/catalog/:slug", element: <DiseaseDetailPage /> },
@@ -73,6 +89,7 @@ const router = createBrowserRouter([
export function App() { export function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthInitializer />
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
); );
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
export function AuthInitializer() {
const setUser = useAuthStore((state) => state.setUser);
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(),
retry: false,
});
useEffect(() => {
if (query.data) {
setUser(query.data.user);
}
}, [query.data, setUser]);
return null;
}
@@ -1,138 +0,0 @@
import { ChangeEvent, FormEvent, useRef, useState, useEffect } from 'react';
import type { DiagnosisRecord } from '@zeavis/shared';
import { Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
type ImageClassificationFormProps = {
onSubmit: (file: File) => Promise<void>;
isSubmitting: boolean;
latestResult: DiagnosisRecord | null;
};
export function ImageClassificationForm({
onSubmit,
isSubmitting,
latestResult,
}: ImageClassificationFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const previewUrlRef = useRef<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Revoke object URL on component unmount
useEffect(() => {
return () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
};
}, []);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const selectedFile = event.target.files?.[0] ?? null;
setError(null);
setFile(selectedFile);
// Revoke previous preview URL before replacing it
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
const newUrl = selectedFile ? URL.createObjectURL(selectedFile) : null;
previewUrlRef.current = newUrl;
setPreviewUrl(newUrl);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file) {
setError('Pilih gambar terlebih dahulu');
return;
}
try {
await onSubmit(file);
// Revoke current preview URL after successful submit before setting null
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setFile(null);
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Gagal mengirim gambar');
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> Diagnosis Gambar
</CardTitle>
<CardDescription>
Upload gambar daun jagung untuk klasifikasi AI dan review pakar jika confidence rendah.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="image-file" className="block text-sm font-medium">
Pilih Gambar
</label>
<input
ref={fileInputRef}
id="image-file"
type="file"
accept="image/jpeg,image/png"
onChange={handleFileChange}
disabled={isSubmitting}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50"
/>
{file && (
<p className="mt-2 text-sm text-muted-foreground">
File dipilih: {file.name}
</p>
)}
</div>
{previewUrl && (
<img src={previewUrl} alt="Pratinjau gambar daun jagung untuk diagnosis" className="h-48 rounded-lg object-cover" />
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? 'Memproses...' : 'Upload dan Diagnosis'}
</Button>
</form>
{latestResult && (
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="font-semibold">
{latestResult.disease?.commonName ?? 'Diagnosis gagal'}
</h3>
<DiagnosisStatusBadge status={latestResult.status} />
</div>
<p className="text-sm text-muted-foreground">
{latestResult.confidence === null
? 'Tidak ada confidence'
: `Confidence ${(latestResult.confidence * 100).toFixed(1)}%`}
</p>
</div>
)}
</CardContent>
</Card>
);
}
+28 -24
View File
@@ -10,14 +10,14 @@ export function Navbar() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const isDashboard = location.pathname === "/dashboard"; const user = useAuthStore((state) => state.user);
const isScan = location.pathname === "/scan";
const isLibrary = location.pathname === "/library";
const navLinkClassName = (isActive: boolean) => const isActive = (path: string) => location.pathname === path;
const navLinkClassName = (active: boolean) =>
[ [
"inline-flex items-center rounded-full px-4 py-2 text-[18px] font-medium transition-colors", "inline-flex items-center rounded-full px-4 py-2 text-[16px] font-medium transition-colors",
isActive active
? "bg-[#48A111] text-white shadow-sm" ? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white", : "text-white/85 hover:bg-white/10 hover:text-white",
].join(" "); ].join(" ");
@@ -29,12 +29,10 @@ export function Navbar() {
onSuccess: () => { onSuccess: () => {
setUser(null); setUser(null);
queryClient.clear(); queryClient.clear();
navigate("/"); navigate("/login");
}, },
}); });
const user = useAuthStore((state) => state.user);
return ( return (
<header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur"> <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"> <div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
@@ -52,27 +50,33 @@ export function Navbar() {
</div> </div>
</div> </div>
<nav className="flex items-center gap-3"> <nav className="flex items-center gap-2">
<Button asChild variant="ghost" className="rounded-full"> <Link to="/dashboard" className={navLinkClassName(isActive("/dashboard"))}>
<Link to="/dashboard" className={navLinkClassName(isDashboard)}> Dashboard
Dashboard </Link>
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
Scan
</Link>
<Link to="/diagnoses" className={navLinkClassName(isActive("/diagnoses"))}>
Diagnosa
</Link>
<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> </Link>
</Button> )}
<Button asChild variant="ghost" className="rounded-full">
<Link to="/scan" className={navLinkClassName(isScan)}>
Scan Tanaman
</Link>
</Button>
<Button asChild variant="ghost" className="rounded-full">
<Link to="/library" className={navLinkClassName(isLibrary)}>
Pustaka Penyakit
</Link>
</Button>
{user && ( {user && (
<Button <Button
variant="outline" variant="outline"
onClick={() => logoutMutation.mutate()} onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending} disabled={logoutMutation.isPending}
className="ml-2 bg-white/10 border-white/20 text-white hover:bg-white/20"
> >
{logoutMutation.isPending ? "Keluar..." : "Keluar"} {logoutMutation.isPending ? "Keluar..." : "Keluar"}
</Button> </Button>
@@ -1,132 +0,0 @@
import { useEffect, useState } from 'react';
import type { DiseaseSlug, DiseaseCatalogItem, ManualClassificationRequest } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export interface ManualClassificationFormProps {
diseases: DiseaseCatalogItem[];
onSubmit: (payload: ManualClassificationRequest) => Promise<void>;
isSubmitting: boolean;
}
export function ManualClassificationForm({
diseases,
onSubmit,
isSubmitting,
}: ManualClassificationFormProps) {
const [selectedSlug, setSelectedSlug] = useState<DiseaseSlug | ''>('');
const [observation, setObservation] = useState('');
const [location, setLocation] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (diseases.length > 0 && !selectedSlug) {
setSelectedSlug(diseases[0].slug);
}
}, [diseases, selectedSlug]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!selectedSlug || !observation.trim() || !location.trim()) {
setError('Semua field harus diisi');
return;
}
try {
await onSubmit({
diseaseSlug: selectedSlug,
observation: observation.trim(),
location: location.trim(),
});
setObservation('');
setLocation('');
if (diseases.length > 0) {
setSelectedSlug(diseases[0].slug);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengirim data');
}
};
const isFormValid = selectedSlug && observation.trim() && location.trim();
return (
<Card>
<CardHeader>
<CardTitle>Klasifikasi Manual</CardTitle>
<CardDescription>Laporkan penyakit daun jagung yang Anda temukan</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">{error}</div>
)}
<div>
<label htmlFor="disease" className="block text-sm font-medium">
Jenis Penyakit
</label>
<select
id="disease"
value={selectedSlug}
onChange={(e) => setSelectedSlug(e.target.value as DiseaseSlug)}
disabled={diseases.length === 0 || isSubmitting}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
>
{diseases.length === 0 ? (
<option value="">Memuat penyakit...</option>
) : (
diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName} ({disease.label})
</option>
))
)}
</select>
</div>
<div>
<label htmlFor="observation" className="block text-sm font-medium">
Pengamatan
</label>
<textarea
id="observation"
value={observation}
onChange={(e) => setObservation(e.target.value)}
placeholder="Jelaskan gejala atau kondisi daun yang Anda amati..."
disabled={isSubmitting}
rows={4}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium">
Lokasi
</label>
<input
id="location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Lokasi penemuan penyakit (desa, kecamatan, kabupaten)"
disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<Button
type="submit"
disabled={!isFormValid || isSubmitting || diseases.length === 0}
className="w-full"
>
{isSubmitting ? 'Mengirim...' : 'Kirim Laporan'}
</Button>
</form>
</CardContent>
</Card>
);
}
-61
View File
@@ -1,61 +0,0 @@
export const mockDiseases = [
{
id: "nlb",
name: "Hawar Daun",
slug: "hawar-daun",
severity: "Tinggi",
imageUrl: "https://via.placeholder.com/320x180?text=Hawar+Daun",
pathogen: "Exserohilum turcicum",
description:
"Penyakit jamur yang menyebabkan lesi pada daun dan dapat menurunkan hasil panen.",
symptoms: [
"Lesi lonjong berbentuk cerutu, 2.5 - 15 cm",
"Warna abu-kehijauan berkembang menjadi coklat -abu",
"Nekrosis parah pada seluruh permukaan daun",
],
prevention:
"Gunakan varietas tahan, rotasi tanaman, dan hapus sisa tanaman terinfeksi.",
},
{
id: "rust",
name: "Karat Daun",
slug: "karat-daun",
imageUrl: "https://via.placeholder.com/320x180?text=Karat+Daun",
pathogen: "Puccinia spp.",
severity: "Sedang",
description:
"Pustulan oranye pada permukaan daun yang dapat mengurangi fotosintesis.",
symptoms: [
"Pustula oranye pada permukaan daun",
"Daun menguning dan rontok pada serangan berat",
],
prevention:
"Hindari kelembapan tinggi, gunakan fungisida bila perlu, dan perbaiki sirkulasi udara.",
},
{
id: "spot",
name: "Bercak Abu-abu",
slug: "bercak-abu-abu",
imageUrl: "https://via.placeholder.com/320x180?text=Bercak+Abu-abu",
pathogen: "Cercospora spp.",
severity: "Rendah",
description:
"Bercak kecil berwarna abu-abu yang umumnya tidak menyebabkan kematian tanaman.",
symptoms: [
"Bercak kecil bundar hingga tidak beraturan",
"Daun kering pada area bercak",
],
prevention:
"Praktek sanitasi, buang daun yang berat terinfeksi, dan pemantauan rutin.",
},
{
id: "healthy",
name: "Daun Sehat",
slug: "daun-sehat",
imageUrl: "https://via.placeholder.com/320x180?text=Daun+Sehat",
severity: "Sehat",
description: "Daun tanpa tanda penyakit.",
symptoms: [],
prevention: "-",
},
];
+245 -237
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react"; import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -14,7 +15,24 @@ export function DashboardPage() {
queryFn: () => apiClient.getDashboardSummary(), queryFn: () => apiClient.getDashboardSummary(),
}); });
const diseasesQuery = useQuery({
queryKey: ["diseases"],
queryFn: () => apiClient.getDiseases(),
});
const summary = summaryQuery.data; const summary = summaryQuery.data;
const diseases = diseasesQuery.data ?? [];
const diseasesQuick = useMemo(() =>
diseases
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((d) => ({
name: d.commonName,
sci: d.label,
color: d.accentColor,
})),
[diseases]
);
const missionCards = [ const missionCards = [
{ {
@@ -47,230 +65,227 @@ export function DashboardPage() {
}, },
]; ];
const diseasesQuick = [
{ name: "Hawar Daun", sci: "Northern Leaf Blight", color: "#b91c1c" },
{ name: "Karat Daun", sci: "Common Rust", color: "#d97706" },
{ name: "Bercak Abu-abu", sci: "Gray Leaf Spot", color: "#6b7280" },
{ name: "Daun Sehat", sci: "Healthy", color: "#16a34a" },
];
const isLoadingData = summaryQuery.isLoading; const isLoadingData = summaryQuery.isLoading;
const hasError = Boolean(summaryQuery.error); const hasError = Boolean(summaryQuery.error);
return ( return (
<main className="p-6"> <div className="space-y-8">
<div className="mx-auto max-w-6xl space-y-8"> {/* Hero header */}
{/* Hero header */} <header
<header className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm" style={{ backgroundImage: `url(${bg})` }}
style={{ backgroundImage: `url(${bg})` }} >
> <div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
<div className="absolute inset-0 bg-linear-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-10"> <div className="space-y-3 w-full md:w-2/3 text-white">
<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">
<span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold"> AI FOR SMART EDUCATION
AI FOR SMART EDUCATION </span>
</span> <h1 className="text-4xl font-extrabold">Selamat Datang di</h1>
<h1 className="text-4xl font-extrabold">Selamat Datang di</h1> <h2 className="text-4xl font-extrabold tracking-tight text-[#9AD872]">
<h2 className="text-4xl font-extrabold tracking-tight text-[#9AD872]"> ZeaVis Edu
ZeaVis Edu </h2>
</h2> <p className="mt-3 max-w-xl text-white/90">
<p className="mt-3 max-w-xl text-white/90"> Platform edukasi berbasis AI untuk membantu petani jagung
Platform edukasi berbasis AI untuk membantu petani jagung Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan akurat.
akurat. </p>
</p> <div className="mt-6 flex items-center gap-4">
<div className="mt-6 flex items-center gap-4"> <Button
<Button asChild
asChild variant="outline"
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-6 py-6 text-lg font-semibold text-white"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Scan Daun Jagung
</Link>
</Button>
<Button
asChild
variant="outline"
className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
>
<Link
to="/library"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button>
</div>
</div>
<div className="w-full md:w-1/3" />
</div>
</header>
{/* Loading / Error states */}
{isLoadingData && (
<Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard...
</Card>
)}
{hasError && (
<Card className="p-8 text-center text-red-600">
<div>Gagal memuat data dashboard</div>
<div className="mt-2 text-sm text-red-500">
{String(summaryQuery.error?.message)}
</div>
</Card>
)}
{/* Main dashboard content */}
{!isLoadingData && !hasError && (
<>
{summary && (
<section
className={
dashboardCompact
? "grid gap-4 md:grid-cols-4 items-stretch"
: "grid gap-6 md:grid-cols-4 items-stretch"
}
> >
<div className="md:col-span-4 text-xl font-bold"> <Link to="/scan" className="inline-flex items-center gap-2">
<h3 className="text-[24px] font-extrabold text-[#214B11]"> <Scan className="h-5 w-6" />
Proyek Urgensi Scan Daun Jagung
</h3> </Link>
<p className="text-[15px] font-normal text-muted-foreground"> </Button>
Data ringkasan terbaru dari proyek Anda untuk memantau <Button
perkembangan dan hasil deteksi penyakit daun jagung asChild
</p> variant="outline"
</div> className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
<Card> >
<CardHeader className="pb-2"> <Link
<CardTitle className="text-sm font-medium text-muted-foreground"> to="/catalog"
Total Penyakit className="inline-flex items-center gap-2"
</CardTitle> >
</CardHeader> Pustaka Penyakit
<CardContent className="h-full flex flex-col justify-start pt-2"> <ChevronRight className="h-5 w-6" />
<div className="text-3xl font-bold"> </Link>
{summary.diseaseCount} </Button>
</div> </div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline"> </div>
Lihat daftar <div className="w-full md:w-1/3" />
</Link> </div>
</CardContent> </header>
</Card>
<Card> {/* Loading / Error states */}
<CardHeader className="pb-2"> {isLoadingData && (
<CardTitle className="text-sm font-medium text-muted-foreground"> <Card className="p-8 text-center text-muted-foreground">
Total Diagnosis Memuat data dashboard...
</CardTitle> </Card>
</CardHeader> )}
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
<Card> {hasError && (
<CardHeader className="pb-2"> <Card className="p-8 text-center text-red-600">
<CardTitle className="text-sm font-medium text-muted-foreground"> <div>Gagal memuat data dashboard</div>
Menunggu Review <div className="mt-2 text-sm text-red-500">
</CardTitle> {String(summaryQuery.error?.message)}
</CardHeader> </div>
<CardContent className="h-full flex flex-col justify-start pt-2"> </Card>
<div className="text-3xl font-bold text-amber-600"> )}
{summary.needsReviewCount}
</div>
<Link to="/diagnoses?status=needs_review" className="text-amber-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
<Card> {/* Main dashboard content */}
<CardHeader className="pb-2"> {!isLoadingData && !hasError && (
<CardTitle className="text-sm font-medium text-muted-foreground"> <>
Risiko Tinggi {summary && (
</CardTitle> <section
</CardHeader> className={
<CardContent className="h-full flex flex-col justify-start pt-2"> dashboardCompact
<div className="text-3xl font-bold text-red-600"> ? "grid gap-4 md:grid-cols-4 items-stretch"
{summary.riskDistribution.high} : "grid gap-6 md:grid-cols-4 items-stretch"
</div> }
<Link to="/diagnoses?risk=high" className="text-red-600 ml-auto hover:underline"> >
Lihat daftar <div className="md:col-span-4 text-xl font-bold">
</Link>
</CardContent>
</Card>
</section>
)}
{/* 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-[24px] font-extrabold text-[#214B11]">
Misi Platform Proyek Urgensi
</h3> </h3>
<p className="text-[15px] font-normal text-muted-foreground"> <p className="text-[15px] font-normal text-muted-foreground">
Fitur inti yang kami sediakan untuk mendukung petani jagung Data ringkasan terbaru dari proyek Anda untuk memantau
Indonesia perkembangan dan hasil deteksi penyakit daun jagung
</p> </p>
</div> </div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Penyakit
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.diseaseCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4"> <Card>
{missionCards.map((card) => { <CardHeader className="pb-2">
const Icon = card.icon; <CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosis
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
return ( <Card>
<Card <CardHeader className="pb-2">
key={card.title} <CardTitle className="text-sm font-medium text-muted-foreground">
className="rounded-3xl border-white/70 bg-white/95 shadow-[0_8px_24px_rgba(16,24,40,0.08)] h-full" Menunggu Review
> </CardTitle>
<CardContent className="space-y-5 p-6 h-full flex flex-col justify-between"> </CardHeader>
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#EFF6E8]"> <CardContent className="h-full flex flex-col justify-start pt-2">
<Icon className={`h-7 w-7 ${card.accent}`} /> <div className="text-3xl font-bold text-amber-600">
</div> {summary.needsReviewCount}
<div className="space-y-2"> </div>
<h4 className="text-lg font-bold text-[#214B11]"> <Link to="/diagnoses?status=needs_review" className="text-amber-600 ml-auto hover:underline">
{card.title} Lihat daftar
</h4> </Link>
<p className="text-sm leading-6 text-slate-500"> </CardContent>
{card.description} </Card>
</p>
</div> <Card>
</CardContent> <CardHeader className="pb-2">
</Card> <CardTitle className="text-sm font-medium text-muted-foreground">
); Risiko Tinggi
})} </CardTitle>
</div> </CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-red-600">
{summary.riskDistribution.high}
</div>
<Link to="/diagnoses?risk=high" className="text-red-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
</section> </section>
)}
{/* Diseases quick access */} {/* Mission section */}
<section className="space-y-4"> <section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<div className="mb-2 flex items-start justify-between"> <div className="space-y-1">
<div> <h3 className="text-[24px] font-extrabold text-[#214B11]">
<h3 className="text-[24px] font-extrabold text-[#214B11]"> Misi Platform
Penyakit yang Dapat Dideteksi </h3>
</h3> <p className="text-[15px] font-normal text-muted-foreground">
<p className="text-[15px] font-normal text-muted-foreground"> Fitur inti yang kami sediakan untuk mendukung petani jagung
4 kelas penyakit dan kondisi daun jagung dalam sistem kami Indonesia
</p> </p>
</div> </div>
<Link
to="/library" <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
className="text-emerald-600 font-semibold inline-flex items-center gap-1" {missionCards.map((card) => {
> const Icon = card.icon;
Lihat Pustaka <ChevronRight className="w-4 h-4" />
</Link> return (
<Card
key={card.title}
className="rounded-3xl border-white/70 bg-white/95 shadow-[0_8px_24px_rgba(16,24,40,0.08)] h-full"
>
<CardContent className="space-y-5 p-6 h-full flex flex-col justify-between">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#EFF6E8]">
<Icon className={`h-7 w-7 ${card.accent}`} />
</div>
<div className="space-y-2">
<h4 className="text-lg font-bold text-[#214B11]">
{card.title}
</h4>
<p className="text-sm leading-6 text-slate-500">
{card.description}
</p>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
{/* Diseases quick access - now API-driven */}
<section className="space-y-4">
<div className="mb-2 flex items-start justify-between">
<div>
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Penyakit yang Dapat Dideteksi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
{diseases.length} kelas penyakit dan kondisi daun jagung dalam sistem kami
</p>
</div> </div>
<Link
to="/catalog"
className="text-emerald-600 font-semibold inline-flex items-center gap-1"
>
Lihat Pustaka <ChevronRight className="w-4 h-4" />
</Link>
</div>
{diseasesQuery.isLoading ? (
<div className="text-center text-muted-foreground py-4">
Memuat data penyakit...
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{diseasesQuick.map((d) => ( {diseasesQuick.map((d) => (
<Card <Card
@@ -296,38 +311,31 @@ export function DashboardPage() {
</Card> </Card>
))} ))}
</div> </div>
</section> )}
</section>
{/* Scan quick access */} {/* Scan quick access */}
<div className="mt-15 flex items-center gap-57 bg-[#1E8A2A] rounded-3xl p-6"> <div className="mt-12 flex items-center gap-6 bg-[#1E8A2A] rounded-3xl p-6">
<div className="text-2xl font-bold text-white"> <div className="flex-1 text-white">
<h3>Siap Mendeteksi Penyakit Daun?</h3> <h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
<div> <p className="text-sm text-[#9AD872] font-normal mt-2">
<p className="text-sm text-[#9AD872] font-normal mt-2"> Unggah foto daun jagung Anda dan dapatkan hasil analisis AI
Unggah foto daun jagung Anda dan dapatkan hasil analisis AI dalam hitungan detik.
dalam hitungan detik. </p>
</p>
</div>
</div>
<Button
asChild
variant="outline"
className="bg-white hover:bg-[#1E8A2A]/90 hover:text-white px-6 py-6 text-lg font-bold text-[#214B11]"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Mulai Scan Sekarang
</Link>
</Button>
<Button
asChild
variant="outline"
className="px-6 py-6 text-lg font-bold text-white hover:bg-[#1E8A2A]"
></Button>
</div> </div>
</> <Button
)} asChild
</div> variant="outline"
</main> className="bg-white hover:bg-[#1E8A2A]/90 hover:text-white px-6 py-6 text-lg font-bold text-[#214B11] shrink-0"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Mulai Scan Sekarang
</Link>
</Button>
</div>
</>
)}
</div>
); );
} }
+158 -103
View File
@@ -1,19 +1,40 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge"; import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { RiskBadge } from "@/components/risk-badge"; import { RiskBadge } from "@/components/risk-badge";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import type { DiagnosisStatus, RiskLevel } from "@zeavis/shared";
const STATUS_OPTIONS: { value: "all" | DiagnosisStatus; label: string }[] = [
{ value: "all", label: "Semua" },
{ value: "ai_verified", label: "Terverifikasi AI" },
{ value: "needs_review", label: "Menunggu Review" },
{ value: "expert_verified", label: "Diverifikasi Pakar" },
{ value: "expert_corrected", label: "Dikoreksi Pakar" },
{ value: "failed", label: "Gagal" },
];
const RISK_OPTIONS: { value: "all" | RiskLevel; label: string }[] = [
{ value: "all", label: "Semua Risiko" },
{ value: "high", label: "Risiko Tinggi" },
{ value: "medium", label: "Risiko Sedang" },
{ value: "low", label: "Risiko Rendah" },
];
export function DiagnosesPage() { export function DiagnosesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialStatus = searchParams.get("status") as DiagnosisStatus | null;
const initialRisk = searchParams.get("risk") as RiskLevel | null;
const [statusFilter, setStatusFilter] = useState< const [statusFilter, setStatusFilter] = useState<
"all" | "needs_review" | "verified" | "failed" "all" | DiagnosisStatus
>("all"); >(initialStatus ?? "all");
const [riskFilter, setRiskFilter] = useState< const [riskFilter, setRiskFilter] = useState<
"all" | "high" | "medium" | "low" "all" | RiskLevel
>("all"); >(initialRisk ?? "all");
const query = useQuery({ const query = useQuery({
queryKey: ["diagnoses"], queryKey: ["diagnoses"],
@@ -32,114 +53,148 @@ export function DiagnosesPage() {
}); });
}, [diagnoses, statusFilter, riskFilter]); }, [diagnoses, statusFilter, riskFilter]);
const handleStatusChange = (value: string) => {
const v = value as "all" | DiagnosisStatus;
setStatusFilter(v);
const next = new URLSearchParams(searchParams);
if (v === "all") next.delete("status");
else next.set("status", v);
setSearchParams(next);
};
const handleRiskChange = (value: string) => {
const v = value as "all" | RiskLevel;
setRiskFilter(v);
const next = new URLSearchParams(searchParams);
if (v === "all") next.delete("risk");
else next.set("risk", v);
setSearchParams(next);
};
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-6xl space-y-6"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-between gap-4"> <div>
<div> <p className="text-sm font-medium text-primary">
<p className="text-sm font-medium text-primary"> Manajemen Diagnosis
Manajemen Diagnosis </p>
</p> <h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
<h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
</div>
<div className="flex items-center gap-3">
<Link to="/dashboard">
<Button variant="outline">Dashboard</Button>
</Link>
</div>
</div> </div>
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link to="/scan">Scan Baru</Link>
</Button>
</div>
</div>
<Card> <Card>
<CardContent className="flex flex-col gap-4 p-4"> <CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label> <label className="text-sm font-medium">Status</label>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)} onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-border bg-background px-2 py-1" className="rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="all">All</option> {STATUS_OPTIONS.map((opt) => (
<option value="needs_review">Needs review</option> <option key={opt.value} value={opt.value}>
<option value="verified">Verified</option> {opt.label}
<option value="failed">Failed</option> </option>
</select> ))}
</select>
<label className="text-sm font-medium">Risiko</label> <label className="text-sm font-medium">Risiko</label>
<select <select
value={riskFilter} value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as any)} onChange={(e) => handleRiskChange(e.target.value)}
className="rounded-md border border-border bg-background px-2 py-1" className="rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="all">All</option> {RISK_OPTIONS.map((opt) => (
<option value="high">High</option> <option key={opt.value} value={opt.value}>
<option value="medium">Medium</option> {opt.label}
<option value="low">Low</option> </option>
</select> ))}
</select>
<div className="ml-auto text-sm text-muted-foreground"> <div className="ml-auto text-sm text-muted-foreground">
Total: {filtered.length} Total: {filtered.length}
</div>
</div> </div>
</div>
{query.isLoading ? ( {query.isLoading ? (
<div className="p-6 text-center text-muted-foreground"> <div className="py-12 text-center text-muted-foreground">
Memuat diagnosis... Memuat diagnosis...
</div> </div>
) : query.isError ? ( ) : query.isError ? (
<div className="p-6 text-center text-red-600"> <div className="py-12 text-center text-red-600">
Gagal memuat diagnosis Gagal memuat diagnosis
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="p-6 text-center text-muted-foreground"> <div className="py-12 text-center">
<p className="text-muted-foreground">
Tidak ada diagnosis sesuai filter Tidak ada diagnosis sesuai filter
</div> </p>
) : ( {diagnoses.length === 0 && (
<div className="grid gap-4 md:grid-cols-2"> <p className="mt-2 text-sm text-muted-foreground">
{filtered.map((d) => ( Mulai dengan{" "}
<Card key={d.id} className="h-full"> <Link to="/scan" className="text-primary underline">
<CardContent className="flex gap-4 p-4 items-start"> melakukan scan daun
<img </Link>
src={d.imageUrl} .
alt="Daun" </p>
className="h-24 w-24 rounded-md object-cover" )}
/> </div>
<div className="flex-1"> ) : (
<div className="flex items-center justify-between gap-2"> <div className="grid gap-4 md:grid-cols-2">
<div> {filtered.map((d) => (
<h3 className="text-lg font-semibold"> <Card key={d.id} className="h-full">
{d.disease?.commonName ?? "Unknown"} <CardContent className="flex gap-4 p-4 items-start">
</h3> <img
<p className="text-sm text-muted-foreground"> src={d.imageUrl}
{d.predictedDiseaseSlug ?? ""} alt="Daun"
</p> className="h-24 w-24 rounded-md object-cover bg-muted"
</div> />
<div className="flex flex-col items-end gap-1"> <div className="flex-1 min-w-0">
<DiagnosisStatusBadge status={d.status} /> <div className="flex items-center justify-between gap-2">
<RiskBadge level={d.disease?.riskLevel ?? "low"} /> <div className="min-w-0">
</div> <h3 className="text-lg font-semibold truncate">
{d.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<p className="text-sm text-muted-foreground">
{d.disease?.label ?? d.predictedDiseaseSlug}
</p>
</div> </div>
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="mt-3 flex items-center justify-between gap-4"> <DiagnosisStatusBadge status={d.status} />
<div className="text-sm text-muted-foreground"> <RiskBadge level={d.disease?.riskLevel ?? "low"} />
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold"
>
Lihat detail
</Link>
</div> </div>
</div> </div>
</CardContent>
</Card> <div className="mt-2 flex items-center justify-between gap-4">
))} <div className="text-sm text-muted-foreground">
</div> {new Date(d.createdAt).toLocaleString("id-ID")}
)} {d.confidence !== null && (
</CardContent> <span className="ml-2">
</Card> {(d.confidence * 100).toFixed(0)}%
</div> </span>
</main> )}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold text-sm shrink-0"
>
Lihat detail
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
); );
} }
+31 -18
View File
@@ -1,7 +1,7 @@
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
import type { DiseaseSlug } from '@zeavis/shared'; import type { DiseaseSlug, DiagnosisStatus } from '@zeavis/shared';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
@@ -47,11 +47,24 @@ export function ExpertReviewsPage() {
await mutation.mutateAsync(); await mutation.mutateAsync();
} }
const statusLabel = (status: DiagnosisStatus) => {
switch (status) {
case 'ai_verified': return 'Terverifikasi AI';
case 'needs_review': return 'Menunggu Review';
case 'expert_verified': return 'Diverifikasi Pakar';
case 'expert_corrected': return 'Dikoreksi Pakar';
case 'failed': return 'Gagal';
}
};
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
<header className="border-b bg-card"> <header className="border-b bg-card">
<div className="mx-auto flex max-w-7xl items-center justify-between p-6"> <div className="mx-auto flex max-w-7xl items-center justify-between p-6">
<h1 className="text-2xl font-bold">Expert Reviews</h1> <div>
<p className="text-sm font-medium text-primary">Review Pakar</p>
<h1 className="text-2xl font-bold">Diagnosis Menunggu Review</h1>
</div>
<Link to="/dashboard"> <Link to="/dashboard">
<Button variant="outline">Dashboard</Button> <Button variant="outline">Dashboard</Button>
</Link> </Link>
@@ -63,15 +76,15 @@ export function ExpertReviewsPage() {
{/* Left: List of reviews */} {/* Left: List of reviews */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-semibold">Diagnoses to Review</h2> <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>
<div className="space-y-2"> <div className="space-y-2">
{reviewsQuery.isPending || diseasesQuery.isPending ? ( {reviewsQuery.isPending || diseasesQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading reviews and diseases...</p> <p className="text-sm text-muted-foreground">Memuat diagnosis...</p>
) : reviews.length === 0 ? ( ) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">No diagnoses pending review</p> <p className="text-sm text-muted-foreground">Tidak ada diagnosis yang menunggu review</p>
) : ( ) : (
reviews.map((review) => ( reviews.map((review) => (
<button <button
@@ -114,7 +127,7 @@ export function ExpertReviewsPage() {
{reviewsQuery.isPending || diseasesQuery.isPending ? ( {reviewsQuery.isPending || diseasesQuery.isPending ? (
<Card> <Card>
<CardContent className="p-6 text-center text-muted-foreground"> <CardContent className="p-6 text-center text-muted-foreground">
Loading reviews and diseases... Memuat diagnosis...
</CardContent> </CardContent>
</Card> </Card>
) : selected ? ( ) : selected ? (
@@ -150,7 +163,7 @@ export function ExpertReviewsPage() {
{/* Top predictions */} {/* Top predictions */}
{selected.predictions.length > 0 && ( {selected.predictions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium">Top Predictions</h4> <h4 className="text-sm font-medium">Prediksi Teratas</h4>
<div className="space-y-1"> <div className="space-y-1">
{selected.predictions.map((pred) => ( {selected.predictions.map((pred) => (
<div key={pred.id} className="flex items-center justify-between text-sm"> <div key={pred.id} className="flex items-center justify-between text-sm">
@@ -167,13 +180,13 @@ export function ExpertReviewsPage() {
{/* Review form */} {/* Review form */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Expert Review</CardTitle> <CardTitle className="text-lg">Review Pakar</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Verdict selection */} {/* Verdict selection */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Verdict</label> <label className="text-sm font-medium">Keputusan</label>
<div className="flex gap-3"> <div className="flex gap-3">
<label htmlFor="verdict-verified" className="flex items-center gap-2"> <label htmlFor="verdict-verified" className="flex items-center gap-2">
<input <input
@@ -188,7 +201,7 @@ export function ExpertReviewsPage() {
}} }}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="text-sm">Verified</span> <span className="text-sm">Terverifikasi</span>
</label> </label>
<label htmlFor="verdict-corrected" className="flex items-center gap-2"> <label htmlFor="verdict-corrected" className="flex items-center gap-2">
<input <input
@@ -200,7 +213,7 @@ export function ExpertReviewsPage() {
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')} onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="text-sm">Corrected</span> <span className="text-sm">Dikoreksi</span>
</label> </label>
</div> </div>
</div> </div>
@@ -209,7 +222,7 @@ export function ExpertReviewsPage() {
{verdict === 'corrected' && ( {verdict === 'corrected' && (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="disease" className="text-sm font-medium"> <label htmlFor="disease" className="text-sm font-medium">
Correct Disease Penyakit yang Benar
</label> </label>
<select <select
id="disease" id="disease"
@@ -218,7 +231,7 @@ export function ExpertReviewsPage() {
required={verdict === 'corrected'} required={verdict === 'corrected'}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="">Select a disease...</option> <option value="">Pilih penyakit...</option>
{diseases.map((disease) => ( {diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}> <option key={disease.slug} value={disease.slug}>
{disease.commonName} {disease.commonName}
@@ -231,13 +244,13 @@ export function ExpertReviewsPage() {
{/* Notes */} {/* Notes */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium"> <label htmlFor="notes" className="text-sm font-medium">
Notes <span className="text-red-500">*</span> Catatan <span className="text-red-500">*</span>
</label> </label>
<textarea <textarea
id="notes" id="notes"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Enter your review notes..." placeholder="Tulis catatan review Anda..."
required required
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
rows={4} rows={4}
@@ -250,12 +263,12 @@ export function ExpertReviewsPage() {
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()} disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
className="w-full" className="w-full"
> >
{mutation.isPending ? 'Submitting...' : 'Submit Review'} {mutation.isPending ? 'Mengirim...' : 'Kirim Review'}
</Button> </Button>
{mutation.isError && ( {mutation.isError && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
Error: {mutation.error instanceof Error ? mutation.error.message : 'Unknown error'} Error: {mutation.error instanceof Error ? mutation.error.message : 'Terjadi kesalahan'}
</p> </p>
)} )}
</form> </form>
@@ -265,7 +278,7 @@ export function ExpertReviewsPage() {
) : ( ) : (
<Card> <Card>
<CardContent className="p-6 text-center text-muted-foreground"> <CardContent className="p-6 text-center text-muted-foreground">
No diagnoses available for review Tidak ada diagnosis yang tersedia untuk review
</CardContent> </CardContent>
</Card> </Card>
)} )}
+83 -170
View File
@@ -1,182 +1,95 @@
import React, { useMemo, useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query";
import { BookOpen } from "lucide-react"; import { DiseaseCard } from "@/components/disease-card";
import { Button } from "@/components/ui/button"; import { apiClient } from "@/lib/api-client";
import { Modal } from "@/components/ui/modal"; import type { RiskLevel } from "@zeavis/shared";
import { mockDiseases } from "@/data/mock-diseases";
export function LibraryPage() { export function LibraryPage() {
type Disease = (typeof mockDiseases)[number]; const [searchQuery, setSearchQuery] = useState("");
const [riskFilter, setRiskFilter] = useState<RiskLevel | "all">("all");
const [filter, setFilter] = useState<string | null>(null); const { data: diseases, isLoading, error } = useQuery({
const [expandedId, setExpandedId] = useState<string | null>(null); queryKey: ["diseases"],
const [selected, setSelected] = useState<Disease | null>(null); queryFn: () => apiClient.getDiseases(),
const [modalOpen, setModalOpen] = useState(false); });
const items = useMemo(() => { const filteredDiseases = (diseases || []).filter((disease) => {
if (!filter) return mockDiseases; const matchesSearch =
return mockDiseases.filter((d) => disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.name.toLowerCase().includes(filter.toLowerCase()), disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
); disease.summary.toLowerCase().includes(searchQuery.toLowerCase());
}, [filter]);
const matchesRisk = riskFilter === "all" || disease.riskLevel === riskFilter;
return matchesSearch && matchesRisk;
});
return ( return (
<main className="p-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold mb-4">Pustaka Penyakit</h1> <div className="flex items-center justify-between">
<div className="mb-4"> <div>
<input <p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
placeholder="Filter penyakit..." <h1 className="text-3xl font-bold">Pustaka Penyakit</h1>
value={filter ?? ""} </div>
onChange={(e) => setFilter(e.target.value || null)}
className="border px-3 py-2 rounded-md w-full max-w-sm"
/>
</div> </div>
<div className="grid grid-cols-1 gap-4"> <div className="flex flex-col gap-4 md:flex-row md:items-end">
{items.map((d) => ( <div className="flex-1">
<article key={d.id} className="bg-white p-4 rounded-lg shadow"> <label htmlFor="search" className="block text-sm font-medium mb-2">
<div className="flex gap-4"> Cari penyakit
<img </label>
src={d.imageUrl} <input
alt={d.name} id="search"
className="h-28 w-48 rounded-md object-cover" type="text"
/> placeholder="Cari berdasarkan nama atau gejala..."
<div className="flex-1"> value={searchQuery}
<div className="flex items-start justify-between"> onChange={(e) => setSearchQuery(e.target.value)}
<div> className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
<h2 className="font-semibold text-lg"> />
{d.name}{" "} </div>
<span className="text-sm text-muted-foreground"> <div>
{d.severity} <label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
</span> Filter risiko
</h2> </label>
<p className="text-sm text-muted-foreground mt-2"> <select
{d.description} id="risk-filter"
</p> value={riskFilter}
{d.pathogen && ( onChange={(e) => setRiskFilter(e.target.value as RiskLevel | "all")}
<div className="mt-2 text-sm"> className="rounded-md border border-border bg-background px-3 py-2 text-sm"
<strong>Patogen:</strong> {d.pathogen} >
</div> <option value="all">Semua Risiko</option>
)} <option value="low">Risiko Rendah</option>
<div className="mt-3 flex items-center gap-3"> <option value="medium">Risiko Sedang</option>
<Button <option value="high">Risiko Tinggi</option>
variant="default" </select>
onClick={() => { </div>
setSelected(d);
setModalOpen(true);
}}
className="inline-flex items-center gap-2 bg-green-600 text-white px-3 py-1 rounded"
>
<BookOpen className="h-4 w-4" />
<span className="text-sm">Baca lebih lanjut</span>
</Button>
<Link
to={`/catalog/${d.slug}`}
className="text-sm text-muted-foreground"
>
Lihat halaman katalog
</Link>
</div>
</div>
<div>
<button
className="text-sm text-primary underline"
onClick={() =>
setExpandedId(expandedId === d.id ? null : d.id)
}
>
{expandedId === d.id ? "Tutup" : "Detail"}
</button>
</div>
</div>
{expandedId === d.id && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<h3 className="font-medium">Gejala</h3>
<ul className="list-disc list-inside text-sm mt-2">
{(d.symptoms || []).length > 0 ? (
d.symptoms.map((s: string, i: number) => (
<li key={`${d.id}-symptom-${i}`}>{s}</li>
))
) : (
<li>Tidak ada gejala khusus</li>
)}
</ul>
</div>
<div>
<h3 className="font-medium">Pencegahan</h3>
<p className="text-sm mt-2">{d.prevention}</p>
</div>
</div>
)}
</div>
</div>
</article>
))}
</div> </div>
<Modal {isLoading && (
open={modalOpen} <div className="py-12 text-center text-muted-foreground">
onClose={() => { Memuat pustaka penyakit...
setModalOpen(false); </div>
setSelected(null); )}
}}
title={selected?.name} {error && (
footer={ <div className="py-12 text-center text-red-600">
selected && ( Gagal memuat pustaka penyakit
<div className="flex items-center justify-end gap-3"> </div>
<Link )}
to={`/catalog/${selected.slug}`}
className="px-3 py-2 rounded bg-green-600 text-white text-sm" {!isLoading && !error && filteredDiseases.length === 0 && (
> <div className="py-12 text-center text-muted-foreground">
Buka halaman katalog Tidak ada penyakit yang cocok dengan filter ini.
</Link> </div>
<button )}
onClick={() => {
setModalOpen(false); {!isLoading && !error && filteredDiseases.length > 0 && (
setSelected(null); <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
}} {filteredDiseases.map((disease) => (
className="px-3 py-2 rounded bg-gray-200 text-sm" <DiseaseCard key={disease.slug} disease={disease} />
> ))}
Tutup </div>
</button> )}
</div> </div>
)
}
>
{selected ? (
<div className="grid grid-cols-1 gap-4">
<img
src={selected.imageUrl}
alt={selected.name}
className="w-full rounded-md object-cover"
/>
<div>
<p className="text-sm text-muted-foreground">
{selected.description}
</p>
{selected.pathogen && (
<p className="mt-2">
<strong>Patogen:</strong> {selected.pathogen}
</p>
)}
<h4 className="mt-3 font-medium">Gejala</h4>
<ul className="list-disc list-inside text-sm mt-2">
{(selected.symptoms || []).length > 0 ? (
selected.symptoms.map((s: string, i: number) => (
<li key={`${selected.id}-symptom-${i}`}>{s}</li>
))
) : (
<li>Tidak ada gejala khusus</li>
)}
</ul>
<h4 className="mt-3 font-medium">Pencegahan</h4>
<p className="text-sm mt-2">{selected.prevention}</p>
</div>
</div>
) : null}
</Modal>
</main>
); );
} }
+1 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
@@ -7,9 +7,6 @@ import { useAuthStore } from "@/store/auth-store";
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
+1 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
@@ -7,9 +7,6 @@ import { useAuthStore } from "@/store/auth-store";
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
+237 -117
View File
@@ -1,12 +1,17 @@
import React, { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { Camera, Upload, X, CheckCircle, ImageOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import type { DiagnosisRecord } from "@zeavis/shared"; import type { DiagnosisRecord } from "@zeavis/shared";
import { apiClient } from "@/lib/api-client";
export function ScanPage() { export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -15,16 +20,28 @@ export function ScanPage() {
mutationFn: (file: File) => apiClient.createDiagnosis(file), mutationFn: (file: File) => apiClient.createDiagnosis(file),
onSuccess: (diagnosis) => { onSuccess: (diagnosis) => {
queryClient.invalidateQueries({ queryKey: ["diagnoses"] }); queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
// show preview modal instead of immediate navigation
setDiagnosisPreview(diagnosis); setDiagnosisPreview(diagnosis);
setPreviewOpen(true); setPreviewOpen(true);
setFileName(null);
setPreviewUrl(null);
if (inputRef.current) inputRef.current.value = "";
}, },
}); });
const handleFile = (f?: File) => { const handleFile = (f?: File) => {
if (!f) return; if (!f) return;
if (f.size > 5 * 1024 * 1024) {
alert("Ukuran file melebihi batas 5MB");
return;
}
setFileName(f.name); setFileName(f.name);
mutation.mutate(f); setPreviewUrl(URL.createObjectURL(f));
};
const handleUpload = () => {
const file = inputRef.current?.files?.[0];
if (!file) return;
mutation.mutate(file);
}; };
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
@@ -38,159 +55,262 @@ export function ScanPage() {
}); });
return ( return (
<main className="p-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold mb-4">Scan Tanaman</h1> <div className="flex items-center justify-between gap-4">
<div className="grid grid-cols-3 gap-6"> <div>
<div className="col-span-2 bg-white p-6 rounded-lg shadow"> <p className="text-sm font-medium text-primary">Deteksi Penyakit</p>
<button <h1 className="text-3xl font-bold">Scan Tanaman</h1>
type="button"
className="w-full border-2 border-dashed border-green-300 rounded-md p-8 text-center cursor-pointer"
onClick={() => inputRef.current?.click()}
>
<div className="text-green-600">Area Unggah Gambar</div>
<div className="mt-4 text-sm text-muted-foreground">
Seret & Lepas atau klik untuk memilih file (PNG/JPG, maks 5MB)
</div>
{fileName && (
<div className="mt-3 text-sm">Dipilih: {fileName}</div>
)}
</button>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
<div className="mt-6 flex items-center gap-3">
<button
className="bg-green-600 text-white px-4 py-2 rounded-md"
onClick={() => inputRef.current?.click()}
>
Pilih Berkas
</button>
{mutation.isPending && (
<div className="text-sm text-muted-foreground">Mengunggah...</div>
)}
{mutation.isError && (
<div className="text-sm text-red-600">
{mutation.error instanceof Error
? mutation.error.message
: String(mutation.error) || "Upload gagal"}
</div>
)}
</div>
</div> </div>
<aside className="bg-white p-6 rounded-lg shadow"> <Button asChild variant="outline">
<h2 className="font-medium mb-2">Panduan Pengambilan Foto</h2> <Link to="/diagnoses">Riwayat Diagnosis</Link>
<ul className="text-sm space-y-2 text-muted-foreground"> </Button>
<li>Jarak 1530 cm dari daun</li> </div>
<li>Pencahayaan cukup, hindari blur</li>
<li>Daun memenuhi bingkai</li> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
</ul> <div className="lg:col-span-2 space-y-4">
<Card>
<CardContent className="p-6">
{/* 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>
<div className="mt-2 text-sm text-muted-foreground">
Seret & Lepas atau klik untuk memilih file (PNG/JPG, maks 5MB)
</div>
</button>
) : (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-muted">
<img
src={previewUrl}
alt="Preview"
className="w-full max-h-80 object-contain"
/>
<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"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
File: <span className="font-medium">{fileName}</span>
</p>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<ImageOff className="mr-2 h-4 w-4" />
Ganti Foto
</Button>
<Button
onClick={handleUpload}
disabled={mutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Upload className="mr-2 h-4 w-4" />
{mutation.isPending ? "Memproses..." : "Analisis"}
</Button>
</div>
</div>
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
{/* Error state */}
{mutation.isError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
<p className="font-medium">Upload gagal</p>
<p className="mt-1">
{mutation.error instanceof Error
? mutation.error.message
: "Terjadi kesalahan saat memproses gambar"}
</p>
</div>
)}
</CardContent>
</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
</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>
<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>
<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>
</ul>
</CardContent>
</Card>
</aside> </aside>
</div> </div>
{/* Result Modal */}
<Modal <Modal
open={previewOpen} open={previewOpen}
onClose={() => { onClose={() => {
setPreviewOpen(false); setPreviewOpen(false);
setDiagnosisPreview(null); setDiagnosisPreview(null);
}} }}
title={diagnosisPreview?.predictedDiseaseSlug ?? "Hasil Diagnosis"} title={diagnosisPreview?.disease?.commonName ?? "Hasil Diagnosis"}
size="sm" size="md"
footer={ footer={
diagnosisPreview && ( diagnosisPreview && (
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<button <Button
className="px-3 py-2 rounded bg-green-600 text-white text-sm" variant="outline"
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
>
Lihat detail
</button>
<button
className="px-3 py-2 rounded bg-gray-200 text-sm"
onClick={() => { onClick={() => {
setPreviewOpen(false); setPreviewOpen(false);
setDiagnosisPreview(null); setDiagnosisPreview(null);
}} }}
> >
Tutup Tutup
</button> </Button>
<Button
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
className="bg-green-600 hover:bg-green-700"
>
Lihat Detail Lengkap
</Button>
</div> </div>
) )
} }
> >
{diagnosisPreview ? ( {diagnosisPreview ? (
<div className="grid grid-cols-1 gap-4"> <div className="space-y-4">
{diagnosisPreview.imageUrl && ( <div className="flex items-center gap-2">
<img <DiagnosisStatusBadge status={diagnosisPreview.status} />
src={diagnosisPreview.imageUrl} {diagnosisPreview.confidence !== null && (
alt="hasil" <span className="text-sm font-medium">
className="w-full rounded-md object-cover" Confidence: {(diagnosisPreview.confidence * 100).toFixed(1)}%
/> </span>
)} )}
<div>
<p className="text-sm text-muted-foreground">
Prediksi:{" "}
<strong>{diagnosisPreview.predictedDiseaseSlug}</strong>
</p>
<p className="text-sm text-muted-foreground">
Confidence:{" "}
<strong>
{Math.round((diagnosisPreview.confidence ?? 0) * 100)}%
</strong>
</p>
</div> </div>
{diagnosisPreview.disease && (
<div>
<h3 className="font-semibold text-lg">
{diagnosisPreview.disease.commonName}
</h3>
<p className="text-sm text-muted-foreground">
{diagnosisPreview.disease.summary}
</p>
</div>
)}
{diagnosisPreview.failureReason && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{diagnosisPreview.failureReason}
</div>
)}
{diagnosisPreview.predictions.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Semua Prediksi</h4>
<div className="space-y-2">
{diagnosisPreview.predictions
.sort((a, b) => a.rank - b.rank)
.map((pred) => (
<div
key={pred.id}
className="flex items-center justify-between rounded-lg border p-2 text-sm"
>
<span>{pred.modelLabel}</span>
<span className="font-semibold">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div>
))}
</div>
</div>
)}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-2"> <h4 className="text-sm font-medium mb-3">
Daftar Diagnosis Terbaru Riwayat Diagnosis Terbaru
</h4> </h4>
{diagnosesQuery.isLoading && ( {diagnosesQuery.isLoading && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Memuat daftar... Memuat riwayat...
</div>
)}
{diagnosesQuery.isError && (
<div className="text-sm text-red-600">
Gagal memuat daftar diagnosis
</div> </div>
)} )}
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && ( {!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
<div className="space-y-2"> <div className="space-y-2">
{(diagnosesQuery.data ?? []).slice(0, 5).map((d) => ( {(diagnosesQuery.data ?? [])
<div .slice(0, 5)
key={d.id} .map((d) => (
className="flex items-center justify-between rounded-md p-2 hover:bg-muted" <div
> key={d.id}
<div className="flex items-center gap-3"> className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
<img >
src={d.imageUrl} <div className="flex items-center gap-3">
alt="thumb" <img
className="h-10 w-10 rounded object-cover" src={d.imageUrl}
/> alt="thumb"
<div className="text-sm"> className="h-10 w-10 rounded object-cover bg-muted"
<div className="font-medium"> />
{d.disease?.commonName ?? d.predictedDiseaseSlug} <div className="text-sm">
</div> <div className="font-medium">
<div className="text-xs text-muted-foreground"> {d.disease?.commonName ?? "Unknown"}
{new Date(d.createdAt).toLocaleString("id-ID")} </div>
<div className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
</div> </div>
</div> </div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div> </div>
<Link ))}
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div>
))}
</div> </div>
)} )}
</div> </div>
</div> </div>
) : null} ) : null}
</Modal> </Modal>
</main> </div>
); );
} }
-31
View File
@@ -1,31 +0,0 @@
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"devDependencies": { "devDependencies": {