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

Selly/frontend
This commit is contained in:
Selly Supriyatin
2026-06-14 20:41:40 +07:00
committed by GitHub
10 changed files with 728 additions and 465 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

@@ -0,0 +1,205 @@
import { useState } from "react";
import { ChevronDown, ChevronUp, AlertTriangle, BookOpen, ShieldCheck, Pill, RefreshCcw } from "lucide-react";
// Spesifikasi data yang dibutuhkan oleh cetakan ini
type Props = {
imageUrl: string;
confidence: number; // contoh: 0.95
diseaseName: string;
scientificName: string;
riskLevel: string;
description: string;
symptoms: string[];
preventions: string[];
medicines: string[];
onRescan?: () => void; // Tombol opsional untuk Scan Ulang
};
export function DiagnosisResultView({
imageUrl,
confidence,
diseaseName,
scientificName,
riskLevel,
description,
symptoms,
preventions,
medicines,
onRescan,
}: Props) {
// Ingatan untuk laci (accordion) sebelah kanan
const [openSection, setOpenSection] = useState<string>("detail");
const toggleSection = (section: string) => {
setOpenSection(openSection === section ? "" : section);
};
const confidencePercent = Math.round(confidence * 100);
return (
<div className="flex flex-col lg:flex-row gap-6 bg-[#FAFAF8] p-4 md:p-6 rounded-3xl">
{/* KIRI: Area Gambar & Hasil Deteksi ML */}
<div className="w-full lg:w-1/3 flex flex-col gap-4">
{/* Gambar Daun */}
<div className="bg-white p-3 rounded-2xl border border-slate-200 shadow-sm">
<img
src={imageUrl}
alt="Daun yang dianalisis"
className="w-full h-48 md:h-64 object-cover rounded-xl"
/>
<div className="mt-3 flex items-center gap-2 text-slate-500 text-sm px-1">
<span className="w-4 h-4 bg-slate-200 rounded flex items-center justify-center text-[10px]">📷</span>
Gambar yang dianalisis
</div>
</div>
{/* Kartu Hasil Deteksi ML */}
<div className="bg-[#FCF9EE] border border-[#F2E5C5] p-5 rounded-2xl shadow-sm relative overflow-hidden">
<div className="flex items-center gap-2 text-amber-700 font-semibold text-sm mb-4">
<AlertTriangle className="w-4 h-4" />
HASIL DETEKSI ML
</div>
<h2 className="text-2xl font-extrabold text-[#D97706]">{diseaseName}</h2>
<p className="text-slate-500 italic text-sm mb-6">{scientificName}</p>
{/* Bar Tingkat Keyakinan AI */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm font-bold text-slate-700">
<span>Tingkat Keyakinan AI</span>
<span className="text-emerald-600">{confidencePercent}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2.5 overflow-hidden">
<div
className="bg-emerald-500 h-2.5 rounded-full transition-all duration-1000"
style={{ width: `${confidencePercent}%` }}
></div>
</div>
<p className="text-[11px] text-emerald-600 flex items-center gap-1 font-medium">
<CheckCircle2 className="w-3 h-3" /> Di atas ambang batas minimum (75%)
</p>
</div>
<div className="flex items-center gap-3 pt-4 border-t border-amber-200/50">
<span className="text-sm font-medium text-slate-600">Tingkat Keparahan:</span>
<span className="px-3 py-1 bg-[#D97706] text-white text-xs font-bold rounded-full">
{riskLevel.toUpperCase()}
</span>
</div>
</div>
</div>
{/* KANAN: Area Edukasi (Accordion) & Referensi */}
<div className="w-full lg:w-2/3 flex flex-col gap-4">
{/* Tombol Header (Jika ada fungsi onRescan) */}
{onRescan && (
<div className="flex justify-end mb-2">
<button
onClick={onRescan}
className="flex items-center gap-2 px-4 py-2 bg-white border border-[#214B11] text-[#214B11] rounded-full text-sm font-bold hover:bg-[#214B11] hover:text-white transition-all shadow-sm"
>
<RefreshCcw className="w-4 h-4" /> Scan Ulang
</button>
</div>
)}
{/* Laci 1: Detail Penyakit */}
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
<div
onClick={() => toggleSection("detail")}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-slate-50"
>
<div className="flex items-center gap-3 font-bold text-[#1E3A8A]">
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
<BookOpen className="w-4 h-4 text-blue-700" />
</div>
Detail Penyakit
</div>
{openSection === "detail" ? <ChevronUp className="w-5 h-5 text-slate-400" /> : <ChevronDown className="w-5 h-5 text-slate-400" />}
</div>
{openSection === "detail" && (
<div className="p-4 pt-0 border-t border-slate-100 text-sm text-slate-600 leading-relaxed">
<p className="mb-4">{description}</p>
<h5 className="font-bold text-[#214B11] mb-2">Gejala Umum:</h5>
<ul className="space-y-1 pl-4 list-disc marker:text-emerald-500">
{symptoms.map((sym, i) => <li key={i}>{sym}</li>)}
</ul>
</div>
)}
</div>
{/* Laci 2: Panduan Pencegahan */}
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
<div
onClick={() => toggleSection("cegah")}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-slate-50"
>
<div className="flex items-center gap-3 font-bold text-[#1E40AF]">
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center">
<ShieldCheck className="w-4 h-4 text-indigo-700" />
</div>
Panduan Pencegahan
</div>
{openSection === "cegah" ? <ChevronUp className="w-5 h-5 text-slate-400" /> : <ChevronDown className="w-5 h-5 text-slate-400" />}
</div>
{openSection === "cegah" && (
<div className="p-4 pt-0 border-t border-slate-100 text-sm text-slate-600">
<ul className="space-y-2">
{preventions.map((prev, i) => (
<li key={i} className="flex gap-2 items-start">
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" /> <span>{prev}</span>
</li>
))}
</ul>
</div>
)}
</div>
{/* Laci 3: Rekomendasi Obat */}
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
<div
onClick={() => toggleSection("obat")}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-slate-50"
>
<div className="flex items-center gap-3 font-bold text-[#6B21A8]">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<Pill className="w-4 h-4 text-purple-700" />
</div>
Rekomendasi Obat
</div>
{openSection === "obat" ? <ChevronUp className="w-5 h-5 text-slate-400" /> : <ChevronDown className="w-5 h-5 text-slate-400" />}
</div>
{openSection === "obat" && (
<div className="p-4 pt-0 border-t border-slate-100 text-sm text-slate-600">
<ul className="space-y-2">
{medicines.map((med, i) => (
<li key={i} className="flex gap-2 items-start">
<span className="text-purple-400 shrink-0 mt-0.5"></span> <span>{med}</span>
</li>
))}
</ul>
</div>
)}
</div>
{/* Kotak Disclaimer Penting */}
<div className="bg-[#FFFBEB] border border-[#FDE68A] rounded-2xl p-4 mt-2">
<div className="flex items-center gap-2 text-amber-600 font-bold mb-2">
<AlertTriangle className="w-5 h-5" /> Disclaimer Penting
</div>
<p className="text-[13px] text-amber-900 leading-relaxed">
ZeaVis Edu adalah <strong>alat bantu edukasi awal</strong>, bukan pengganti diagnosis profesional. Hasil ini tidak dapat dijadikan dasar keputusan agronomis tanpa konfirmasi dari <strong>ahli pertanian atau Petugas Pengamat Organisme Pengganggu Tanaman (POPT)</strong> yang berwenang.
</p>
</div>
</div>
</div>
);
}
// Ikon bantuan untuk persentase
function CheckCircle2(props: any) {
return <svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
}
+229 -135
View File
@@ -1,174 +1,268 @@
import { useState, useEffect } from 'react'; import { useState, useMemo } from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { Link, useSearchParams } from 'react-router-dom'; import {
import { AlertCircle } from 'lucide-react'; ChevronDown,
import type { RiskLevel } from '@zeavis/shared'; ChevronUp,
import { Card, CardContent } from '@/components/ui/card'; AlertTriangle,
import { RiskBadge } from '@/components/risk-badge'; CheckCircle2,
import { apiClient } from '@/lib/api-client'; Pill,
ShieldCheck,
Search,
Leaf,
} from "lucide-react";
import { apiClient } from "@/lib/api-client";
import { RiskBadge } from "@/components/risk-badge";
import { diseaseCatalogSeed } from "@zeavis/shared/diseases";
export function CatalogPage() { export function CatalogPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [expandedId, setExpandedId] = useState<string | null>(null);
const initialRisk = searchParams.get('risk') as RiskLevel | null; const [searchQuery, setSearchQuery] = useState("");
const [riskFilter, setRiskFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState(''); const { data: diseases, isLoading } = useQuery({
const [riskFilter, setRiskFilter] = useState<RiskLevel | 'all'>(initialRisk ?? 'all'); queryKey: ["diseases"],
// Sync URL params with state
useEffect(() => {
if (initialRisk && initialRisk !== riskFilter) {
setRiskFilter(initialRisk);
}
}, [initialRisk]);
const { data: diseases, isLoading, error } = useQuery({
queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(), queryFn: () => apiClient.getDiseases(),
}); });
const filteredDiseases = (diseases || []) const filteredDiseases = useMemo(() => {
.sort((a, b) => a.displayOrder - b.displayOrder) if (!diseases) return [];
.filter((disease) => {
return diseases.filter((disease) => {
const matchesSearch = const matchesSearch =
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) || disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) || disease.symptoms.some((symptom) =>
disease.summary.toLowerCase().includes(searchQuery.toLowerCase()) || symptom.toLowerCase().includes(searchQuery.toLowerCase()),
disease.symptoms.some((s) => s.toLowerCase().includes(searchQuery.toLowerCase())); );
const matchesRisk = riskFilter === 'all' || disease.riskLevel === riskFilter; const matchesRisk =
riskFilter === "all" || disease.riskLevel === riskFilter;
return matchesSearch && matchesRisk; return matchesSearch && matchesRisk;
}); });
}, [diseases, searchQuery, riskFilter]);
if (isLoading) {
return (
<div className="p-8 text-center text-muted-foreground">
Memuat pustaka penyakit...
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-5xl mx-auto pb-10">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> {/* Page Title */}
<div> <div>
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1> <h1 className="text-2xl md:text-3xl font-extrabold text-[#214B11]">
<p className="text-gray-500 mt-1 text-md"> Pustaka Penyakit
Referensi lengkap penyakit dan kondisi daun jagung yang dapat dideteksi oleh sistem AI ZeaVis Edu </h1>
</p> <p className="mt-1 text-muted-foreground text-sm md:text-base">
Referensi lengkap penyakit dan kondisi daun jagung yang dapat
dideteksi oleh sistem AI ZeaVis Edu.
</p>
</div>
{/* Search and Filter */}
<div className="flex flex-col md:flex-row gap-4 items-end bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex-1 w-full space-y-1.5">
<label className="text-sm font-semibold text-slate-700">
Cari penyakit
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Cari berdasarkan nama atau gejala..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#214B11]/20 focus:border-[#214B11] transition-all"
/>
</div>
</div>
<div className="w-full md:w-48 space-y-1.5 shrink-0">
<label className="text-sm font-semibold text-slate-700">
Filter risiko
</label>
<select
value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value)}
className="w-full py-2 px-3 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#214B11]/20 focus:border-[#214B11] transition-all bg-white"
>
<option value="all">Semua Risiko</option>
<option value="high">Risiko Tinggi</option>
<option value="medium">Risiko Sedang</option>
<option value="low">Risiko Rendah</option>
</select>
</div> </div>
</div> </div>
{/* Filters */} {/* Accordion List */}
<div className="flex flex-col gap-4 md:flex-row md:items-end"> <div className="space-y-4">
<div className="flex-1"> {filteredDiseases.length === 0 ? (
<label htmlFor="search" className="block text-sm font-medium mb-2"> <div className="text-center py-10 bg-white rounded-2xl border border-slate-200">
Cari penyakit <p className="text-slate-500">
</label> Tidak ada penyakit yang sesuai dengan pencarianmu.
<div className="relative"> </p>
<AlertCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="search"
type="text"
placeholder="Cari berdasarkan nama atau gejala..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-border bg-background pl-10 pr-3 py-2 text-sm"
/>
</div>
</div> </div>
<div> ) : (
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2"> filteredDiseases.map((disease) => {
Filter risiko const isExpanded = expandedId === disease.slug;
</label>
<select const seedData = diseaseCatalogSeed.find(seed => seed.slug === disease.slug);
id="risk-filter" const finalImageUrl = disease.imageUrl || seedData?.imageUrl || "https://placehold.co/100x100?text=Daun";
value={riskFilter} const finalMedicines = disease.medicineRecommendations || seedData?.medicineRecommendations;
onChange={(e) => {
const v = e.target.value as RiskLevel | 'all';
setRiskFilter(v);
const next = new URLSearchParams(searchParams);
if (v === 'all') next.delete('risk');
else next.set('risk', v);
setSearchParams(next);
}}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="all">Semua Risiko</option>
<option value="low">Risiko Rendah</option>
<option value="medium">Risiko Sedang</option>
<option value="high">Risiko Tinggi</option>
</select>
</div>
</div>
{isLoading && (
<Card className="p-8 text-center text-muted-foreground">
Memuat katalog...
</Card>
)}
{error && ( return (
<Card className="p-8 text-center text-red-600"> <div
Katalog belum tersedia
</Card>
)}
{!isLoading && !error && filteredDiseases.length === 0 && (
<Card className="p-8 text-center text-muted-foreground">
Tidak ada penyakit yang cocok dengan filter ini.
</Card>
)}
{!isLoading && !error && filteredDiseases.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{filteredDiseases.map((disease) => (
<Link
key={disease.slug} key={disease.slug}
to={`/catalog/${disease.slug}`} onClick={() =>
className="block transition-transform hover:scale-[1.03]" setExpandedId(isExpanded ? null : disease.slug)
}
className={`bg-white rounded-2xl border transition-all duration-300 ${
isExpanded
? "border-[#214B11]/30 shadow-md ring-1 ring-[#214B11]/5"
: "border-slate-200 hover:border-slate-300 hover:shadow-sm"
}`}
> >
<Card className="h-full rounded-2xl bg-white shadow-sm hover:shadow-md"> {/* Accordion Header */}
<CardContent className="p-5 space-y-4"> <div
<div className="flex flex-wrap items-start justify-between gap-2"> className="flex items-center justify-between p-4 md:p-5 cursor-pointer select-none"
<div className="min-w-0"> >
<div className="flex items-center gap-4">
<img
src={finalImageUrl}
alt={disease.commonName}
className="w-12 h-12 md:w-16 md:h-16 rounded-xl object-cover shrink-0 bg-slate-100"
/>
<div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<h3 className="text-lg font-bold text-[#214B11]"> <h3 className="text-lg font-bold text-[#214B11]">
{disease.commonName} {disease.commonName}
</h3> </h3>
<p className="text-sm text-slate-400 italic"> <div className="w-fit">
{disease.label} <RiskBadge level={disease.riskLevel} />
</p> </div>
</div>
<p className="text-sm text-slate-500 italic mt-0.5">
{disease.label}
</p>
</div>
</div>
<div className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-slate-50 text-slate-400">
{isExpanded ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</div>
</div>
{/* Accordion Content */}
{isExpanded && (
<div className="p-4 md:p-6 border-t border-slate-100 animate-in slide-in-from-top-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<img
src={
finalImageUrl
}
alt="Detail Penyakit"
className="w-full h-48 md:h-full object-cover rounded-xl"
/>
<div className="space-y-5">
<div>
<h4 className="font-bold text-[#214B11] flex items-center gap-2 mb-2">
Deskripsi
</h4>
<p className="text-sm text-slate-600 leading-relaxed">
{disease.description || disease.summary}
</p>
</div>
<div>
<h4 className="font-bold text-[#214B11] flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-amber-500" />
Gejala
</h4>
<ul className="space-y-2">
{disease.symptoms?.map((symptom, idx) => (
<li
key={idx}
className="flex items-start gap-2 text-sm text-slate-600"
>
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 shrink-0" />
<span>{symptom}</span>
</li>
))}
</ul>
</div>
</div> </div>
<RiskBadge level={disease.riskLevel} />
</div> </div>
<p className="text-sm text-muted-foreground line-clamp-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{disease.summary} <div className="bg-[#F2F8F0] border border-[#D5E8CE] rounded-xl p-5">
</p> <h4 className="font-bold text-[#214B11] flex items-center gap-2 mb-3">
<ShieldCheck className="w-4 h-4 text-emerald-600" />
{disease.symptoms.length > 0 && ( Panduan Pencegahan
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">
Gejala:
</h4> </h4>
<ul className="space-y-1"> <ul className="space-y-2">
{disease.symptoms.slice(0, 2).map((symptom, index) => ( {disease.recommendations?.map((rec, idx) => (
<li key={index} className="text-xs text-muted-foreground flex items-start gap-1.5"> <li
<span className="text-primary mt-0.5"></span> key={idx}
{symptom} className="flex items-start gap-2 text-sm text-slate-700"
>
<CheckCircle2 className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
<span>{rec}</span>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)}
<div className="flex items-center justify-between pt-2 border-t"> <div className="bg-[#F8F4FD] border border-[#E9DDF5] rounded-xl p-5">
<div <h4 className="font-bold text-purple-900 flex items-center gap-2 mb-3">
className="h-2.5 w-2.5 rounded-full" <Pill className="w-4 h-4 text-purple-600" />
style={{ backgroundColor: disease.accentColor }} Rekomendasi Obat
/> </h4>
<span className="text-xs text-primary font-medium"> <ul className="space-y-2">
Lihat detail {finalMedicines ? (
</span> finalMedicines.map((obat, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-slate-700">
<Leaf className="w-4 h-4 text-purple-500 mt-0.5 shrink-0" />
<span>{obat}</span>
</li>
))
) : (
<li className="text-sm text-slate-500 italic">
Belum ada data rekomendasi obat spesifik.
</li>
)}
</ul>
</div>
</div> </div>
</CardContent> </div>
</Card> )}
</Link> </div>
))} );
</div> })
)} )}
</div>
{/* Warning Box */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3 items-start mt-8">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-amber-800 leading-relaxed">
<strong>Disclaimer:</strong> Informasi dalam pustaka ini bersifat
edukatif dan disarikan dari literatur ilmiah. ZeaVis Edu bukan
pengganti diagnosis lapangan oleh{" "}
<strong>Petugas Pengamat Organisme Pengganggu Tanaman (POPT)</strong>{" "}
atau ahli pertanian berlisensi. Selalu konsultasikan kondisi tanaman
Anda kepada dinas pertanian atau balai penelitian tanaman terkait.
</p>
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useUiStore } from "@/store/ui-store"; import { useUiStore } from "@/store/ui-store";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import bg from "@/assets/images/dashboard-bg.png"; import bg from "@/assets/images/dashboard-bg.webp";
export function DashboardPage() { export function DashboardPage() {
const { dashboardCompact } = useUiStore(); const { dashboardCompact } = useUiStore();
+116 -79
View File
@@ -1,102 +1,139 @@
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } 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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; Card,
import { RiskBadge } from '@/components/risk-badge'; CardContent,
import { apiClient } from '@/lib/api-client'; CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { apiClient } from "@/lib/api-client";
import { DiagnosisResultView } from "../components/diagnose-result-view";
export function DiagnosisDetailPage() { export function DiagnosisDetailPage() {
const { id } = useParams(); const { id } = useParams();
const query = useQuery({ const query = useQuery({
queryKey: ['diagnosis', id], queryKey: ["diagnosis", id],
queryFn: () => apiClient.getDiagnosis(id!), queryFn: () => apiClient.getDiagnosis(id!),
enabled: Boolean(id), enabled: Boolean(id),
}); });
if (query.isLoading) return <div className="p-8 text-center text-muted-foreground">Memuat diagnosis...</div>; if (query.isLoading)
if (query.error || !query.data) return <div className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</div>; 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; const diagnosis = query.data;
const finalImageUrl =
diagnosis.imageUrl || "/src/assets/images/placeholder-image.png";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header Page */}
<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">Detail Diagnosis</p> <h1 className="text-xl md:text-3xl font-extrabold text-[#214B11]">
<h1 className="text-xl md:text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1> Analisis & Modul Edukasi
</div> </h1>
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button> <p className="text-sm font-medium text-slate-500 mt-1">
Hasil deteksi AI dan panduan edukasi berdasarkan gambar yang
diunggah
</p>
</div> </div>
<Button asChild variant="outline">
<Link to="/diagnoses">Kembali ke Riwayat</Link>
</Button>
</div>
<section className="grid gap-6 lg:grid-cols-2"> {/* Warning if expert review is needed */}
<Card> {diagnosis.status === "needs_review" && (
<CardContent className="p-4"> <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" /> <strong>Catatan:</strong> Hasil ini masih sementara karena tingkat
</CardContent> keyakinan (confidence) di bawah standar minimum dan sedang menunggu
</Card> ulasan pakar.
</div>
)}
<Card> {/* Message if AI failed to process */}
<CardHeader> {diagnosis.failureReason && (
<div className="flex items-center justify-between gap-3"> <div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900 shadow-sm">
<CardTitle>Hasil AI</CardTitle> <strong>Gagal Memproses:</strong> {diagnosis.failureReason}
<DiagnosisStatusBadge status={diagnosis.status} /> </div>
</div> )}
<CardDescription>
{diagnosis.confidence === null ? 'Model gagal memproses gambar.' : `Confidence ${(diagnosis.confidence * 100).toFixed(1)}%`} {/* Diagnosis Result View */}
</CardDescription> <DiagnosisResultView
</CardHeader> imageUrl={finalImageUrl}
<CardContent className="space-y-4"> confidence={diagnosis.confidence ?? 0}
{diagnosis.status === 'needs_review' && ( diseaseName={diagnosis.disease?.commonName ?? "Tidak Diketahui"}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"> scientificName={diagnosis.disease?.label ?? ""}
Hasil ini masih sementara karena confidence di bawah 70% dan sedang menunggu review pakar. riskLevel={diagnosis.disease?.riskLevel ?? "Sedang"}
description={
diagnosis.disease?.description ??
diagnosis.disease?.summary ??
"Deskripsi tidak tersedia."
}
symptoms={diagnosis.disease?.symptoms ?? []}
preventions={diagnosis.disease?.recommendations ?? []}
medicines={(diagnosis.disease as any)?.medicineRecommendations ?? []}
/>
{/* List of Alternative Predictions */}
{diagnosis.predictions && diagnosis.predictions.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4 md:p-6 mt-4">
<h4 className="font-bold text-[#214B11] mb-1">
Semua Prediksi Model
</h4>
<p className="text-xs text-slate-500 mb-4">
Tebakan alternatif lain dari sistem AI ZeaVis
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{diagnosis.predictions
.sort((a, b) => a.rank - b.rank)
.map((pred) => (
<div
key={pred.id}
className="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 p-3 text-sm"
>
<span className="text-slate-600 font-medium">
{pred.modelLabel}
</span>
<span className="font-bold text-slate-700">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div> </div>
)} ))}
{diagnosis.failureReason && <p className="text-sm text-red-600">{diagnosis.failureReason}</p>} </div>
{diagnosis.disease && ( </div>
<div className="space-y-3"> )}
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{diagnosis.disease.label}</h2>
<RiskBadge level={diagnosis.disease.riskLevel} />
</div>
<p className="text-muted-foreground">{diagnosis.disease.summary}</p>
<div>
<h3 className="font-semibold">Rekomendasi</h3>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{diagnosis.disease.recommendations.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
</div>
)}
</CardContent>
</Card>
</section>
<Card> {/* Expert Notes (Only shown if already reviewed by human) */}
{diagnosis.latestReview && (
<Card className="rounded-2xl shadow-sm border-blue-200 bg-blue-50/50 mt-4">
<CardHeader> <CardHeader>
<CardTitle>Top Predictions</CardTitle> <CardTitle className="text-blue-800">
Catatan Pakar Agronomi
</CardTitle>
<CardDescription className="text-blue-600/70">
Ditinjau oleh: {diagnosis.latestReview.expert.name}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent>
{diagnosis.predictions.map((prediction) => ( <p className="text-sm text-slate-700 leading-relaxed bg-white p-4 rounded-xl border border-blue-100 shadow-sm">
<div key={prediction.id} className="flex items-center justify-between rounded-lg border p-3 text-sm"> "{diagnosis.latestReview.notes}"
<span>{prediction.rank}. {prediction.modelLabel}</span> </p>
<span className="font-semibold">{(prediction.confidence * 100).toFixed(1)}%</span>
</div>
))}
</CardContent> </CardContent>
</Card> </Card>
)}
{diagnosis.latestReview && ( </div>
<Card> );
<CardHeader> }
<CardTitle>Catatan Pakar</CardTitle>
<CardDescription>{diagnosis.latestReview.expert.name}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{diagnosis.latestReview.notes}</p>
</CardContent>
</Card>
)}
</div>
);
}
+64 -182
View File
@@ -15,10 +15,10 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; 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"; import { apiClient } from "@/lib/api-client";
import { trackScan, trackDiagnosisResult } from "@/lib/telemetry"; import { trackScan, trackDiagnosisResult } from "@/lib/telemetry";
import { DiagnosisResultView } from "../components/diagnose-result-view";
export function ScanPage() { export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
@@ -36,8 +36,6 @@ export function ScanPage() {
queryClient.invalidateQueries({ queryKey: ["diagnoses"] }); queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
setDiagnosisPreview(diagnosis); setDiagnosisPreview(diagnosis);
setPreviewOpen(true); setPreviewOpen(true);
setFileName(null);
setPreviewUrl(null);
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
}, },
onError: () => { onError: () => {
@@ -55,7 +53,6 @@ export function ScanPage() {
const url = URL.createObjectURL(f); const url = URL.createObjectURL(f);
setPreviewUrl(url); setPreviewUrl(url);
// Detect image dimensions
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
setImageDimensions({ width: img.width, height: img.height }); setImageDimensions({ width: img.width, height: img.height });
@@ -71,8 +68,7 @@ export function ScanPage() {
}; };
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [diagnosisPreview, setDiagnosisPreview] = const [diagnosisPreview, setDiagnosisPreview] = useState<DiagnosisRecord | null>(null);
useState<DiagnosisRecord | null>(null);
const diagnosesQuery = useQuery({ const diagnosesQuery = useQuery({
queryKey: ["diagnoses"], queryKey: ["diagnoses"],
@@ -82,12 +78,12 @@ export function ScanPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Main Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1> <h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1>
<p className="text-gray-500 mt-1 text-md"> <p className="text-gray-500 mt-1 text-md">
Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara real-time.
real-time.
</p> </p>
</div> </div>
<Button asChild variant="outline"> <Button asChild variant="outline">
@@ -95,17 +91,17 @@ export function ScanPage() {
</Button> </Button>
</div> </div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Upload Area */}
<div className="lg:col-span-2 space-y-4"> <div className="lg:col-span-2 space-y-4">
<Card className="w-full lg:h-117 py-3"> <Card className="w-full lg:h-117 py-3">
<CardContent className="px-6 py-4 h-full flex flex-col"> <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"> <div className="text-black flex items-center gap-2 mb-3 text-lg font-semibold">
<Camera className="text-green-500" size={25} /> <Camera className="text-green-500" size={25} />
Area Unggah Gambar Area Unggah Gambar
</div> </div>
{/* Upload area */}
{!previewUrl ? ( {!previewUrl ? (
<div className="space-y-3"> <div className="space-y-3">
<div <div
@@ -113,31 +109,23 @@ export function ScanPage() {
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
<Upload className="mx-auto text-green-500 mb-3" size={48} /> <Upload className="mx-auto text-green-500 mb-3" size={48} />
<h3 className="font-semibold text-base text-gray-800 mb-1"> <h3 className="font-semibold text-base text-gray-800 mb-1">Seret & Lepas Foto Daun</h3>
Seret & Lepas Foto Daun <p className="text-xs text-gray-500 mb-3">atau klik untuk memilih file berkas dari perangkat Anda</p>
</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="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"> <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} /> <Check size={14} /> PNG, JPG, JPEG, WEBP
PNG, JPG, JPEG
</div> </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"> <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} /> <Check size={14} /> Maks. 5 MB
Maks. 5 MB
</div> </div>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => inputRef.current?.click()} 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" 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} /> <Upload size={18} /> Pilih Berkas
Pilih Berkas
</button> </button>
</div> </div>
) : ( ) : (
@@ -150,19 +138,12 @@ export function ScanPage() {
className="w-full max-h-80 object-contain" className="w-full max-h-80 object-contain"
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
setImageDimensions({ setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
width: img.naturalWidth,
height: img.naturalHeight,
});
}} }}
/> />
{/* Scanner area detection overlay */}
{imageDimensions && ( {imageDimensions && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{/* Outer dark overlay */}
<div className="absolute inset-0 bg-black/20" /> <div className="absolute inset-0 bg-black/20" />
{/* Scan area frame - responsive to image */}
<div <div
className="relative flex items-center justify-center" className="relative flex items-center justify-center"
style={{ style={{
@@ -170,13 +151,10 @@ export function ScanPage() {
height: `${Math.min(imageDimensions.height * 0.7, 320)}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 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 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 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" /> <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"> <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-xs font-semibold tracking-widest">AREA SCAN</span>
<span className="text-[10px] text-lime-200 font-medium"> <span className="text-[10px] text-lime-200 font-medium">
@@ -186,7 +164,6 @@ export function ScanPage() {
</div> </div>
</div> </div>
)} )}
<button <button
type="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 z-10" className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors z-10"
@@ -214,8 +191,7 @@ export function ScanPage() {
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
}} }}
> >
<ImageOff className="mr-2 h-4 w-4" /> <ImageOff className="mr-2 h-4 w-4" /> Ganti Foto
Ganti Foto
</Button> </Button>
<Button <Button
onClick={handleUpload} onClick={handleUpload}
@@ -233,19 +209,16 @@ export function ScanPage() {
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/png,image/jpeg" accept="image/png,image/jpeg,image/webp"
className="hidden" className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])} onChange={(e) => handleFile(e.target.files?.[0])}
/> />
{/* Error state */}
{mutation.isError && ( {mutation.isError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600"> <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="font-medium">Upload gagal</p>
<p className="mt-1"> <p className="mt-1">
{mutation.error instanceof Error {mutation.error instanceof Error ? mutation.error.message : "Terjadi kesalahan saat memproses gambar"}
? mutation.error.message
: "Terjadi kesalahan saat memproses gambar"}
</p> </p>
</div> </div>
)} )}
@@ -253,109 +226,58 @@ export function ScanPage() {
</Card> </Card>
</div> </div>
{/* Right Sidebar */} {/* Right Column: Guidelines */}
<aside className="lg:col-span-1 flex flex-col gap-5"> <aside className="lg:col-span-1 flex flex-col gap-5">
{/* Card 1: Panduan Pengambilan Foto */}
<Card className="w-full h-max"> <Card className="w-full h-max">
<CardContent className="px-6 py-4"> <CardContent className="px-6 py-4">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-full"> <div className="inline-flex h-9 w-9 items-center justify-center rounded-full">
<CircleAlert className="text-emerald-600" size={20} /> <CircleAlert className="text-emerald-600" size={20} />
</div> </div>
<h2 className="font-bold text-emerald-800"> <h2 className="font-bold text-emerald-800">Panduan Pengambilan Foto</h2>
Panduan Pengambilan Foto
</h2>
</div> </div>
<ul className="space-y-3"> <ul className="space-y-3">
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0"> <div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<ZoomIn className="text-emerald-600" size={20} /> <ZoomIn className="text-emerald-600" size={20} />
</div> </div>
<div className="text-sm text-slate-700"> <div className="text-sm text-slate-700">Jarak 1530 cm dari daun</div>
Jarak 1530 cm dari daun
</div>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0"> <div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<Sun className="text-emerald-600" size={20} /> <Sun className="text-emerald-600" size={20} />
</div> </div>
<div className="text-sm text-slate-700"> <div className="text-sm text-slate-700">Pencahayaan cukup merata</div>
Pencahayaan cukup merata
</div>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0"> <div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<AlignCenter className="text-emerald-600" size={20} /> <AlignCenter className="text-emerald-600" size={20} />
</div> </div>
<div className="text-sm text-slate-700"> <div className="text-sm text-slate-700">Posisi daun mengisi bingkai dengan jelas</div>
Posisi daun mengisi bingkai dengan jelas
</div>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0"> <div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<Camera className="text-emerald-600" size={20} /> <Camera className="text-emerald-600" size={20} />
</div> </div>
<div className="text-sm text-slate-700"> <div className="text-sm text-slate-700">Hindari bayangan, pantulan, & blur</div>
Hindari bayangan atau pantulan pada daun & blur pada gambar
</div>
</li> </li>
</ul> </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> </CardContent>
</Card> </Card>
{/* Card 2: Persyaratan Berkas */}
<Card className="w-full h-max"> <Card className="w-full h-max">
<CardContent className="px-6 py-4"> <CardContent className="px-6 py-4">
<h3 className="font-bold text-emerald-800 flex items-center gap-3 mb-4"> <h3 className="font-bold text-emerald-800 flex items-center gap-3 mb-4">Persyaratan Berkas</h3>
Persyaratan Berkas
</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{[ {[
{ label: "Format", value: "PNG, JPG, JPEG" }, { label: "Format", value: "PNG, JPG, JPEG, WEBP" },
{ label: "Ukuran Maks.", value: "5 MB" }, { label: "Ukuran Maks.", value: "5 MB" },
{ label: "Resolusi Min.", value: "512 × 512 px" }, { label: "Resolusi Min.", value: "512 × 512 px" },
{ label: "Objek Foto", value: "Daun jagung tunggal" }, { label: "Objek Foto", value: "Daun jagung tunggal" },
].map((r, i) => ( ].map((r, i) => (
<div <div key={i} className="flex items-center justify-between py-1.5" style={{ borderBottom: i < 3 ? "1px solid #f0f0f0" : "none" }}>
key={i} <span className="text-gray-400 text-xs">{r.label}</span>
className="flex items-center justify-between py-1.5" <span className="text-sm font-medium text-gray-700">{r.value}</span>
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>
))} ))}
</div> </div>
@@ -364,18 +286,18 @@ export function ScanPage() {
</aside> </aside>
</div> </div>
{/* Result Modal */} {/* Modal Preview Diagnosis */}
<Modal <Modal
open={previewOpen} open={previewOpen}
onClose={() => { onClose={() => {
setPreviewOpen(false); setPreviewOpen(false);
setDiagnosisPreview(null); setDiagnosisPreview(null);
}} }}
title={diagnosisPreview?.disease?.commonName ?? "Hasil Diagnosis"} title="Hasil Analisis AI"
size="md" size="lg"
footer={ footer={
diagnosisPreview && ( diagnosisPreview && (
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3 w-full">
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@@ -383,59 +305,62 @@ export function ScanPage() {
setDiagnosisPreview(null); setDiagnosisPreview(null);
}} }}
> >
Tutup Tutup Jendela
</Button> </Button>
<Button <Button
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)} onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
className="bg-green-600 hover:bg-green-700" className="bg-[#214B11] hover:bg-[#1a3a0d] text-white"
> >
Lihat Detail Lengkap Masuk ke Halaman Riwayat
</Button> </Button>
</div> </div>
) )
} }
> >
{diagnosisPreview ? ( {diagnosisPreview ? (
<div className="space-y-4"> <div className="space-y-6 p-1">
<div className="flex items-center gap-2">
<DiagnosisStatusBadge status={diagnosisPreview.status} /> {/* Warning if expert review is needed */}
{diagnosisPreview.confidence !== null && ( {diagnosisPreview.status === "needs_review" && (
<span className="text-sm font-medium"> <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
Confidence: {(diagnosisPreview.confidence * 100).toFixed(1)}% <strong>Catatan:</strong> Hasil ini masih sementara karena tingkat keyakinan (confidence) di bawah standar minimum.
</span>
)}
</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> </div>
)} )}
{/* Message if AI fails to process */}
{diagnosisPreview.failureReason && ( {diagnosisPreview.failureReason && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600"> <div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900 shadow-sm">
{diagnosisPreview.failureReason} <strong>Gagal Memproses:</strong> {diagnosisPreview.failureReason}
</div> </div>
)} )}
{diagnosisPreview.predictions.length > 0 && ( <DiagnosisResultView
<div> imageUrl={previewUrl || diagnosisPreview.imageUrl || "https://placehold.co/600x400?text=Foto+Daun"}
<h4 className="text-sm font-medium mb-2">Semua Prediksi</h4> confidence={diagnosisPreview.confidence ?? 0}
<div className="space-y-2"> diseaseName={diagnosisPreview.disease?.commonName ?? "Tidak Diketahui"}
scientificName={diagnosisPreview.disease?.label ?? ""}
riskLevel={diagnosisPreview.disease?.riskLevel ?? "Sedang"}
description={diagnosisPreview.disease?.description ?? diagnosisPreview.disease?.summary ?? "Deskripsi tidak tersedia."}
symptoms={diagnosisPreview.disease?.symptoms ?? []}
preventions={diagnosisPreview.disease?.recommendations ?? []}
medicines={(diagnosisPreview.disease as any)?.medicineRecommendations ?? []}
/>
{/* All Model Predictions */}
{diagnosisPreview.predictions && diagnosisPreview.predictions.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4 md:p-6 mt-4">
<h4 className="font-bold text-[#214B11] mb-1">Semua Prediksi Model</h4>
<p className="text-xs text-slate-500 mb-4">Tebakan alternatif lain dari sistem AI ZeaVis</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{diagnosisPreview.predictions {diagnosisPreview.predictions
.sort((a, b) => a.rank - b.rank) .sort((a, b) => a.rank - b.rank)
.map((pred) => ( .map((pred) => (
<div <div
key={pred.id} key={pred.id}
className="flex items-center justify-between rounded-lg border p-2 text-sm" className="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 p-3 text-sm"
> >
<span>{pred.modelLabel}</span> <span className="text-slate-600 font-medium">{pred.modelLabel}</span>
<span className="font-semibold"> <span className="font-bold text-slate-700">
{(pred.confidence * 100).toFixed(1)}% {(pred.confidence * 100).toFixed(1)}%
</span> </span>
</div> </div>
@@ -443,52 +368,9 @@ export function ScanPage() {
</div> </div>
</div> </div>
)} )}
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">
Riwayat Diagnosis Terbaru
</h4>
{diagnosesQuery.isLoading && (
<div className="text-sm text-muted-foreground">
Memuat riwayat...
</div>
)}
{!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>
</div>
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div>
))}
</div>
)}
</div>
</div> </div>
) : null} ) : null}
</Modal> </Modal>
</div> </div>
); );
} }
+113 -68
View File
@@ -1,10 +1,20 @@
export const diseaseLabels = ['Bercak Daun', 'Hawar Daun', 'Karat Daun', 'Daun Sehat'] as const; export const diseaseLabels = [
"Bercak Daun",
"Hawar Daun",
"Karat Daun",
"Daun Sehat",
] as const;
export type DiseaseLabel = (typeof diseaseLabels)[number]; export type DiseaseLabel = (typeof diseaseLabels)[number];
export const diseaseSlugs = ['bercak-daun', 'hawar-daun', 'karat-daun', 'daun-sehat'] as const; export const diseaseSlugs = [
"bercak-daun",
"hawar-daun",
"karat-daun",
"daun-sehat",
] as const;
export type DiseaseSlug = (typeof diseaseSlugs)[number]; export type DiseaseSlug = (typeof diseaseSlugs)[number];
export const riskLevels = ['low', 'medium', 'high'] as const; export const riskLevels = ["low", "medium", "high"] as const;
export type RiskLevel = (typeof riskLevels)[number]; export type RiskLevel = (typeof riskLevels)[number];
export type DiseaseCatalogItem = { export type DiseaseCatalogItem = {
@@ -15,6 +25,8 @@ export type DiseaseCatalogItem = {
description: string; description: string;
symptoms: string[]; symptoms: string[];
recommendations: string[]; recommendations: string[];
medicineRecommendations: string[];
imageUrl: string;
riskLevel: RiskLevel; riskLevel: RiskLevel;
accentColor: string; accentColor: string;
displayOrder: number; displayOrder: number;
@@ -22,100 +34,133 @@ export type DiseaseCatalogItem = {
export const diseaseCatalogSeed: DiseaseCatalogItem[] = [ export const diseaseCatalogSeed: DiseaseCatalogItem[] = [
{ {
slug: 'bercak-daun', slug: "bercak-daun",
label: 'Bercak Daun', label: "Bercak Daun",
commonName: 'Gray Leaf Spot', commonName: "Gray Leaf Spot",
summary: 'Penyakit jamur yang menyebabkan bercak abu-abu pada daun jagung.', summary: "Penyakit jamur yang menyebabkan bercak abu-abu pada daun jagung.",
description: description:
'Bercak Daun adalah penyakit jamur yang disebabkan oleh Cercospora zeae-maydis. Penyakit ini ditandai dengan munculnya bercak berbentuk persegi panjang berwarna abu-abu dengan tepi coklat pada daun jagung. Penyakit ini berkembang pesat dalam kondisi lembab dan dapat mengurangi hasil panen secara signifikan.', "Bercak Daun adalah penyakit jamur yang disebabkan oleh Cercospora zeae-maydis. Penyakit ini ditandai dengan munculnya bercak berbentuk persegi panjang berwarna abu-abu dengan tepi coklat pada daun jagung. Penyakit ini berkembang pesat dalam kondisi lembab dan dapat mengurangi hasil panen secara signifikan.",
symptoms: [ symptoms: [
'Bercak berbentuk persegi panjang berwarna abu-abu', "Bercak berbentuk persegi panjang berwarna abu-abu",
'Tepi bercak berwarna coklat atau merah', "Tepi bercak berwarna coklat atau merah",
'Bercak biasanya muncul pada daun bagian bawah terlebih dahulu', "Bercak biasanya muncul pada daun bagian bawah terlebih dahulu",
'Dalam kondisi lembab, bercak dapat berkembang dengan cepat', "Dalam kondisi lembab, bercak dapat berkembang dengan cepat",
], ],
recommendations: [ recommendations: [
'Gunakan varietas jagung yang tahan terhadap penyakit ini', "Gunakan varietas jagung yang tahan terhadap penyakit ini",
'Terapkan rotasi tanaman dengan tanaman non-inang', "Terapkan rotasi tanaman dengan tanaman non-inang",
'Kurangi kelembaban dengan meningkatkan jarak tanam', "Kurangi kelembaban dengan meningkatkan jarak tanam",
'Aplikasikan fungisida jika diperlukan', "Aplikasikan fungisida jika diperlukan",
'Buang sisa tanaman yang terinfeksi setelah panen', "Buang sisa tanaman yang terinfeksi setelah panen",
], ],
riskLevel: 'high', medicineRecommendations: [
accentColor: '#9CA3AF', "Azoksistrobin 250 SC: fungisida strobilurin sistemik",
"Piraklostrobin (Headline 250 EC): 1 mL/L air",
"Kombinasi strobilurin + triazol untuk efikasi maksimal",
"Aplikasi preventif lebih efektif daripada kuratif",
],
imageUrl:
"https://images.unsplash.com/photo-1651029826601-9d3fae8926d2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=600",
riskLevel: "high",
accentColor: "#DC2626",
displayOrder: 1, displayOrder: 1,
}, },
{ {
slug: 'hawar-daun', slug: "hawar-daun",
label: 'Hawar Daun', label: "Hawar Daun",
commonName: 'Northern/Southern Leaf Blight', commonName: "Northern/Southern Leaf Blight",
summary: 'Penyakit jamur yang menyebabkan hawar pada daun jagung dengan gejala bercak memanjang.', summary:
"Penyakit jamur yang menyebabkan hawar pada daun jagung dengan gejala bercak memanjang.",
description: description:
'Hawar Daun adalah penyakit jamur yang disebabkan oleh Exserohilum turcicum (Northern Leaf Blight) atau Bipolaris maydis (Southern Leaf Blight). Penyakit ini ditandai dengan munculnya bercak memanjang berwarna coklat atau abu-abu pada daun jagung. Penyakit ini dapat menyebabkan kerusakan daun yang parah dan mengurangi hasil panen.', "Hawar Daun adalah penyakit jamur yang disebabkan oleh Exserohilum turcicum (Northern Leaf Blight) atau Bipolaris maydis (Southern Leaf Blight). Penyakit ini ditandai dengan munculnya bercak memanjang berwarna coklat atau abu-abu pada daun jagung. Penyakit ini dapat menyebabkan kerusakan daun yang parah dan mengurangi hasil panen.",
symptoms: [ symptoms: [
'Bercak memanjang berwarna coklat atau abu-abu', "Bercak memanjang berwarna coklat atau abu-abu",
'Bercak dapat mencapai panjang 10-15 cm', "Bercak dapat mencapai panjang 10-15 cm",
'Tepi bercak sering berwarna lebih gelap', "Tepi bercak sering berwarna lebih gelap",
'Dalam kondisi lembab, bercak dapat berkembang dengan cepat', "Dalam kondisi lembab, bercak dapat berkembang dengan cepat",
'Daun dapat mati jika terinfeksi parah', "Daun dapat mati jika terinfeksi parah",
], ],
recommendations: [ recommendations: [
'Gunakan varietas jagung yang tahan terhadap penyakit ini', "Gunakan varietas jagung yang tahan terhadap penyakit ini",
'Terapkan rotasi tanaman dengan tanaman non-inang', "Terapkan rotasi tanaman dengan tanaman non-inang",
'Kurangi kelembaban dengan meningkatkan jarak tanam', "Kurangi kelembaban dengan meningkatkan jarak tanam",
'Aplikasikan fungisida jika diperlukan', "Aplikasikan fungisida jika diperlukan",
'Buang sisa tanaman yang terinfeksi setelah panen', "Buang sisa tanaman yang terinfeksi setelah panen",
], ],
riskLevel: 'high', medicineRecommendations: [
accentColor: '#8B4513', "Mancozeb 80 WP: 2 g/L air, semprotkan 710 hari sekali",
"Propineb 70 WP: 2 g/L air, dimulai saat gejala awal",
"Azoksistrobin + Difenokonazol (Amistar Top 325 SC)",
"Klorotalonil 75 WP sebagai fungisida protektif",
],
riskLevel: "high",
accentColor: "#DC2626",
displayOrder: 2, displayOrder: 2,
imageUrl:
"https://images.unsplash.com/photo-1671559457949-5ac52323e19d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=600",
}, },
{ {
slug: 'karat-daun', slug: "karat-daun",
label: 'Karat Daun', label: "Karat Daun",
commonName: 'Common Rust', commonName: "Common Rust",
summary: 'Penyakit jamur yang menyebabkan pustula berwarna coklat kemerahan pada daun jagung.', summary:
"Penyakit jamur yang menyebabkan pustula berwarna coklat kemerahan pada daun jagung.",
description: description:
'Karat Daun adalah penyakit jamur yang disebabkan oleh Puccinia sorghi. Penyakit ini ditandai dengan munculnya pustula kecil berwarna coklat kemerahan pada permukaan daun jagung. Penyakit ini berkembang dalam kondisi lembab dan dapat mengurangi hasil panen jika tidak dikendalikan.', "Karat Daun adalah penyakit jamur yang disebabkan oleh Puccinia sorghi. Penyakit ini ditandai dengan munculnya pustula kecil berwarna coklat kemerahan pada permukaan daun jagung. Penyakit ini berkembang dalam kondisi lembab dan dapat mengurangi hasil panen jika tidak dikendalikan.",
symptoms: [ symptoms: [
'Pustula kecil berwarna coklat kemerahan pada permukaan daun', "Pustula kecil berwarna coklat kemerahan pada permukaan daun",
'Pustula biasanya muncul pada daun bagian bawah terlebih dahulu', "Pustula biasanya muncul pada daun bagian bawah terlebih dahulu",
'Dalam kondisi lembab, pustula dapat berkembang dengan cepat', "Dalam kondisi lembab, pustula dapat berkembang dengan cepat",
'Daun dapat menjadi kuning dan mati jika terinfeksi parah', "Daun dapat menjadi kuning dan mati jika terinfeksi parah",
], ],
recommendations: [ recommendations: [
'Gunakan varietas jagung yang tahan terhadap penyakit ini', "Gunakan varietas jagung yang tahan terhadap penyakit ini",
'Terapkan rotasi tanaman dengan tanaman non-inang', "Terapkan rotasi tanaman dengan tanaman non-inang",
'Kurangi kelembaban dengan meningkatkan jarak tanam', "Kurangi kelembaban dengan meningkatkan jarak tanam",
'Aplikasikan fungisida jika diperlukan', "Aplikasikan fungisida jika diperlukan",
'Buang sisa tanaman yang terinfeksi setelah panen', "Buang sisa tanaman yang terinfeksi setelah panen",
], ],
riskLevel: 'medium', medicineRecommendations: [
accentColor: '#DC2626', "Propikonazol 250 EC: 0.5 mL/L air, 2 minggu sekali",
"Tebukonazol 250 EC: fungisida sistemik triazol",
"Trifloksistrobin + Tebukonazol (Nativo 75 WG)",
"Aplikasikan saat ≥ 5% permukaan daun terinfeksi",
],
imageUrl:
"https://images.unsplash.com/photo-1660904900487-3c6fbcb3b387?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=600",
riskLevel: "medium",
accentColor: "#8B4513",
displayOrder: 3, displayOrder: 3,
}, },
{ {
slug: 'daun-sehat', slug: "daun-sehat",
label: 'Daun Sehat', label: "Daun Sehat",
commonName: 'Healthy Leaf', commonName: "Healthy Leaf",
summary: 'Daun jagung yang sehat tanpa tanda-tanda penyakit atau kerusakan.', summary:
"Daun jagung yang sehat tanpa tanda-tanda penyakit atau kerusakan.",
description: description:
'Daun Sehat menunjukkan kondisi daun jagung yang optimal tanpa adanya gejala penyakit jamur atau kerusakan lainnya. Daun yang sehat memiliki warna hijau cerah, tekstur normal, dan tidak menunjukkan bercak, pustula, atau tanda-tanda kerusakan lainnya.', "Daun Sehat menunjukkan kondisi daun jagung yang optimal tanpa adanya gejala penyakit jamur atau kerusakan lainnya. Daun yang sehat memiliki warna hijau cerah, tekstur normal, dan tidak menunjukkan bercak, pustula, atau tanda-tanda kerusakan lainnya.",
imageUrl: "https://cdn.iespa.or.id/image/manfaat-daun-jagung/public",
symptoms: [ symptoms: [
'Warna daun hijau cerah dan seragam', "Warna daun hijau cerah dan seragam",
'Tekstur daun normal dan tidak ada bercak', "Tekstur daun normal dan tidak ada bercak",
'Tidak ada pustula atau tanda-tanda penyakit lainnya', "Tidak ada pustula atau tanda-tanda penyakit lainnya",
'Daun terlihat kuat dan tidak layu', "Daun terlihat kuat dan tidak layu",
], ],
recommendations: [ recommendations: [
'Pertahankan praktik budidaya yang baik', "Pertahankan praktik budidaya yang baik",
'Lakukan monitoring rutin untuk mendeteksi penyakit sejak dini', "Lakukan monitoring rutin untuk mendeteksi penyakit sejak dini",
'Terapkan rotasi tanaman untuk menjaga kesehatan tanah', "Terapkan rotasi tanaman untuk menjaga kesehatan tanah",
'Berikan nutrisi yang cukup untuk pertumbuhan optimal', "Berikan nutrisi yang cukup untuk pertumbuhan optimal",
'Jaga kelembaban tanah yang sesuai', "Jaga kelembaban tanah yang sesuai",
], ],
riskLevel: 'low', medicineRecommendations: [
accentColor: '#22C55E', "Tidak diperlukan aplikasi fungisida saat ini",
"Pupuk foliar mikro (Fe, Mn, Zn) dapat memperkuat ketahanan",
"Pupuk organik/biostimulant untuk memperbaiki kondisi tanah",
],
riskLevel: "low",
accentColor: "#22C55E",
displayOrder: 4, displayOrder: 4,
}, },
]; ];