Merge branch 'ATLAS-PJK-GM007:main' into main

This commit is contained in:
Asep Haryana Saputra
2026-06-07 16:10:09 +07:00
committed by GitHub
33 changed files with 2173 additions and 1037 deletions
+2 -1
View File
@@ -29,6 +29,7 @@
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.15",
"typescript": "^6.0.3",
"vite": "^8.0.14"
"vite": "^8.0.14",
"vite-tsconfig-paths": "6.1.1"
}
}
+72 -25
View File
@@ -1,52 +1,99 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthGuard } from '@/components/auth-guard';
import { DashboardPage } from '@/pages/dashboard-page';
import { LandingPage } from '@/pages/landing-page';
import { CatalogPage } from '@/pages/catalog-page';
import { DiseaseDetailPage } from '@/pages/disease-detail-page';
import { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page';
import { ExpertReviewsPage } from '@/pages/expert-reviews-page';
import { LoginPage } from '@/pages/login-page';
import { RegisterPage } from '@/pages/register-page';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createBrowserRouter,
RouterProvider,
Navigate,
} from "react-router-dom";
import { AuthInitializer } from "@/components/auth-initializer";
// import { AuthGuard } from "@/components/auth-guard";
import { DashboardPage } from "@/pages/dashboard-page";
import { ScanPage } from "@/pages/scan-page";
import { LibraryPage } from "@/pages/library-page";
import { CatalogPage } from "@/pages/catalog-page";
import { DiseaseDetailPage } from "@/pages/disease-detail-page";
import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
import { DiagnosesPage } from "@/pages/diagnoses-page";
// import { LoginPage } from "@/pages/login-page";
// import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{ path: '/', element: <LandingPage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: "/", element: <Navigate to="/dashboard" replace /> },
// { path: "/login", element: <LoginPage /> },
// { path: "/register", element: <RegisterPage /> },
{
path: '/dashboard',
path: "/dashboard",
element: (
<AuthGuard>
<MainLayout>
<DashboardPage />
</AuthGuard>
</MainLayout>
),
},
{
path: '/diagnoses/:id',
path: "/scan",
element: (
<AuthGuard>
<MainLayout>
<ScanPage />
</MainLayout>
),
},
{
path: "/library",
element: (
<MainLayout>
<LibraryPage />
</MainLayout>
),
},
{
path: "/diagnoses",
element: (
<MainLayout>
<DiagnosesPage />
</MainLayout>
),
},
{
path: "/diagnoses/:id",
element: (
<MainLayout>
<DiagnosisDetailPage />
</AuthGuard>
</MainLayout>
),
},
{
path: '/expert/reviews',
path: "/expert/reviews",
element: (
<AuthGuard requireExpert>
<MainLayout>
<ExpertReviewsPage />
</AuthGuard>
</MainLayout>
),
},
{
path: "/catalog",
element: (
<MainLayout>
<CatalogPage />
</MainLayout>
),
},
{
path: "/catalog/:slug",
element: (
<MainLayout>
<DiseaseDetailPage />
</MainLayout>
),
},
{ path: '/catalog', element: <CatalogPage /> },
{ path: '/catalog/:slug', element: <DiseaseDetailPage /> },
]);
export function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthInitializer />
<RouterProvider router={router} />
</QueryClientProvider>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

@@ -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;
}
+2 -2
View File
@@ -14,8 +14,8 @@ export function DiseaseCard({ disease }: DiseaseCardProps) {
<Link to={`/catalog/${disease.slug}`} className="block transition-transform hover:scale-105">
<Card className="h-full">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-xl">{disease.commonName}</CardTitle>
<CardDescription className="mt-1">{disease.label}</CardDescription>
</div>
@@ -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>
);
}
@@ -0,0 +1,9 @@
export function Footer() {
return (
<footer className="border-t bg-[#ECF4E8]">
<div className="mx-auto max-w-6xl px-6 py-6 text-sm text-muted-foreground text-center">
© 2026 ZeaVis Edu - AI for Smart Education
</div>
</footer>
);
}
@@ -0,0 +1,17 @@
import { ReactNode } from "react";
import { Navbar } from "./navbar";
import { Footer } from "./footer";
type Props = { children: ReactNode };
export function MainLayout({ children }: Props) {
return (
<div className="min-h-screen flex flex-col bg-[#ECF4E8]">
<Navbar />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 sm:px-6 py-6 sm:py-8">
{children}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,94 @@
import { createPortal } from "react-dom";
import { Link, useLocation } from "react-router-dom";
import { X, Leaf } from "lucide-react";
type Props = {
open: boolean;
onClose: () => void;
};
const NAV_ITEMS = [
{ path: "/dashboard", label: "Dashboard" },
{ path: "/scan", label: "Scan Tanaman" },
{ path: "/diagnoses", label: "Diagnosa" },
{ path: "/catalog", label: "Pustaka", altPath: "/library" },
];
export function MobileNav({ open, onClose }: Props) {
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
const navContent = (
<div
// 2. Ubah z-50 menjadi z-[999] agar levelnya mentok paling atas
className={`fixed inset-0 z-[999] lg:hidden transition-opacity duration-300 ease-in-out ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
aria-hidden={!open}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`fixed inset-y-0 right-0 z-[999] flex w-full max-w-sm flex-col bg-[#306D29] shadow-2xl transition-transform duration-300 ease-in-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 h-20 border-b border-white/10 shrink-0">
<div className="flex items-center gap-3 font-semibold text-white">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#48A111]">
<Link to="/dashboard" onClick={onClose}>
<Leaf className="h-5 w-5" />
</Link>
</div>
<div className="leading-tight">
<div className="text-lg font-bold">ZeaVis Edu</div>
<div className="text-[13px] font-normal text-[#9AD872]">
Smart AI for Corn Disease Detection
</div>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Tutup menu"
>
<X className="h-6 w-6" />
</button>
</div>
{/* Nav items */}
<nav className="flex flex-col gap-2 overflow-y-auto px-4 py-6 flex-1">
{NAV_ITEMS.map((item) => {
const active =
isActive(item.path) || (item.altPath && isActive(item.altPath));
return (
<Link
key={item.path}
to={item.path}
onClick={onClose}
className={`rounded-full px-4 py-3 text-[16px] font-medium transition-colors ${
active
? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white"
}`}
>
{item.label}
</Link>
);
})}
</nav>
</div>
</div>
);
return typeof document !== "undefined"
? createPortal(navContent, document.body)
: navContent;
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Leaf, Menu } from "lucide-react";
import { MobileNav } from "./mobile-nav";
export function Navbar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
//
const isActive = (path: string) => location.pathname === path;
const navLinkClassName = (active: boolean) =>
[
"inline-flex items-center rounded-full px-4 py-2 text-[16px] font-medium transition-colors",
active
? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white",
].join(" ");
return (
<header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
<div className="flex items-center gap-3 font-semibold">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#48A111] text-primary-foreground">
<Link to="/dashboard">
<Leaf className="h-5 w-5" />
</Link>
</div>
<div className="leading-tight font-bold text-2xl">
<div>ZeaVis Edu</div>
<div className="text-[14px] font-normal text-[#9AD872]">
Smart AI for Corn Disease Detection
</div>
</div>
</div>
<nav className="hidden lg:flex items-center gap-2">
<Link
to="/dashboard"
className={navLinkClassName(isActive("/dashboard"))}
>
Dashboard
</Link>
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
Scan Tanaman
</Link>
<Link
to="/diagnoses"
className={navLinkClassName(isActive("/diagnoses"))}
>
Diagnosa
</Link>
<Link
to="/catalog"
className={navLinkClassName(
isActive("/catalog") || isActive("/library"),
)}
>
Pustaka
</Link>
<Link
to="/expert/reviews"
className={navLinkClassName(isActive("/expert/reviews"))}
>
Review
</Link>
</nav>
<button
onClick={() => setMobileMenuOpen(true)}
className="lg:hidden rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Buka menu navigasi"
>
<Menu className="h-6 w-6" />
</button>
<MobileNav
open={mobileMenuOpen}
onClose={() => setMobileMenuOpen(false)}
/>
</div>
</header>
);
}
@@ -1,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>
);
}
+26 -15
View File
@@ -1,25 +1,25 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50',
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-border bg-transparent hover:bg-muted',
ghost: 'hover:bg-muted',
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-border bg-transparent hover:bg-muted",
ghost: "hover:bg-[#48A111] hover:text-primary-foreground hover:rounded-[50px]",
},
size: {
default: 'h-10 px-4 py-2',
lg: 'h-12 rounded-lg px-6',
default: "h-10 px-4 py-2",
lg: "h-12 rounded-lg px-6",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
);
@@ -29,8 +29,19 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
asChild?: boolean;
};
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
export function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
@@ -0,0 +1,12 @@
import React, { ReactNode } from 'react';
type Props = {
children?: ReactNode;
className?: string;
};
export function ModalFooter({ children, className }: Props) {
return <div className={`mt-4 text-right ${className ?? ''}`}>{children}</div>;
}
export default ModalFooter;
@@ -0,0 +1,18 @@
import React, { ReactNode } from 'react';
type Props = {
children?: ReactNode;
right?: ReactNode;
className?: string;
};
export function ModalHeader({ children, right, className }: Props) {
return (
<div className={`flex items-start justify-between ${className ?? ''}`}>
<div>{children}</div>
{right && <div>{right}</div>}
</div>
);
}
export default ModalHeader;
+82
View File
@@ -0,0 +1,82 @@
import React, { ReactNode, useEffect, useRef } from "react";
import { ModalHeader } from "./modal-header";
import { ModalFooter } from "./modal-footer";
type Size = "sm" | "md" | "lg" | "full";
const sizeClass: Record<Size, string> = {
sm: "max-w-xl",
md: "max-w-3xl",
lg: "max-w-5xl",
full: "max-w-full h-full",
};
type Props = {
open: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
footer?: ReactNode;
headerRight?: ReactNode;
size?: Size;
closeOnBackdrop?: boolean;
className?: string;
};
export function Modal({
open,
onClose,
children,
title,
footer,
headerRight,
size = "md",
closeOnBackdrop = true,
className,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (open) window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (open) {
containerRef.current?.focus();
}
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => closeOnBackdrop && onClose()}
/>
<div
role="dialog"
aria-modal="true"
tabIndex={-1}
ref={containerRef}
className={`relative z-10 w-full ${sizeClass[size]} mx-4 max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-lg ${className ?? ""}`}
>
<ModalHeader right={headerRight}>
{title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
</ModalHeader>
<div className="mt-4">{children}</div>
<ModalFooter>
{footer ?? (
<button onClick={onClose} className="px-4 py-2 rounded bg-gray-200">
Tutup
</button>
)}
</ModalFooter>
</div>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.png";
declare module "*.svg";
+13 -1
View File
@@ -15,6 +15,11 @@ import type {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
export interface ApiError extends Error {
status: number;
source?: 'uploader' | 'model-service' | 'unknown';
}
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${apiBaseUrl}${endpoint}`;
const response = await fetch(url, {
@@ -25,14 +30,21 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
let source: 'uploader' | 'model-service' | 'unknown' | undefined;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
if (errorData.source) {
source = errorData.source;
}
} catch {
}
throw new Error(errorMessage);
const error = new Error(errorMessage) as ApiError;
error.status = response.status;
error.source = source;
throw error;
}
return response.json();
+5 -5
View File
@@ -1,9 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './app';
import './index.css';
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app";
import "./index.css";
ReactDOM.createRoot(document.getElementById('root')!).render(
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
+121 -56
View File
@@ -1,80 +1,96 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { AlertCircle } from 'lucide-react';
import type { RiskLevel } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { DiseaseCard } from '@/components/disease-card';
import { Card, CardContent } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';
import { apiClient } from '@/lib/api-client';
export function CatalogPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialRisk = searchParams.get('risk') as RiskLevel | null;
const [searchQuery, setSearchQuery] = useState('');
const [riskFilter, setRiskFilter] = useState<RiskLevel | 'all'>('all');
const [riskFilter, setRiskFilter] = useState<RiskLevel | 'all'>(initialRisk ?? 'all');
// Sync URL params with state
useEffect(() => {
if (initialRisk && initialRisk !== riskFilter) {
setRiskFilter(initialRisk);
}
}, [initialRisk]);
const { data: diseases, isLoading, error } = useQuery({
queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(),
});
const filteredDiseases = (diseases || []).filter((disease) => {
const matchesSearch =
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.summary.toLowerCase().includes(searchQuery.toLowerCase());
const filteredDiseases = (diseases || [])
.sort((a, b) => a.displayOrder - b.displayOrder)
.filter((disease) => {
const matchesSearch =
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.summary.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;
});
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-6xl space-y-8">
<header className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1>
<p className="mt-2 text-muted-foreground">
Pelajari tentang penyakit daun jagung dan cara penanganannya
</p>
</div>
<Button asChild variant="outline">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</div>
</header>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
<p className="text-gray-500 mt-1 text-md">
Referensi lengkap penyakit dan kondisi daun jagung yang dapat dideteksi oleh sistem AI ZeaVis Edu
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1">
<label htmlFor="search" className="block text-sm font-medium mb-2">
Cari penyakit
</label>
{/* Filters */}
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1">
<label htmlFor="search" className="block text-sm font-medium mb-2">
Cari penyakit
</label>
<div className="relative">
<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 px-3 py-2 text-sm"
className="w-full rounded-md border border-border bg-background pl-10 pr-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
Filter risiko
</label>
<select
id="risk-filter"
value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | 'all')}
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>
<div>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
Filter risiko
</label>
<select
id="risk-filter"
value={riskFilter}
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>
@@ -97,13 +113,62 @@ export function CatalogPage() {
)}
{!isLoading && !error && filteredDiseases.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{filteredDiseases.map((disease) => (
<DiseaseCard key={disease.slug} disease={disease} />
<Link
key={disease.slug}
to={`/catalog/${disease.slug}`}
className="block transition-transform hover:scale-[1.03]"
>
<Card className="h-full rounded-2xl bg-white shadow-sm hover:shadow-md">
<CardContent className="p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-lg font-bold text-[#214B11]">
{disease.commonName}
</h3>
<p className="text-sm text-slate-400 italic">
{disease.label}
</p>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
<p className="text-sm text-muted-foreground line-clamp-3">
{disease.summary}
</p>
{disease.symptoms.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">
Gejala:
</h4>
<ul className="space-y-1">
{disease.symptoms.slice(0, 2).map((symptom, index) => (
<li key={index} className="text-xs text-muted-foreground flex items-start gap-1.5">
<span className="text-primary mt-0.5"></span>
{symptom}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t">
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: disease.accentColor }}
/>
<span className="text-xs text-primary font-medium">
Lihat detail
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</main>
</div>
);
}
+312 -236
View File
@@ -1,272 +1,348 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { BookOpen, History, LayoutDashboard, TrendingUp, LogOut, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ManualClassificationForm } from '@/components/manual-classification-form';
import { ImageClassificationForm } from '@/components/image-classification-form';
import { DiagnosisCard } from '@/components/diagnosis-card';
import { useAuthStore } from '@/store/auth-store';
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useUiStore } from "@/store/ui-store";
import { apiClient } from "@/lib/api-client";
import bg from "@/assets/images/dashboard-bg.png";
export function DashboardPage() {
const { user } = useAuthStore();
const setUser = useAuthStore((state) => state.setUser);
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [diseasesQuery, summaryQuery, diagnosesQuery, classificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(),
},
{
queryKey: ['dashboard-summary'],
queryFn: () => apiClient.getDashboardSummary(),
},
{
queryKey: ['diagnoses'],
queryFn: () => apiClient.getDiagnoses(),
},
{
queryKey: ['manual-classifications'],
queryFn: () => apiClient.getManualClassifications(),
},
],
const { dashboardCompact } = useUiStore();
const summaryQuery = useQuery({
queryKey: ["dashboard-summary"],
queryFn: () => apiClient.getDashboardSummary(),
});
const createDiagnosisMutation = useMutation({
mutationFn: async (file: File) => {
return await apiClient.createDiagnosis(file);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['diagnoses'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
},
const diseasesQuery = useQuery({
queryKey: ["diseases"],
queryFn: () => apiClient.getDiseases(),
});
const createClassificationMutation = useMutation({
mutationFn: async (payload: Parameters<typeof apiClient.createManualClassification>[0]) => {
await apiClient.createManualClassification(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
queryClient.invalidateQueries({ queryKey: ['manual-classifications'] });
},
});
const logoutMutation = useMutation({
mutationFn: async () => {
await apiClient.logout();
},
onSuccess: () => {
setUser(null);
queryClient.clear();
navigate('/login');
},
});
const diseases = diseasesQuery.data || [];
const summary = summaryQuery.data;
const diagnoses = diagnosesQuery.data || [];
const classifications = classificationsQuery.data || [];
const diseases = diseasesQuery.data ?? [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
const hasError = diseasesQuery.error || summaryQuery.error;
const diseasesQuick = useMemo(() =>
diseases
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((d) => ({
name: d.commonName,
sci: d.label,
color: d.accentColor,
slug: d.slug,
})),
[diseases]
);
const missionCards = [
{
icon: Scan,
title: "Deteksi Otomatis",
description:
"Upload foto daun jagung dan AI kami akan mengidentifikasi penyakit secara instan.",
accent: "text-lime-600",
},
{
icon: BookOpen,
title: "Modul Edukasi",
description:
"Informasi detail tentang gejala, penyebab, dan dampak setiap penyakit daun jagung.",
accent: "text-amber-600",
},
{
icon: Shield,
title: "Panduan Pencegahan",
description:
"Strategi pencegahan berbasis sains untuk melindungi tanaman Anda dari infeksi.",
accent: "text-blue-600",
},
{
icon: Pill,
title: "Rekomendasi Obat",
description:
"Saran fungisida dan perawatan mandiri yang tepat sesuai jenis penyakit.",
accent: "text-violet-600",
},
];
const isLoadingData = summaryQuery.isLoading;
const hasError = Boolean(summaryQuery.error);
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-6xl space-y-8">
<header className="flex flex-col gap-4 rounded-3xl border bg-card p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-primary">
<LayoutDashboard className="h-4 w-4" /> Dashboard
</div>
<h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1>
<p className="text-muted-foreground">
{user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'}
<div className="space-y-8">
{/* Hero header */}
<header
className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
style={{ backgroundImage: `url(${bg})` }}
>
<div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-6 md:p-10">
<div className="space-y-3 w-full md:w-2/3 text-white">
<span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold">
AI FOR SMART EDUCATION
</span>
<h1 className="text-2xl md:text-4xl font-extrabold">Selamat Datang di</h1>
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tight text-[#9AD872]">
ZeaVis Edu
</h2>
<p className="mt-3 max-w-xl text-white/90">
Platform edukasi berbasis AI untuk membantu petani jagung
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
akurat.
</p>
</div>
<div className="flex gap-3">
{user?.role === 'expert' && (
<Button asChild variant="outline">
<Link to="/expert/reviews">
<CheckCircle2 className="h-4 w-4 mr-2" />
Review Pakar
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:gap-4">
<Button
asChild
variant="outline"
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Scan Daun Jagung
</Link>
</Button>
)}
<Button variant="outline" onClick={toggleDashboardCompact}>
{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'}
</Button>
<Button
variant="outline"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="h-4 w-4 mr-2" />
{logoutMutation.isPending ? 'Keluar...' : 'Keluar'}
</Button>
<Button
asChild
variant="outline"
className="px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white hover:bg-[#1E8A2A]"
>
<Link
to="/catalog"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button>
</div>
</div>
</header>
<div className="w-full md:w-1/3" />
</div>
</header>
{isLoadingData && (
<Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard...
</Card>
)}
{/* 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">
Gagal 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>
)}
{!isLoadingData && !hasError && (
<>
{summary && (
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Penyakit
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary.diseaseCount}</div>
</CardContent>
</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">
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Proyek Urgensi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Data ringkasan terbaru dari proyek Anda untuk memantau
perkembangan dan hasil deteksi penyakit daun jagung
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosis
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary.imageClassificationCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-amber-600">
{summary.needsReviewCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Risiko Tinggi
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">
{summary.riskDistribution.high}
</div>
</CardContent>
</Card>
</section>
)}
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}>
{/* Total Diagnoses — the actual count from diagnoses table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Katalog Penyakit
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosa
</CardTitle>
<CardDescription>
Pelajari tentang {diseases.length} penyakit daun jagung
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link to="/catalog">Buka Katalog</Link>
</Button>
<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>
{/* Menunggu Review */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Distribusi Risiko
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review
</CardTitle>
<CardDescription>
Penyakit berdasarkan tingkat risiko
</CardDescription>
</CardHeader>
<CardContent>
{summary && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Tinggi</span>
<span className="font-semibold">{summary.riskDistribution.high}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Sedang</span>
<span className="font-semibold">{summary.riskDistribution.medium}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Rendah</span>
<span className="font-semibold">{summary.riskDistribution.low}</span>
</div>
</div>
)}
<CardContent className="h-full flex flex-col justify-start pt-2">
<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>
{/* Diagnosa Gagal */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Gagal
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-red-600">
</div>
<Link to="/diagnoses?status=failed" className="text-red-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
{/* Risiko Tinggi — catalog count, link to catalog filtered */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Kategori Risiko Tinggi
</CardTitle>
</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="/catalog?risk=high" className="text-red-600 ml-auto hover:underline">
Lihat pustaka
</Link>
</CardContent>
</Card>
</section>
)}
<ImageClassificationForm
onSubmit={async (file) => {
await createDiagnosisMutation.mutateAsync(file);
}}
isSubmitting={createDiagnosisMutation.isPending}
latestResult={diagnoses[0] ?? null}
/>
{/* Mission section */}
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<div className="space-y-1">
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Misi Platform
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Fitur inti yang kami sediakan untuk mendukung petani jagung
Indonesia
</p>
</div>
<ManualClassificationForm
diseases={diseases}
onSubmit={async (payload) => {
await createClassificationMutation.mutateAsync(payload);
}}
isSubmitting={createClassificationMutation.isPending}
/>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
{missionCards.map((card) => {
const Icon = card.icon;
{diagnoses.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Riwayat Diagnosis
</CardTitle>
<CardDescription>
{diagnoses.length} diagnosis yang telah dibuat
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{diagnoses.slice(0, 6).map((diagnosis) => (
<DiagnosisCard key={diagnosis.id} diagnosis={diagnosis} />
))}
</div>
</CardContent>
</Card>
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 clickable, link to catalog/:slug */}
<section className="space-y-4">
<div className="mb-2 flex items-start justify-between">
<div>
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Penyakit yang Dapat Dideteksi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
{diseases.length} kelas penyakit dan kondisi daun jagung dalam sistem kami
</p>
</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">
{diseasesQuick.map((d) => (
<Link key={d.slug} to={`/catalog/${d.slug}`}>
<Card
className="rounded-2xl bg-white p-4 shadow-sm h-full transition-transform hover:scale-[1.03] hover:shadow-md cursor-pointer"
>
<CardContent className="h-full p-4 flex flex-col justify-between">
<div className="flex items-start gap-3">
<div
className="mt-1 h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: d.color }}
/>
<div>
<div className="text-sm font-bold text-[#214B11]">
{d.name}
</div>
<div className="text-xs text-slate-400 italic mt-1">
{d.sci}
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</>
)}
</div>
</main>
</section>
{/* Scan quick access */}
<div className="mt-12 flex flex-col sm:flex-row items-center gap-4 sm:gap-6 bg-[#1E8A2A] rounded-3xl p-5 sm:p-6">
<div className="flex-1 text-white">
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
<p className="text-sm text-[#9AD872] font-normal mt-2">
Unggah foto daun jagung Anda dan dapatkan hasil analisis AI
dalam hitungan detik.
</p>
</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] 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>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { RiskBadge } from "@/components/risk-badge";
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() {
const [searchParams, setSearchParams] = useSearchParams();
const initialStatus = searchParams.get("status") as DiagnosisStatus | null;
const initialRisk = searchParams.get("risk") as RiskLevel | null;
const [statusFilter, setStatusFilter] = useState<
"all" | DiagnosisStatus
>(initialStatus ?? "all");
const [riskFilter, setRiskFilter] = useState<
"all" | RiskLevel
>(initialRisk ?? "all");
const query = useQuery({
queryKey: ["diagnoses"],
queryFn: () => apiClient.getDiagnoses(),
});
const diagnoses = query.data ?? [];
const filtered = useMemo(() => {
return diagnoses.filter((d) => {
if (statusFilter !== "all" && d.status !== statusFilter) return false;
if (riskFilter !== "all") {
const level = d.disease?.riskLevel ?? "low";
if (level !== riskFilter) return false;
}
return true;
});
}, [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 (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
<p className="text-gray-500 mt-1 text-md">
Lihat hasil diagnosa dari scan yang telah dilakukan
</p>
</div>
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link to="/scan">Scan Baru</Link>
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label>
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<label className="text-sm font-medium">Risiko</label>
<select
value={riskFilter}
onChange={(e) => handleRiskChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{RISK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div className="w-full sm:w-auto sm:ml-auto text-sm text-muted-foreground">
Total: {filtered.length}
</div>
</div>
{query.isLoading ? (
<div className="py-12 text-center text-muted-foreground">
Memuat diagnosis...
</div>
) : query.isError ? (
<div className="py-12 text-center text-red-600">
Gagal memuat diagnosis
</div>
) : filtered.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Tidak ada diagnosis sesuai filter
</p>
{diagnoses.length === 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Mulai dengan{" "}
<Link to="/scan" className="text-primary underline">
melakukan scan daun
</Link>
.
</p>
)}
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{filtered.map((d) => (
<Card key={d.id} className="h-full">
<CardContent className="flex gap-4 p-4 items-start">
<img
src={d.imageUrl}
alt="Daun"
className="h-16 md:h-24 w-16 md:w-24 rounded-md object-cover bg-muted shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<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 className="flex flex-col items-end gap-1 shrink-0">
<DiagnosisStatusBadge status={d.status} />
<RiskBadge level={d.disease?.riskLevel ?? "low"} />
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
{d.confidence !== null && (
<span className="ml-2">
{(d.confidence * 100).toFixed(0)}%
</span>
)}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold text-sm shrink-0"
>
Lihat detail
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
export default DiagnosesPage;
+8 -10
View File
@@ -14,23 +14,22 @@ export function DiagnosisDetailPage() {
enabled: Boolean(id),
});
if (query.isLoading) return <main className="p-8 text-center text-muted-foreground">Memuat diagnosis...</main>;
if (query.error || !query.data) return <main className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</main>;
if (query.isLoading) return <div className="p-8 text-center text-muted-foreground">Memuat diagnosis...</div>;
if (query.error || !query.data) return <div className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</div>;
const diagnosis = query.data;
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-primary">Detail Diagnosis</p>
<h1 className="text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
<h1 className="text-xl md:text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
</div>
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
</div>
<section className="grid gap-6 lg:grid-cols-[360px_1fr]">
<section className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="p-4">
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
@@ -99,6 +98,5 @@ export function DiagnosisDetailPage() {
</Card>
)}
</div>
</main>
);
}
);
}
+95 -97
View File
@@ -1,7 +1,7 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { isDiseaseSlug } from '@zeavis/shared';
import { ArrowLeft, BookOpen, AlertCircle } from 'lucide-react';
import { ArrowLeft, AlertCircle, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';
@@ -20,37 +20,37 @@ export function DiseaseDetailPage() {
if (!validatedSlug || error || (!isLoading && !disease)) {
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-4xl">
<Button asChild variant="ghost" className="mb-8">
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button asChild variant="ghost">
<Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog
</Link>
</Button>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card>
</div>
</main>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card>
</div>
);
}
if (isLoading) {
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-4xl">
<Button asChild variant="ghost" className="mb-8">
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button asChild variant="ghost">
<Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog
</Link>
</Button>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Memuat materi...</p>
</Card>
</div>
</main>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Memuat materi...</p>
</Card>
</div>
);
}
@@ -59,88 +59,86 @@ export function DiseaseDetailPage() {
}
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-4xl space-y-8">
<div className="flex items-center justify-between">
<Button asChild variant="ghost">
<Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/dashboard">
<BookOpen className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
</div>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-4xl font-bold tracking-tight">{disease.commonName}</h1>
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
<p className="text-lg leading-relaxed">{disease.summary}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Deskripsi</CardTitle>
</CardHeader>
<CardContent>
<p className="leading-relaxed text-muted-foreground">{disease.description}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gejala</CardTitle>
<CardDescription>Tanda-tanda yang perlu diperhatikan</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{disease.symptoms.map((symptom, index) => (
<li key={index} className="flex gap-3">
<AlertCircle className="h-5 w-5 flex-shrink-0 text-primary mt-0.5" />
<span className="text-muted-foreground">{symptom}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Rekomendasi Penanganan</CardTitle>
<CardDescription>Langkah-langkah untuk mengendalikan penyakit</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3">
{disease.recommendations.map((recommendation, index) => (
<li key={index} className="flex gap-3">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{index + 1}
</span>
<span className="text-muted-foreground pt-0.5">{recommendation}</span>
</li>
))}
</ol>
</CardContent>
</Card>
<div className="flex gap-3 pt-4">
<Button asChild className="flex-1">
<Link to="/catalog">Lihat Katalog Lengkap</Link>
</Button>
<Button asChild variant="outline" className="flex-1">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button asChild variant="ghost">
<Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/dashboard">
<BookOpen className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
</div>
</main>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl md:text-4xl font-bold tracking-tight">{disease.commonName}</h1>
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
<p className="text-lg leading-relaxed">{disease.summary}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Deskripsi</CardTitle>
</CardHeader>
<CardContent>
<p className="leading-relaxed text-muted-foreground">{disease.description}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gejala</CardTitle>
<CardDescription>Tanda-tanda yang perlu diperhatikan</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{disease.symptoms.map((symptom, index) => (
<li key={index} className="flex gap-3">
<AlertCircle className="h-5 w-5 flex-shrink-0 text-primary mt-0.5" />
<span className="text-muted-foreground">{symptom}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Rekomendasi Penanganan</CardTitle>
<CardDescription>Langkah-langkah untuk mengendalikan penyakit</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3">
{disease.recommendations.map((recommendation, index) => (
<li key={index} className="flex gap-3">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{index + 1}
</span>
<span className="text-muted-foreground pt-0.5">{recommendation}</span>
</li>
))}
</ol>
</CardContent>
</Card>
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<Button asChild className="flex-1">
<Link to="/catalog">Lihat Katalog Lengkap</Link>
</Button>
<Button asChild variant="outline" className="flex-1">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</div>
</div>
);
}
+307 -241
View File
@@ -1,43 +1,61 @@
import { FormEvent, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
import type { DiseaseSlug } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
import { apiClient } from '@/lib/api-client';
import { FormEvent, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DiseaseSlug } from "@zeavis/shared";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { apiClient } from "@/lib/api-client";
export function ExpertReviewsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('diagnosis');
const [verdict, setVerdict] = useState<'verified' | 'corrected'>('verified');
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<DiseaseSlug | ''>('');
const [notes, setNotes] = useState('');
const selectedId = searchParams.get("diagnosis");
const [verdict, setVerdict] = useState<"verified" | "corrected">("verified");
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<
DiseaseSlug | ""
>("");
const [notes, setNotes] = useState("");
const queryClient = useQueryClient();
const [reviewsQuery, diseasesQuery] = useQueries({
queries: [
{ queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() },
{ queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() },
{
queryKey: ["expert-reviews"],
queryFn: () => apiClient.getExpertReviews(),
},
{ queryKey: ["diseases"], queryFn: () => apiClient.getDiseases() },
],
});
const reviews = reviewsQuery.data || [];
const diseases = diseasesQuery.data || [];
const selected = useMemo(() => reviews.find((item) => item.id === selectedId) ?? reviews[0] ?? null, [reviews, selectedId]);
const selected = useMemo(
() => reviews.find((item) => item.id === selectedId) ?? reviews[0] ?? null,
[reviews, selectedId],
);
const mutation = useMutation({
mutationFn: () => apiClient.reviewDiagnosis(selected!.id, {
verdict,
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined,
notes,
}),
mutationFn: () =>
apiClient.reviewDiagnosis(selected!.id, {
verdict,
correctedDiseaseSlug:
verdict === "corrected"
? correctedDiseaseSlug || undefined
: undefined,
notes,
}),
onSuccess: () => {
setNotes('');
setVerdict('verified');
setCorrectedDiseaseSlug('');
queryClient.invalidateQueries({ queryKey: ['expert-reviews'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
setNotes("");
setVerdict("verified");
setCorrectedDiseaseSlug("");
queryClient.invalidateQueries({ queryKey: ["expert-reviews"] });
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
},
});
@@ -47,231 +65,279 @@ export function ExpertReviewsPage() {
await mutation.mutateAsync();
}
const isLoading = reviewsQuery.isPending || diseasesQuery.isPending;
return (
<main className="min-h-screen bg-background">
<header className="border-b bg-card">
<div className="mx-auto flex max-w-7xl items-center justify-between p-6">
<h1 className="text-2xl font-bold">Expert Reviews</h1>
<Link to="/dashboard">
<Button variant="outline">Dashboard</Button>
</Link>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
<p className="text-gray-500 mt-1 text-md">
Tinjau hasil diagnosa dari scan yang telah dilakukan dan berikan
feedback untuk meningkatkan akurasi sistem AI ZeaVis Edu
</p>
</div>
</header>
</div>
<div className="mx-auto max-w-7xl p-6">
<div className="grid gap-6 lg:grid-cols-3">
{/* Left: List of reviews */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Diagnoses to Review</h2>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span>
</div>
<div className="space-y-2">
{reviewsQuery.isPending || diseasesQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading reviews and diseases...</p>
) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">No diagnoses pending review</p>
) : (
reviews.map((review) => (
<button
key={review.id}
type="button"
onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${
selected?.id === review.id
? 'ring-2 ring-primary'
: 'hover:border-primary'
}`}
>
<Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3">
<img
src={review.imageUrl}
alt="Daun jagung"
className="h-20 w-20 rounded-lg object-cover"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3>
<DiagnosisStatusBadge status={review.status} className="text-xs" />
</div>
<p className="text-xs text-muted-foreground">
{review.confidence === null ? 'Tidak ada confidence' : `${(review.confidence * 100).toFixed(1)}%`}
</p>
<p className="text-xs text-muted-foreground">{new Date(review.createdAt).toLocaleString('id-ID')}</p>
</div>
</CardContent>
</Card>
</button>
))
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left: List of reviews */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Daftar Diagnosis</h2>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
{reviews.length}
</span>
</div>
{/* Right: Detail and form */}
<div className="lg:col-span-2 space-y-6">
{reviewsQuery.isPending || diseasesQuery.isPending ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Loading reviews and diseases...
</CardContent>
</Card>
) : selected ? (
<>
{/* Detail card */}
<Card>
<CardHeader>
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
<CardDescription>
{new Date(selected.createdAt).toLocaleString('id-ID')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<img
src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`}
className="h-64 w-full rounded-lg object-cover"
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<DiagnosisStatusBadge status={selected.status} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span>
<span className="text-sm">
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`}
</span>
</div>
</div>
{/* Top predictions */}
{selected.predictions.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Top Predictions</h4>
<div className="space-y-1">
{selected.predictions.map((pred) => (
<div key={pred.id} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{pred.modelLabel}</span>
<span className="font-medium">{(pred.confidence * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Review form */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Expert Review</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Verdict selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Verdict</label>
<div className="flex gap-3">
<label htmlFor="verdict-verified" className="flex items-center gap-2">
<input
id="verdict-verified"
type="radio"
name="verdict"
value="verified"
checked={verdict === 'verified'}
onChange={(e) => {
setVerdict(e.target.value as 'verified' | 'corrected');
setCorrectedDiseaseSlug('');
}}
className="h-4 w-4"
/>
<span className="text-sm">Verified</span>
</label>
<label htmlFor="verdict-corrected" className="flex items-center gap-2">
<input
id="verdict-corrected"
type="radio"
name="verdict"
value="corrected"
checked={verdict === 'corrected'}
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
className="h-4 w-4"
/>
<span className="text-sm">Corrected</span>
</label>
</div>
</div>
{/* Disease selection (only if corrected) */}
{verdict === 'corrected' && (
<div className="space-y-2">
<label htmlFor="disease" className="text-sm font-medium">
Correct Disease
</label>
<select
id="disease"
value={correctedDiseaseSlug}
onChange={(e) => setCorrectedDiseaseSlug(e.target.value as DiseaseSlug | '')}
required={verdict === 'corrected'}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="">Select a disease...</option>
{diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName}
</option>
))}
</select>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium">
Notes <span className="text-red-500">*</span>
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Enter your review notes..."
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"
rows={4}
/>
</div>
{/* Submit button */}
<Button
type="submit"
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
className="w-full"
>
{mutation.isPending ? 'Submitting...' : 'Submit Review'}
</Button>
{mutation.isError && (
<p className="text-sm text-red-500">
Error: {mutation.error instanceof Error ? mutation.error.message : 'Unknown error'}
</p>
)}
</form>
</CardContent>
</Card>
</>
<div className="space-y-2">
{isLoading ? (
<p className="text-sm text-muted-foreground">
Memuat diagnosis...
</p>
) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">
Tidak ada diagnosis yang menunggu review
</p>
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
No diagnoses available for review
</CardContent>
</Card>
reviews.map((review) => (
<button
key={review.id}
type="button"
onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${
selected?.id === review.id
? "ring-2 ring-primary"
: "hover:border-primary"
}`}
>
<Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3 items-start">
<img
src={review.imageUrl}
alt="Daun jagung"
className="h-14 md:h-20 w-14 md:w-20 rounded-lg object-cover shrink-0"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">
{review.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<DiagnosisStatusBadge
status={review.status}
className="text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
{review.confidence === null
? "Tidak ada confidence"
: `${(review.confidence * 100).toFixed(1)}%`}
</p>
<p className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleString("id-ID")}
</p>
</div>
</CardContent>
</Card>
</button>
))
)}
</div>
</div>
{/* Right: Detail and form */}
<div className="lg:col-span-2 space-y-6">
{isLoading ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Memuat diagnosis...
</CardContent>
</Card>
) : selected ? (
<>
{/* Detail card */}
<Card>
<CardHeader>
<CardTitle>
{selected.disease?.commonName ?? "Diagnosis gagal"}
</CardTitle>
<CardDescription>
{new Date(selected.createdAt).toLocaleString("id-ID")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<img
src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? "diagnosis"}`}
className="h-48 md:h-64 w-full rounded-lg object-cover"
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<DiagnosisStatusBadge status={selected.status} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span>
<span className="text-sm">
{selected.confidence === null
? "N/A"
: `${(selected.confidence * 100).toFixed(1)}%`}
</span>
</div>
</div>
{selected.predictions.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Prediksi Teratas</h4>
<div className="space-y-1">
{selected.predictions.map((pred) => (
<div
key={pred.id}
className="flex items-center justify-between text-sm"
>
<span className="text-muted-foreground">
{pred.modelLabel}
</span>
<span className="font-medium">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Review form */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Review Pakar</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Keputusan</label>
<div className="flex flex-col sm:flex-row gap-3">
<label
htmlFor="verdict-verified"
className="flex items-center gap-2"
>
<input
id="verdict-verified"
type="radio"
name="verdict"
value="verified"
checked={verdict === "verified"}
onChange={(e) => {
setVerdict(
e.target.value as "verified" | "corrected",
);
setCorrectedDiseaseSlug("");
}}
className="h-4 w-4"
/>
<span className="text-sm">Terverifikasi</span>
</label>
<label
htmlFor="verdict-corrected"
className="flex items-center gap-2"
>
<input
id="verdict-corrected"
type="radio"
name="verdict"
value="corrected"
checked={verdict === "corrected"}
onChange={(e) =>
setVerdict(
e.target.value as "verified" | "corrected",
)
}
className="h-4 w-4"
/>
<span className="text-sm">Dikoreksi</span>
</label>
</div>
</div>
{verdict === "corrected" && (
<div className="space-y-2">
<label
htmlFor="disease"
className="text-sm font-medium"
>
Penyakit yang Benar
</label>
<select
id="disease"
value={correctedDiseaseSlug}
onChange={(e) =>
setCorrectedDiseaseSlug(
e.target.value as DiseaseSlug | "",
)
}
required={verdict === "corrected"}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="">Pilih penyakit...</option>
{diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium">
Catatan <span className="text-red-500">*</span>
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Tulis catatan review Anda..."
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"
rows={4}
/>
</div>
<Button
type="submit"
disabled={
mutation.isPending ||
(verdict === "corrected" && !correctedDiseaseSlug) ||
!notes.trim()
}
className="w-full"
>
{mutation.isPending ? "Mengirim..." : "Kirim Review"}
</Button>
{mutation.isError && (
<p className="text-sm text-red-500">
Error:{" "}
{mutation.error instanceof Error
? mutation.error.message
: "Terjadi kesalahan"}
</p>
)}
</form>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Tidak ada diagnosis yang tersedia untuk review
</CardContent>
</Card>
)}
</div>
</div>
</main>
</div>
);
}
+32 -19
View File
@@ -1,23 +1,31 @@
import { ArrowRight, Leaf, ShieldCheck, Sprout } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowRight, Leaf, ShieldCheck, Sprout } from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const features = [
{
icon: Leaf,
title: 'Katalog Penyakit Lengkap',
description: 'Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.',
title: "Katalog Penyakit Lengkap",
description:
"Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.",
},
{
icon: ShieldCheck,
title: 'Pantau Risiko Penyakit',
description: 'Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.',
title: "Pantau Risiko Penyakit",
description:
"Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.",
},
{
icon: Sprout,
title: 'Laporkan Pengamatan',
description: 'Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.',
title: "Laporkan Pengamatan",
description:
"Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.",
},
];
@@ -44,23 +52,26 @@ export function LandingPage() {
</div>
<div className="space-y-5">
<h1 className="max-w-3xl text-5xl font-bold tracking-tight sm:text-6xl">
Belajar mengenali penyakit daun jagung dan pantau pengamatan Anda.
Belajar mengenali penyakit daun jagung dan pantau pengamatan
Anda.
</h1>
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara penanganannya,
serta laporkan pengamatan Anda untuk membantu penelitian dan edukasi.
Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara
penanganannya, serta laporkan pengamatan Anda untuk membantu
penelitian dan edukasi.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg">
<Link to="/dashboard">
Buka Dashboard <ArrowRight className="ml-2 h-4 w-4" />
<Link to="/scan">
Mulai Scan Sekarang <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to="/catalog">
Lihat Katalog Penyakit
</Link>
<Link to="/library">Pustaka Penyakit</Link>
</Button>
<Button asChild size="lg" variant="ghost">
<Link to="/dashboard">Dashboard</Link>
</Button>
</div>
</div>
@@ -74,7 +85,9 @@ export function LandingPage() {
</div>
<div>
<CardTitle className="text-xl">{feature.title}</CardTitle>
<CardDescription className="mt-2 leading-6">{feature.description}</CardDescription>
<CardDescription className="mt-2 leading-6">
{feature.description}
</CardDescription>
</div>
</CardHeader>
</Card>
+97
View File
@@ -0,0 +1,97 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { DiseaseCard } from "@/components/disease-card";
import { apiClient } from "@/lib/api-client";
import type { RiskLevel } from "@zeavis/shared";
export function LibraryPage() {
const [searchQuery, setSearchQuery] = useState("");
const [riskFilter, setRiskFilter] = useState<RiskLevel | "all">("all");
const { data: diseases, isLoading, error } = useQuery({
queryKey: ["diseases"],
queryFn: () => apiClient.getDiseases(),
});
const filteredDiseases = (diseases || []).filter((disease) => {
const matchesSearch =
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.summary.toLowerCase().includes(searchQuery.toLowerCase());
const matchesRisk = riskFilter === "all" || disease.riskLevel === riskFilter;
return matchesSearch && matchesRisk;
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
<p className="text-gray-500 mt-1 text-md">
Referensi lengkap penyakit daun jagung yang dapat dideteksi oleh sistem AI
</p>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1">
<label htmlFor="search" className="block text-sm font-medium mb-2">
Cari penyakit
</label>
<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 px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
Filter risiko
</label>
<select
id="risk-filter"
value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | "all")}
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 && (
<div className="py-12 text-center text-muted-foreground">
Memuat pustaka penyakit...
</div>
)}
{error && (
<div className="py-12 text-center text-red-600">
Gagal memuat pustaka penyakit
</div>
)}
{!isLoading && !error && filteredDiseases.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
Tidak ada penyakit yang cocok dengan filter ini.
</div>
)}
{!isLoading && !error && filteredDiseases.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredDiseases.map((disease) => (
<DiseaseCard key={disease.slug} disease={disease} />
))}
</div>
)}
</div>
);
}
+22 -13
View File
@@ -1,35 +1,41 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthForm } from '@/components/auth-form';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store";
export function LoginPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null);
const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() });
const meQuery = useQuery({
queryKey: ["auth", "me"],
queryFn: () => apiClient.getMe(),
});
const mutation = useMutation({
mutationFn: apiClient.login,
onSuccess: (response) => {
setUser(response.user);
queryClient.setQueryData(['auth', 'me'], response);
navigate('/dashboard');
queryClient.setQueryData(["auth", "me"], response);
navigate("/dashboard");
},
onError: (err) => setError(err instanceof Error ? err.message : 'Login gagal'),
onError: (err) =>
setError(err instanceof Error ? err.message : "Login gagal"),
});
return (
<main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full space-y-4">
<div className="w-full max-w-sm md:max-w-md space-y-4">
<AuthForm
mode="login"
isSubmitting={mutation.isPending}
error={error}
googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)}
googleOAuthEnabled={Boolean(
meQuery.data?.features.googleOAuthEnabled,
)}
onSubmit={async ({ email, password }) => {
setError(null);
return mutation.mutateAsync({ email, password });
@@ -37,7 +43,10 @@ export function LoginPage() {
onFieldChange={() => setError(null)}
/>
<p className="text-center text-sm text-muted-foreground">
Belum punya akun? <Link className="text-primary" to="/register">Daftar</Link>
Belum punya akun?{" "}
<Link className="text-primary" to="/register">
Daftar
</Link>
</p>
</div>
</main>
+23 -14
View File
@@ -1,43 +1,52 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthForm } from '@/components/auth-form';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store";
export function RegisterPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null);
const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() });
const meQuery = useQuery({
queryKey: ["auth", "me"],
queryFn: () => apiClient.getMe(),
});
const mutation = useMutation({
mutationFn: apiClient.register,
onSuccess: (response) => {
setUser(response.user);
queryClient.setQueryData(['auth', 'me'], response);
navigate('/dashboard');
queryClient.setQueryData(["auth", "me"], response);
navigate("/dashboard");
},
onError: (err) => setError(err instanceof Error ? err.message : 'Registrasi gagal'),
onError: (err) =>
setError(err instanceof Error ? err.message : "Registrasi gagal"),
});
return (
<main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full space-y-4">
<div className="w-full max-w-sm md:max-w-md space-y-4">
<AuthForm
mode="register"
isSubmitting={mutation.isPending}
error={error}
googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)}
googleOAuthEnabled={Boolean(
meQuery.data?.features.googleOAuthEnabled,
)}
onSubmit={async ({ name, email, password }) => {
setError(null);
return mutation.mutateAsync({ name: name ?? '', email, password });
return mutation.mutateAsync({ name: name ?? "", email, password });
}}
onFieldChange={() => setError(null)}
/>
<p className="text-center text-sm text-muted-foreground">
Sudah punya akun? <Link className="text-primary" to="/login">Masuk</Link>
Sudah punya akun?{" "}
<Link className="text-primary" to="/login">
Masuk
</Link>
</p>
</div>
</main>
+488
View File
@@ -0,0 +1,488 @@
import { useRef, useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import {
Camera,
Upload,
X,
ImageOff,
CircleAlert,
ZoomIn,
Sun,
AlignCenter,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import type { DiagnosisRecord } from "@zeavis/shared";
import { apiClient } from "@/lib/api-client";
export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (file: File) => apiClient.createDiagnosis(file),
onSuccess: (diagnosis) => {
queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
setDiagnosisPreview(diagnosis);
setPreviewOpen(true);
setFileName(null);
setPreviewUrl(null);
if (inputRef.current) inputRef.current.value = "";
},
});
const handleFile = (f?: File) => {
if (!f) return;
if (f.size > 5 * 1024 * 1024) {
alert("Ukuran file melebihi batas 5MB");
return;
}
setFileName(f.name);
const url = URL.createObjectURL(f);
setPreviewUrl(url);
// Detect image dimensions
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
};
img.src = url;
};
const handleUpload = () => {
const file = inputRef.current?.files?.[0];
if (!file) return;
mutation.mutate(file);
};
const [previewOpen, setPreviewOpen] = useState(false);
const [diagnosisPreview, setDiagnosisPreview] =
useState<DiagnosisRecord | null>(null);
const diagnosesQuery = useQuery({
queryKey: ["diagnoses"],
queryFn: () => apiClient.getDiagnoses(),
enabled: previewOpen,
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1>
<p className="text-gray-500 mt-1 text-md">
Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara
real-time.
</p>
</div>
<Button asChild variant="outline">
<Link to="/diagnoses">Riwayat Diagnosis</Link>
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Card className="w-full lg:h-117 py-3">
<CardContent className="px-6 py-4 h-full flex flex-col">
{/* Left Sidebar */}
<div className="text-black flex items-center gap-2 mb-3 text-lg font-semibold">
<Camera className="text-green-500" size={25} />
Area Unggah Gambar
</div>
{/* Upload area */}
{!previewUrl ? (
<div className="space-y-3">
<div
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
onClick={() => inputRef.current?.click()}
>
<Upload className="mx-auto text-green-500 mb-3" size={48} />
<h3 className="font-semibold text-base text-gray-800 mb-1">
Seret & Lepas Foto Daun
</h3>
<p className="text-xs text-gray-500 mb-3">
atau klik untuk memilih file berkas dari perangkat Anda
</p>
<div className="flex flex-wrap gap-2 justify-center">
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
<Check size={14} />
PNG, JPG, JPEG
</div>
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
<Check size={14} />
Maks. 5 MB
</div>
</div>
</div>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Upload size={18} />
Pilih Berkas
</button>
</div>
) : (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-muted w-full">
<img
ref={imageRef}
src={previewUrl}
alt="Preview"
className="w-full max-h-80 object-contain"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
}}
/>
{/* Scanner area detection overlay */}
{imageDimensions && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{/* Outer dark overlay */}
<div className="absolute inset-0 bg-black/20" />
{/* Scan area frame - responsive to image */}
<div
className="relative flex items-center justify-center"
style={{
width: `${Math.min(imageDimensions.width * 0.7, 280)}px`,
height: `${Math.min(imageDimensions.height * 0.7, 320)}px`,
}}
>
{/* Corner markers */}
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-lime-300" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-lime-300" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-lime-300" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-lime-300" />
{/* Center text */}
<div className="text-white text-center flex flex-col gap-1">
<span className="text-xs font-semibold tracking-widest">AREA SCAN</span>
<span className="text-[10px] text-lime-200 font-medium">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
</div>
</div>
)}
<button
type="button"
className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors z-10"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
setImageDimensions(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 flex-wrap gap-3">
<Button
variant="outline"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
setImageDimensions(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>
{/* Right Sidebar */}
<aside className="lg:col-span-1 flex flex-col gap-5">
{/* Card 1: Panduan Pengambilan Foto */}
<Card className="w-full h-max">
<CardContent className="px-6 py-4">
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-full">
<CircleAlert className="text-emerald-600" size={20} />
</div>
<h2 className="font-bold text-emerald-800">
Panduan Pengambilan Foto
</h2>
</div>
<ul className="space-y-3">
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<ZoomIn className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Jarak 1530 cm dari daun
</div>
</li>
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<Sun className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Pencahayaan cukup merata
</div>
</li>
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<AlignCenter className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Posisi daun mengisi bingkai dengan jelas
</div>
</li>
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<Camera className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Hindari bayangan atau pantulan pada daun & blur pada gambar
</div>
</li>
</ul>
<div className="mt-4">
<div className="rounded-lg overflow-hidden mt-4">
<div className="relative h-36 overflow-hidden bg-[url('/src/assets/images/scan-guide-bg.png')] bg-cover bg-center text-white md:h-44">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-0 bg-linear-to-b from-black/5 via-transparent to-black/35" />
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-5 text-center">
<div className="relative flex h-full w-30 items-center justify-center mt-8">
<span className="absolute left-0 top-0 h-4 w-4 border-l-2 border-t-2 border-l-lime-300 border-t-lime-300" />
<span className="absolute right-0 top-0 h-4 w-4 border-r-2 border-t-2 border-r-lime-300 border-t-lime-300" />
<span className="absolute bottom-0 left-0 h-4 w-4 border-b-2 border-l-2 border-b-lime-300 border-l-lime-300" />
<span className="absolute bottom-0 right-0 h-4 w-4 border-b-2 border-r-2 border-b-lime-300 border-r-lime-300" />
<span className="relative z-10 text-xs font-semibold tracking-[0.28em] text-white">
AREA SCAN
</span>
</div>
<div className="text-[11px] font-medium text-lime-100 bg-black/30 w-full py-1 rounded">
Posisi ideal: daun memenuhi bingkai
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Card 2: Persyaratan Berkas */}
<Card className="w-full h-max">
<CardContent className="px-6 py-4">
<h3 className="font-bold text-emerald-800 flex items-center gap-3 mb-4">
Persyaratan Berkas
</h3>
<div className="flex flex-col gap-2">
{[
{ label: "Format", value: "PNG, JPG, JPEG" },
{ label: "Ukuran Maks.", value: "5 MB" },
{ label: "Resolusi Min.", value: "512 × 512 px" },
{ label: "Objek Foto", value: "Daun jagung tunggal" },
].map((r, i) => (
<div
key={i}
className="flex items-center justify-between py-1.5"
style={{
borderBottom: i < 3 ? "1px solid #f0f0f0" : "none",
}}
>
<span
className="text-gray-400"
style={{ fontSize: "0.75rem" }}
>
{r.label}
</span>
<span className="text-sm font-medium text-gray-700">
{r.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
{/* Result Modal */}
<Modal
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
title={diagnosisPreview?.disease?.commonName ?? "Hasil Diagnosis"}
size="md"
footer={
diagnosisPreview && (
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
onClick={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
>
Tutup
</Button>
<Button
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
className="bg-green-600 hover:bg-green-700"
>
Lihat Detail Lengkap
</Button>
</div>
)
}
>
{diagnosisPreview ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<DiagnosisStatusBadge status={diagnosisPreview.status} />
{diagnosisPreview.confidence !== null && (
<span className="text-sm font-medium">
Confidence: {(diagnosisPreview.confidence * 100).toFixed(1)}%
</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>
)}
{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">
<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>
) : null}
</Modal>
</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: [],
};
+2 -1
View File
@@ -1,4 +1,5 @@
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import path from 'node:path';
import { defineConfig, loadEnv } from 'vite';
@@ -7,7 +8,7 @@ export default defineConfig(({ mode }) => {
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
return {
plugins: [react()],
plugins: [react(), tsconfigPaths()],
server: {
proxy: {
'/api': apiProxyTarget,
+7
View File
@@ -50,6 +50,7 @@
"postcss": "^8.5.15",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vite-tsconfig-paths": "6.1.1",
},
},
"packages/shared": {
@@ -297,6 +298,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -381,6 +384,8 @@
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="],
@@ -393,6 +398,8 @@
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
"zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],