Merge branch 'main' of https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu into selly/frontend

This commit is contained in:
seriouselly
2026-06-04 19:58:40 +07:00
20 changed files with 1188 additions and 1426 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ node_modules/
.env .env
.env.* .env.*
!.env.example !.env.example
.codegraph/
dist/ dist/
build/ build/
coverage/ coverage/
+64 -31
View File
@@ -4,6 +4,8 @@ import {
RouterProvider, RouterProvider,
Navigate, Navigate,
} from "react-router-dom"; } from "react-router-dom";
import { AuthInitializer } from "@/components/auth-initializer";
import { AuthGuard } from "@/components/auth-guard";
import { DashboardPage } from "@/pages/dashboard-page"; import { DashboardPage } from "@/pages/dashboard-page";
import { ScanPage } from "@/pages/scan-page"; import { ScanPage } from "@/pages/scan-page";
import { LibraryPage } from "@/pages/library-page"; import { LibraryPage } from "@/pages/library-page";
@@ -12,67 +14,98 @@ import { DiseaseDetailPage } from "@/pages/disease-detail-page";
import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page"; import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
import { ExpertReviewsPage } from "@/pages/expert-reviews-page"; import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
import { DiagnosesPage } from "@/pages/diagnoses-page"; import { DiagnosesPage } from "@/pages/diagnoses-page";
import { LoginPage } from "@/pages/login-page";
import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout"; import { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/login" replace /> },
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ {
path: "/", path: "/dashboard",
element: <Navigate to="/dashboard" replace />, element: (
}, <AuthGuard>
{ <MainLayout>
path: "/", <DashboardPage />
element: <Navigate to="/dashboard" replace />, </MainLayout>
</AuthGuard>
),
}, },
{ {
path: "/scan", path: "/scan",
element: ( element: (
<MainLayout> <AuthGuard>
<ScanPage /> <MainLayout>
</MainLayout> <ScanPage />
</MainLayout>
</AuthGuard>
), ),
}, },
{ {
path: "/library", path: "/library",
element: ( element: (
<MainLayout> <AuthGuard>
<LibraryPage /> <MainLayout>
</MainLayout> <LibraryPage />
</MainLayout>
</AuthGuard>
), ),
}, },
{
path: "/dashboard",
element: (
<MainLayout>
<DashboardPage />
</MainLayout>
),
},
{
path: "/diagnoses/:id",
element: <DiagnosisDetailPage />,
},
{ {
path: "/diagnoses", path: "/diagnoses",
element: ( element: (
<MainLayout> <AuthGuard>
<DiagnosesPage /> <MainLayout>
</MainLayout> <DiagnosesPage />
</MainLayout>
</AuthGuard>
),
},
{
path: "/diagnoses/:id",
element: (
<AuthGuard>
<MainLayout>
<DiagnosisDetailPage />
</MainLayout>
</AuthGuard>
), ),
}, },
{ {
path: "/expert/reviews", path: "/expert/reviews",
element: <ExpertReviewsPage />, element: (
<AuthGuard requireExpert>
<MainLayout>
<ExpertReviewsPage />
</MainLayout>
</AuthGuard>
),
},
{
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() { export function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthInitializer />
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
); );
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
export function AuthInitializer() {
const setUser = useAuthStore((state) => state.setUser);
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(),
retry: false,
});
useEffect(() => {
if (query.data) {
setUser(query.data.user);
}
}, [query.data, setUser]);
return null;
}
+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"> <Link to={`/catalog/${disease.slug}`} className="block transition-transform hover:scale-105">
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1"> <div className="min-w-0 flex-1">
<CardTitle className="text-xl">{disease.commonName}</CardTitle> <CardTitle className="text-xl">{disease.commonName}</CardTitle>
<CardDescription className="mt-1">{disease.label}</CardDescription> <CardDescription className="mt-1">{disease.label}</CardDescription>
</div> </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>
);
}
+28 -24
View File
@@ -10,14 +10,14 @@ export function Navbar() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const isDashboard = location.pathname === "/dashboard"; const user = useAuthStore((state) => state.user);
const isScan = location.pathname === "/scan";
const isLibrary = location.pathname === "/library";
const navLinkClassName = (isActive: boolean) => const isActive = (path: string) => location.pathname === path;
const navLinkClassName = (active: boolean) =>
[ [
"inline-flex items-center rounded-full px-4 py-2 text-[18px] font-medium transition-colors", "inline-flex items-center rounded-full px-4 py-2 text-[16px] font-medium transition-colors",
isActive active
? "bg-[#48A111] text-white shadow-sm" ? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white", : "text-white/85 hover:bg-white/10 hover:text-white",
].join(" "); ].join(" ");
@@ -29,12 +29,10 @@ export function Navbar() {
onSuccess: () => { onSuccess: () => {
setUser(null); setUser(null);
queryClient.clear(); queryClient.clear();
navigate("/"); navigate("/login");
}, },
}); });
const user = useAuthStore((state) => state.user);
return ( return (
<header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur"> <header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6"> <div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
@@ -52,27 +50,33 @@ export function Navbar() {
</div> </div>
</div> </div>
<nav className="flex items-center gap-3"> <nav className="flex items-center gap-2">
<Button asChild variant="ghost" className="rounded-full"> <Link to="/dashboard" className={navLinkClassName(isActive("/dashboard"))}>
<Link to="/dashboard" className={navLinkClassName(isDashboard)}> Dashboard
Dashboard </Link>
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
Scan
</Link>
<Link to="/diagnoses" className={navLinkClassName(isActive("/diagnoses"))}>
Diagnosa
</Link>
<Link to="/catalog" className={navLinkClassName(isActive("/catalog") || isActive("/library"))}>
Pustaka
</Link>
{user?.role === "expert" && (
<Link
to="/expert/reviews"
className={navLinkClassName(isActive("/expert/reviews"))}
>
Review
</Link> </Link>
</Button> )}
<Button asChild variant="ghost" className="rounded-full">
<Link to="/scan" className={navLinkClassName(isScan)}>
Scan Tanaman
</Link>
</Button>
<Button asChild variant="ghost" className="rounded-full">
<Link to="/library" className={navLinkClassName(isLibrary)}>
Pustaka Penyakit
</Link>
</Button>
{user && ( {user && (
<Button <Button
variant="outline" variant="outline"
onClick={() => logoutMutation.mutate()} onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending} disabled={logoutMutation.isPending}
className="ml-2 bg-white/10 border-white/20 text-white hover:bg-white/20"
> >
{logoutMutation.isPending ? "Keluar..." : "Keluar"} {logoutMutation.isPending ? "Keluar..." : "Keluar"}
</Button> </Button>
@@ -1,132 +0,0 @@
import { useEffect, useState } from 'react';
import type { DiseaseSlug, DiseaseCatalogItem, ManualClassificationRequest } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export interface ManualClassificationFormProps {
diseases: DiseaseCatalogItem[];
onSubmit: (payload: ManualClassificationRequest) => Promise<void>;
isSubmitting: boolean;
}
export function ManualClassificationForm({
diseases,
onSubmit,
isSubmitting,
}: ManualClassificationFormProps) {
const [selectedSlug, setSelectedSlug] = useState<DiseaseSlug | ''>('');
const [observation, setObservation] = useState('');
const [location, setLocation] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (diseases.length > 0 && !selectedSlug) {
setSelectedSlug(diseases[0].slug);
}
}, [diseases, selectedSlug]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!selectedSlug || !observation.trim() || !location.trim()) {
setError('Semua field harus diisi');
return;
}
try {
await onSubmit({
diseaseSlug: selectedSlug,
observation: observation.trim(),
location: location.trim(),
});
setObservation('');
setLocation('');
if (diseases.length > 0) {
setSelectedSlug(diseases[0].slug);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengirim data');
}
};
const isFormValid = selectedSlug && observation.trim() && location.trim();
return (
<Card>
<CardHeader>
<CardTitle>Klasifikasi Manual</CardTitle>
<CardDescription>Laporkan penyakit daun jagung yang Anda temukan</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">{error}</div>
)}
<div>
<label htmlFor="disease" className="block text-sm font-medium">
Jenis Penyakit
</label>
<select
id="disease"
value={selectedSlug}
onChange={(e) => setSelectedSlug(e.target.value as DiseaseSlug)}
disabled={diseases.length === 0 || isSubmitting}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
>
{diseases.length === 0 ? (
<option value="">Memuat penyakit...</option>
) : (
diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName} ({disease.label})
</option>
))
)}
</select>
</div>
<div>
<label htmlFor="observation" className="block text-sm font-medium">
Pengamatan
</label>
<textarea
id="observation"
value={observation}
onChange={(e) => setObservation(e.target.value)}
placeholder="Jelaskan gejala atau kondisi daun yang Anda amati..."
disabled={isSubmitting}
rows={4}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium">
Lokasi
</label>
<input
id="location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Lokasi penemuan penyakit (desa, kecamatan, kabupaten)"
disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<Button
type="submit"
disabled={!isFormValid || isSubmitting || diseases.length === 0}
className="w-full"
>
{isSubmitting ? 'Mengirim...' : 'Kirim Laporan'}
</Button>
</form>
</CardContent>
</Card>
);
}
-61
View File
@@ -1,61 +0,0 @@
export const mockDiseases = [
{
id: "nlb",
name: "Hawar Daun",
slug: "hawar-daun",
severity: "Tinggi",
imageUrl: "https://via.placeholder.com/320x180?text=Hawar+Daun",
pathogen: "Exserohilum turcicum",
description:
"Penyakit jamur yang menyebabkan lesi pada daun dan dapat menurunkan hasil panen.",
symptoms: [
"Lesi lonjong berbentuk cerutu, 2.5 - 15 cm",
"Warna abu-kehijauan berkembang menjadi coklat -abu",
"Nekrosis parah pada seluruh permukaan daun",
],
prevention:
"Gunakan varietas tahan, rotasi tanaman, dan hapus sisa tanaman terinfeksi.",
},
{
id: "rust",
name: "Karat Daun",
slug: "karat-daun",
imageUrl: "https://via.placeholder.com/320x180?text=Karat+Daun",
pathogen: "Puccinia spp.",
severity: "Sedang",
description:
"Pustulan oranye pada permukaan daun yang dapat mengurangi fotosintesis.",
symptoms: [
"Pustula oranye pada permukaan daun",
"Daun menguning dan rontok pada serangan berat",
],
prevention:
"Hindari kelembapan tinggi, gunakan fungisida bila perlu, dan perbaiki sirkulasi udara.",
},
{
id: "spot",
name: "Bercak Abu-abu",
slug: "bercak-abu-abu",
imageUrl: "https://via.placeholder.com/320x180?text=Bercak+Abu-abu",
pathogen: "Cercospora spp.",
severity: "Rendah",
description:
"Bercak kecil berwarna abu-abu yang umumnya tidak menyebabkan kematian tanaman.",
symptoms: [
"Bercak kecil bundar hingga tidak beraturan",
"Daun kering pada area bercak",
],
prevention:
"Praktek sanitasi, buang daun yang berat terinfeksi, dan pemantauan rutin.",
},
{
id: "healthy",
name: "Daun Sehat",
slug: "daun-sehat",
imageUrl: "https://via.placeholder.com/320x180?text=Daun+Sehat",
severity: "Sehat",
description: "Daun tanpa tanda penyakit.",
symptoms: [],
prevention: "-",
},
];
+119 -55
View File
@@ -1,80 +1,95 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 type { RiskLevel } from '@zeavis/shared';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { DiseaseCard } from '@/components/disease-card'; import { RiskBadge } from '@/components/risk-badge';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
export function CatalogPage() { export function CatalogPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialRisk = searchParams.get('risk') as RiskLevel | null;
const [searchQuery, setSearchQuery] = useState(''); 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({ const { data: diseases, isLoading, error } = useQuery({
queryKey: ['diseases'], queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(), queryFn: () => apiClient.getDiseases(),
}); });
const filteredDiseases = (diseases || []).filter((disease) => { const filteredDiseases = (diseases || [])
const matchesSearch = .sort((a, b) => a.displayOrder - b.displayOrder)
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) || .filter((disease) => {
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch =
disease.summary.toLowerCase().includes(searchQuery.toLowerCase()); 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 ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-6xl space-y-8"> <div className="flex items-center justify-between">
<header className="space-y-4"> <div>
<div className="flex items-center justify-between"> <p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
<div> <h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1>
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1> </div>
<p className="mt-2 text-muted-foreground"> </div>
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-4"> {/* Filters */}
<div className="flex flex-col gap-4 md:flex-row md:items-end"> <div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1"> <div className="flex-1">
<label htmlFor="search" className="block text-sm font-medium mb-2"> <label htmlFor="search" className="block text-sm font-medium mb-2">
Cari penyakit Cari penyakit
</label> </label>
<div className="relative">
<AlertCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input <input
id="search" id="search"
type="text" type="text"
placeholder="Cari berdasarkan nama atau gejala..." placeholder="Cari berdasarkan nama atau gejala..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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>
<div> </div>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2"> <div>
Filter risiko <label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
</label> Filter risiko
<select </label>
id="risk-filter" <select
value={riskFilter} id="risk-filter"
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | 'all')} value={riskFilter}
className="rounded-md border border-border bg-background px-3 py-2 text-sm" onChange={(e) => {
> const v = e.target.value as RiskLevel | 'all';
<option value="all">Semua Risiko</option> setRiskFilter(v);
<option value="low">Risiko Rendah</option> const next = new URLSearchParams(searchParams);
<option value="medium">Risiko Sedang</option> if (v === 'all') next.delete('risk');
<option value="high">Risiko Tinggi</option> else next.set('risk', v);
</select> setSearchParams(next);
</div> }}
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> </div>
@@ -97,13 +112,62 @@ export function CatalogPage() {
)} )}
{!isLoading && !error && filteredDiseases.length > 0 && ( {!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) => ( {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>
)} )}
</div> </div>
</main>
); );
} }
+282 -279
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react"; import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -14,7 +15,25 @@ export function DashboardPage() {
queryFn: () => apiClient.getDashboardSummary(), queryFn: () => apiClient.getDashboardSummary(),
}); });
const diseasesQuery = useQuery({
queryKey: ["diseases"],
queryFn: () => apiClient.getDiseases(),
});
const summary = summaryQuery.data; const summary = summaryQuery.data;
const diseases = diseasesQuery.data ?? [];
const diseasesQuick = useMemo(() =>
diseases
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((d) => ({
name: d.commonName,
sci: d.label,
color: d.accentColor,
slug: d.slug,
})),
[diseases]
);
const missionCards = [ const missionCards = [
{ {
@@ -47,299 +66,283 @@ export function DashboardPage() {
}, },
]; ];
const diseasesQuick = [
{ name: "Hawar Daun", sci: "Northern Leaf Blight", color: "#b91c1c" },
{ name: "Karat Daun", sci: "Common Rust", color: "#d97706" },
{ name: "Bercak Abu-abu", sci: "Gray Leaf Spot", color: "#6b7280" },
{ name: "Daun Sehat", sci: "Healthy", color: "#16a34a" },
];
const isLoadingData = summaryQuery.isLoading; const isLoadingData = summaryQuery.isLoading;
const hasError = Boolean(summaryQuery.error); const hasError = Boolean(summaryQuery.error);
return ( return (
<main className="p-6"> <div className="space-y-8">
<div className="mx-auto max-w-6xl space-y-8"> {/* Hero header */}
{/* Hero header */} <header
<header className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm" style={{ backgroundImage: `url(${bg})` }}
style={{ backgroundImage: `url(${bg})` }} >
> <div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
<div className="absolute inset-0 bg-linear-to-b from-[#2F6E1A]/60 to-black/30" /> <div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-10">
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-10"> <div className="space-y-3 w-full md:w-2/3 text-white">
<div className="space-y-3 w-full md:w-2/3 text-white"> <span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold">
<span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold"> AI FOR SMART EDUCATION
AI FOR SMART EDUCATION </span>
</span> <h1 className="text-4xl font-extrabold">Selamat Datang di</h1>
<h1 className="text-4xl font-extrabold">Selamat Datang di</h1> <h2 className="text-4xl font-extrabold tracking-tight text-[#9AD872]">
<h2 className="text-4xl font-extrabold tracking-tight text-[#9AD872]"> ZeaVis Edu
ZeaVis Edu </h2>
</h2> <p className="mt-3 max-w-xl text-white/90">
<p className="mt-3 max-w-xl text-white/90"> Platform edukasi berbasis AI untuk membantu petani jagung
Platform edukasi berbasis AI untuk membantu petani jagung Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan akurat.
akurat. </p>
</p> <div className="mt-6 flex items-center gap-4">
<div className="mt-6 flex items-center gap-4">
<Button
asChild
variant="outline"
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-6 py-6 text-lg font-semibold text-white"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Scan Daun Jagung
</Link>
</Button>
<Button
asChild
variant="outline"
className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
>
<Link
to="/library"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button>
</div>
</div>
<div className="w-full md:w-1/3" />
</div>
</header>
{/* Loading / Error states */}
{isLoadingData && (
<Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard...
</Card>
)}
{hasError && (
<Card className="p-8 text-center text-red-600">
<div>Gagal memuat data dashboard</div>
<div className="mt-2 text-sm text-red-500">
{String(summaryQuery.error?.message)}
</div>
</Card>
)}
{/* Main dashboard content */}
{!isLoadingData && !hasError && (
<>
{summary && (
<section
className={
dashboardCompact
? "grid gap-4 md:grid-cols-4 items-stretch"
: "grid gap-6 md:grid-cols-4 items-stretch"
}
>
<div className="md:col-span-4 text-xl font-bold">
<h3 className="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-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Penyakit
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.diseaseCount}
</div>
<Link
to="/diagnoses"
className="text-emerald-600 text-sm ml-auto hover:underline"
>
Lihat daftar
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosis
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
<Link
to="/diagnoses"
className="text-emerald-600 text-sm ml-auto hover:underline"
>
Lihat daftar
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review
</CardTitle>
</CardHeader>
<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 text-sm ml-auto hover:underline"
>
Lihat daftar
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
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="/diagnoses?risk=high"
className="text-red-600 text-sm ml-auto hover:underline"
>
Lihat daftar
</Link>
</CardContent>
</Card>
</section>
)}
{/* Mission section */}
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<div className="space-y-1">
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Misi Platform
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Fitur inti yang kami sediakan untuk mendukung petani jagung
Indonesia
</p>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
{missionCards.map((card) => {
const Icon = card.icon;
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 */}
<section className="space-y-4">
<div className="mb-2 flex items-start justify-between">
<div>
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Penyakit yang Dapat Dideteksi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
4 kelas penyakit dan kondisi daun jagung dalam sistem kami
</p>
</div>
<Link
to="/library"
className="text-emerald-600 font-semibold inline-flex items-center gap-1 hover:underline"
>
Lihat Pustaka <ChevronRight className="w-4 h-4" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{diseasesQuick.map((d) => (
<Card
key={d.name}
className="rounded-2xl bg-white p-4 shadow-sm h-full"
>
<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"
style={{ backgroundColor: d.color }}
/>
<div>
<div className="font-bold text-[#214B11]">
{d.name}
</div>
<div className="text-sm text-slate-400 italic mt-1">
{d.sci}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</section>
{/* Scan quick access */}
<div className="mt-15 flex items-center gap-57 bg-[#1E8A2A] rounded-3xl p-6">
<div className="text-2xl font-bold text-white">
<h3>Siap Mendeteksi Penyakit Daun?</h3>
<div>
<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>
</div>
<Button <Button
asChild asChild
variant="outline" variant="outline"
className="bg-white hover:bg-[#1E8A2A]/90 hover:text-white px-6 py-6 text-lg font-bold text-[#214B11]" className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-6 py-6 text-lg font-semibold text-white"
> >
<Link to="/scan" className="inline-flex items-center gap-2"> <Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" /> <Scan className="h-5 w-6" />
Mulai Scan Sekarang Scan Daun Jagung
</Link> </Link>
</Button> </Button>
<Button <Button
asChild asChild
variant="outline" variant="outline"
className="px-6 py-6 text-lg font-bold text-white hover:bg-[#1E8A2A]" className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
></Button> >
<Link
to="/catalog"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button>
</div> </div>
</> </div>
)} <div className="w-full md:w-1/3" />
</div> </div>
</main> </header>
{/* Loading / Error states */}
{isLoadingData && (
<Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard...
</Card>
)}
{hasError && (
<Card className="p-8 text-center text-red-600">
<div>Gagal memuat data dashboard</div>
<div className="mt-2 text-sm text-red-500">
{String(summaryQuery.error?.message)}
</div>
</Card>
)}
{/* Main dashboard content */}
{!isLoadingData && !hasError && (
<>
{summary && (
<section
className={
dashboardCompact
? "grid gap-4 md:grid-cols-4 items-stretch"
: "grid gap-6 md:grid-cols-4 items-stretch"
}
>
<div className="md:col-span-4 text-xl font-bold">
<h3 className="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>
{/* Total Diagnoses — the actual count from diagnoses table */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosa
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
{/* Menunggu Review */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review
</CardTitle>
</CardHeader>
<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>
)}
{/* Mission section */}
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<div className="space-y-1">
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Misi Platform
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Fitur inti yang kami sediakan untuk mendukung petani jagung
Indonesia
</p>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
{missionCards.map((card) => {
const Icon = card.icon;
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-[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>
)}
</section>
{/* Scan quick access */}
<div className="mt-12 flex items-center gap-6 bg-[#1E8A2A] rounded-3xl 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>
); );
} }
+158 -103
View File
@@ -1,19 +1,40 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge"; import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { RiskBadge } from "@/components/risk-badge"; import { RiskBadge } from "@/components/risk-badge";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import type { DiagnosisStatus, RiskLevel } from "@zeavis/shared";
const STATUS_OPTIONS: { value: "all" | DiagnosisStatus; label: string }[] = [
{ value: "all", label: "Semua" },
{ value: "ai_verified", label: "Terverifikasi AI" },
{ value: "needs_review", label: "Menunggu Review" },
{ value: "expert_verified", label: "Diverifikasi Pakar" },
{ value: "expert_corrected", label: "Dikoreksi Pakar" },
{ value: "failed", label: "Gagal" },
];
const RISK_OPTIONS: { value: "all" | RiskLevel; label: string }[] = [
{ value: "all", label: "Semua Risiko" },
{ value: "high", label: "Risiko Tinggi" },
{ value: "medium", label: "Risiko Sedang" },
{ value: "low", label: "Risiko Rendah" },
];
export function DiagnosesPage() { export function DiagnosesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialStatus = searchParams.get("status") as DiagnosisStatus | null;
const initialRisk = searchParams.get("risk") as RiskLevel | null;
const [statusFilter, setStatusFilter] = useState< const [statusFilter, setStatusFilter] = useState<
"all" | "needs_review" | "verified" | "failed" "all" | DiagnosisStatus
>("all"); >(initialStatus ?? "all");
const [riskFilter, setRiskFilter] = useState< const [riskFilter, setRiskFilter] = useState<
"all" | "high" | "medium" | "low" "all" | RiskLevel
>("all"); >(initialRisk ?? "all");
const query = useQuery({ const query = useQuery({
queryKey: ["diagnoses"], queryKey: ["diagnoses"],
@@ -32,114 +53,148 @@ export function DiagnosesPage() {
}); });
}, [diagnoses, statusFilter, riskFilter]); }, [diagnoses, statusFilter, riskFilter]);
const handleStatusChange = (value: string) => {
const v = value as "all" | DiagnosisStatus;
setStatusFilter(v);
const next = new URLSearchParams(searchParams);
if (v === "all") next.delete("status");
else next.set("status", v);
setSearchParams(next);
};
const handleRiskChange = (value: string) => {
const v = value as "all" | RiskLevel;
setRiskFilter(v);
const next = new URLSearchParams(searchParams);
if (v === "all") next.delete("risk");
else next.set("risk", v);
setSearchParams(next);
};
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-6xl space-y-6"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-between gap-4"> <div>
<div> <p className="text-sm font-medium text-primary">
<p className="text-sm font-medium text-primary"> Manajemen Diagnosis
Manajemen Diagnosis </p>
</p> <h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
<h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
</div>
<div className="flex items-center gap-3">
<Link to="/dashboard">
<Button variant="outline">Dashboard</Button>
</Link>
</div>
</div> </div>
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link to="/scan">Scan Baru</Link>
</Button>
</div>
</div>
<Card> <Card>
<CardContent className="flex flex-col gap-4 p-4"> <CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label> <label className="text-sm font-medium">Status</label>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)} onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-border bg-background px-2 py-1" className="rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="all">All</option> {STATUS_OPTIONS.map((opt) => (
<option value="needs_review">Needs review</option> <option key={opt.value} value={opt.value}>
<option value="verified">Verified</option> {opt.label}
<option value="failed">Failed</option> </option>
</select> ))}
</select>
<label className="text-sm font-medium">Risiko</label> <label className="text-sm font-medium">Risiko</label>
<select <select
value={riskFilter} value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as any)} onChange={(e) => handleRiskChange(e.target.value)}
className="rounded-md border border-border bg-background px-2 py-1" className="rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="all">All</option> {RISK_OPTIONS.map((opt) => (
<option value="high">High</option> <option key={opt.value} value={opt.value}>
<option value="medium">Medium</option> {opt.label}
<option value="low">Low</option> </option>
</select> ))}
</select>
<div className="ml-auto text-sm text-muted-foreground"> <div className="ml-auto text-sm text-muted-foreground">
Total: {filtered.length} Total: {filtered.length}
</div>
</div> </div>
</div>
{query.isLoading ? ( {query.isLoading ? (
<div className="p-6 text-center text-muted-foreground"> <div className="py-12 text-center text-muted-foreground">
Memuat diagnosis... Memuat diagnosis...
</div> </div>
) : query.isError ? ( ) : query.isError ? (
<div className="p-6 text-center text-red-600"> <div className="py-12 text-center text-red-600">
Gagal memuat diagnosis Gagal memuat diagnosis
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="p-6 text-center text-muted-foreground"> <div className="py-12 text-center">
<p className="text-muted-foreground">
Tidak ada diagnosis sesuai filter Tidak ada diagnosis sesuai filter
</div> </p>
) : ( {diagnoses.length === 0 && (
<div className="grid gap-4 md:grid-cols-2"> <p className="mt-2 text-sm text-muted-foreground">
{filtered.map((d) => ( Mulai dengan{" "}
<Card key={d.id} className="h-full"> <Link to="/scan" className="text-primary underline">
<CardContent className="flex gap-4 p-4 items-start"> melakukan scan daun
<img </Link>
src={d.imageUrl} .
alt="Daun" </p>
className="h-24 w-24 rounded-md object-cover" )}
/> </div>
<div className="flex-1"> ) : (
<div className="flex items-center justify-between gap-2"> <div className="grid gap-4 md:grid-cols-2">
<div> {filtered.map((d) => (
<h3 className="text-lg font-semibold"> <Card key={d.id} className="h-full">
{d.disease?.commonName ?? "Unknown"} <CardContent className="flex gap-4 p-4 items-start">
</h3> <img
<p className="text-sm text-muted-foreground"> src={d.imageUrl}
{d.predictedDiseaseSlug ?? ""} alt="Daun"
</p> className="h-24 w-24 rounded-md object-cover bg-muted"
</div> />
<div className="flex flex-col items-end gap-1"> <div className="flex-1 min-w-0">
<DiagnosisStatusBadge status={d.status} /> <div className="flex items-center justify-between gap-2">
<RiskBadge level={d.disease?.riskLevel ?? "low"} /> <div className="min-w-0">
</div> <h3 className="text-lg font-semibold truncate">
{d.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<p className="text-sm text-muted-foreground">
{d.disease?.label ?? d.predictedDiseaseSlug}
</p>
</div> </div>
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="mt-3 flex items-center justify-between gap-4"> <DiagnosisStatusBadge status={d.status} />
<div className="text-sm text-muted-foreground"> <RiskBadge level={d.disease?.riskLevel ?? "low"} />
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold"
>
Lihat detail
</Link>
</div> </div>
</div> </div>
</CardContent>
</Card> <div className="mt-2 flex items-center justify-between gap-4">
))} <div className="text-sm text-muted-foreground">
</div> {new Date(d.createdAt).toLocaleString("id-ID")}
)} {d.confidence !== null && (
</CardContent> <span className="ml-2">
</Card> {(d.confidence * 100).toFixed(0)}%
</div> </span>
</main> )}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold text-sm shrink-0"
>
Lihat detail
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
); );
} }
+95 -97
View File
@@ -1,7 +1,7 @@
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isDiseaseSlug } from '@zeavis/shared'; 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge'; import { RiskBadge } from '@/components/risk-badge';
@@ -20,37 +20,37 @@ export function DiseaseDetailPage() {
if (!validatedSlug || error || (!isLoading && !disease)) { if (!validatedSlug || error || (!isLoading && !disease)) {
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-4xl"> <div className="flex items-center gap-4">
<Button asChild variant="ghost" className="mb-8"> <Button asChild variant="ghost">
<Link to="/catalog"> <Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog Kembali ke Katalog
</Link> </Link>
</Button> </Button>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card>
</div> </div>
</main> <Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card>
</div>
); );
} }
if (isLoading) { if (isLoading) {
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-4xl"> <div className="flex items-center gap-4">
<Button asChild variant="ghost" className="mb-8"> <Button asChild variant="ghost">
<Link to="/catalog"> <Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog Kembali ke Katalog
</Link> </Link>
</Button> </Button>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Memuat materi...</p>
</Card>
</div> </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 ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-4xl space-y-8"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <Button asChild variant="ghost">
<Button asChild variant="ghost"> <Link to="/catalog">
<Link to="/catalog"> <ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Kembali ke Katalog
Kembali ke Katalog </Link>
</Link> </Button>
</Button> <Button asChild variant="outline">
<Button asChild variant="outline"> <Link to="/dashboard">
<Link to="/dashboard"> <BookOpen className="mr-2 h-4 w-4" />
<BookOpen className="mr-2 h-4 w-4" /> Dashboard
Dashboard </Link>
</Link> </Button>
</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> </div>
</main>
<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>
); );
} }
+220 -223
View File
@@ -1,5 +1,5 @@
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
import type { DiseaseSlug } from '@zeavis/shared'; import type { DiseaseSlug } from '@zeavis/shared';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -24,14 +24,18 @@ export function ExpertReviewsPage() {
const reviews = reviewsQuery.data || []; const reviews = reviewsQuery.data || [];
const diseases = diseasesQuery.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({ const mutation = useMutation({
mutationFn: () => apiClient.reviewDiagnosis(selected!.id, { mutationFn: () =>
verdict, apiClient.reviewDiagnosis(selected!.id, {
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined, verdict,
notes, correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined,
}), notes,
}),
onSuccess: () => { onSuccess: () => {
setNotes(''); setNotes('');
setVerdict('verified'); setVerdict('verified');
@@ -47,231 +51,224 @@ export function ExpertReviewsPage() {
await mutation.mutateAsync(); await mutation.mutateAsync();
} }
const isLoading = reviewsQuery.isPending || diseasesQuery.isPending;
return ( return (
<main className="min-h-screen bg-background"> <div className="space-y-6">
<header className="border-b bg-card"> <div className="flex items-center justify-between">
<div className="mx-auto flex max-w-7xl items-center justify-between p-6"> <div>
<h1 className="text-2xl font-bold">Expert Reviews</h1> <p className="text-sm font-medium text-primary">Review Pakar</p>
<Link to="/dashboard"> <h1 className="text-3xl font-bold">Diagnosis Menunggu Review</h1>
<Button variant="outline">Dashboard</Button>
</Link>
</div> </div>
</header> </div>
<div className="mx-auto max-w-7xl p-6"> <div className="grid gap-6 lg:grid-cols-3">
<div className="grid gap-6 lg:grid-cols-3"> {/* Left: List of reviews */}
{/* Left: List of reviews */} <div className="space-y-3">
<div className="space-y-3"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <h2 className="font-semibold">Daftar Diagnosis</h2>
<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>
<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> </div>
{/* Right: Detail and form */} <div className="space-y-2">
<div className="lg:col-span-2 space-y-6"> {isLoading ? (
{reviewsQuery.isPending || diseasesQuery.isPending ? ( <p className="text-sm text-muted-foreground">Memuat diagnosis...</p>
<Card> ) : reviews.length === 0 ? (
<CardContent className="p-6 text-center text-muted-foreground"> <p className="text-sm text-muted-foreground">Tidak ada diagnosis yang menunggu review</p>
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>
</>
) : ( ) : (
<Card> reviews.map((review) => (
<CardContent className="p-6 text-center text-muted-foreground"> <button
No diagnoses available for review key={review.id}
</CardContent> type="button"
</Card> 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>
</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-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 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> </div>
</main> </div>
); );
} }
+83 -170
View File
@@ -1,182 +1,95 @@
import React, { useMemo, useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query";
import { BookOpen } from "lucide-react"; import { DiseaseCard } from "@/components/disease-card";
import { Button } from "@/components/ui/button"; import { apiClient } from "@/lib/api-client";
import { Modal } from "@/components/ui/modal"; import type { RiskLevel } from "@zeavis/shared";
import { mockDiseases } from "@/data/mock-diseases";
export function LibraryPage() { export function LibraryPage() {
type Disease = (typeof mockDiseases)[number]; const [searchQuery, setSearchQuery] = useState("");
const [riskFilter, setRiskFilter] = useState<RiskLevel | "all">("all");
const [filter, setFilter] = useState<string | null>(null); const { data: diseases, isLoading, error } = useQuery({
const [expandedId, setExpandedId] = useState<string | null>(null); queryKey: ["diseases"],
const [selected, setSelected] = useState<Disease | null>(null); queryFn: () => apiClient.getDiseases(),
const [modalOpen, setModalOpen] = useState(false); });
const items = useMemo(() => { const filteredDiseases = (diseases || []).filter((disease) => {
if (!filter) return mockDiseases; const matchesSearch =
return mockDiseases.filter((d) => disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.name.toLowerCase().includes(filter.toLowerCase()), disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
); disease.summary.toLowerCase().includes(searchQuery.toLowerCase());
}, [filter]);
const matchesRisk = riskFilter === "all" || disease.riskLevel === riskFilter;
return matchesSearch && matchesRisk;
});
return ( return (
<main className="p-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold mb-4">Pustaka Penyakit</h1> <div className="flex items-center justify-between">
<div className="mb-4"> <div>
<input <p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
placeholder="Filter penyakit..." <h1 className="text-3xl font-bold">Pustaka Penyakit</h1>
value={filter ?? ""} </div>
onChange={(e) => setFilter(e.target.value || null)}
className="border px-3 py-2 rounded-md w-full max-w-sm"
/>
</div> </div>
<div className="grid grid-cols-1 gap-4"> <div className="flex flex-col gap-4 md:flex-row md:items-end">
{items.map((d) => ( <div className="flex-1">
<article key={d.id} className="bg-white p-4 rounded-lg shadow"> <label htmlFor="search" className="block text-sm font-medium mb-2">
<div className="flex gap-4"> Cari penyakit
<img </label>
src={d.imageUrl} <input
alt={d.name} id="search"
className="h-28 w-48 rounded-md object-cover" type="text"
/> placeholder="Cari berdasarkan nama atau gejala..."
<div className="flex-1"> value={searchQuery}
<div className="flex items-start justify-between"> onChange={(e) => setSearchQuery(e.target.value)}
<div> className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
<h2 className="font-semibold text-lg"> />
{d.name}{" "} </div>
<span className="text-sm text-muted-foreground"> <div>
{d.severity} <label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
</span> Filter risiko
</h2> </label>
<p className="text-sm text-muted-foreground mt-2"> <select
{d.description} id="risk-filter"
</p> value={riskFilter}
{d.pathogen && ( onChange={(e) => setRiskFilter(e.target.value as RiskLevel | "all")}
<div className="mt-2 text-sm"> className="rounded-md border border-border bg-background px-3 py-2 text-sm"
<strong>Patogen:</strong> {d.pathogen} >
</div> <option value="all">Semua Risiko</option>
)} <option value="low">Risiko Rendah</option>
<div className="mt-3 flex items-center gap-3"> <option value="medium">Risiko Sedang</option>
<Button <option value="high">Risiko Tinggi</option>
variant="default" </select>
onClick={() => { </div>
setSelected(d);
setModalOpen(true);
}}
className="inline-flex items-center gap-2 bg-green-600 text-white px-3 py-1 rounded"
>
<BookOpen className="h-4 w-4" />
<span className="text-sm">Baca lebih lanjut</span>
</Button>
<Link
to={`/catalog/${d.slug}`}
className="text-sm text-muted-foreground"
>
Lihat halaman katalog
</Link>
</div>
</div>
<div>
<button
className="text-sm text-primary underline"
onClick={() =>
setExpandedId(expandedId === d.id ? null : d.id)
}
>
{expandedId === d.id ? "Tutup" : "Detail"}
</button>
</div>
</div>
{expandedId === d.id && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<h3 className="font-medium">Gejala</h3>
<ul className="list-disc list-inside text-sm mt-2">
{(d.symptoms || []).length > 0 ? (
d.symptoms.map((s: string, i: number) => (
<li key={`${d.id}-symptom-${i}`}>{s}</li>
))
) : (
<li>Tidak ada gejala khusus</li>
)}
</ul>
</div>
<div>
<h3 className="font-medium">Pencegahan</h3>
<p className="text-sm mt-2">{d.prevention}</p>
</div>
</div>
)}
</div>
</div>
</article>
))}
</div> </div>
<Modal {isLoading && (
open={modalOpen} <div className="py-12 text-center text-muted-foreground">
onClose={() => { Memuat pustaka penyakit...
setModalOpen(false); </div>
setSelected(null); )}
}}
title={selected?.name} {error && (
footer={ <div className="py-12 text-center text-red-600">
selected && ( Gagal memuat pustaka penyakit
<div className="flex items-center justify-end gap-3"> </div>
<Link )}
to={`/catalog/${selected.slug}`}
className="px-3 py-2 rounded bg-green-600 text-white text-sm" {!isLoading && !error && filteredDiseases.length === 0 && (
> <div className="py-12 text-center text-muted-foreground">
Buka halaman katalog Tidak ada penyakit yang cocok dengan filter ini.
</Link> </div>
<button )}
onClick={() => {
setModalOpen(false); {!isLoading && !error && filteredDiseases.length > 0 && (
setSelected(null); <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
}} {filteredDiseases.map((disease) => (
className="px-3 py-2 rounded bg-gray-200 text-sm" <DiseaseCard key={disease.slug} disease={disease} />
> ))}
Tutup </div>
</button> )}
</div> </div>
)
}
>
{selected ? (
<div className="grid grid-cols-1 gap-4">
<img
src={selected.imageUrl}
alt={selected.name}
className="w-full rounded-md object-cover"
/>
<div>
<p className="text-sm text-muted-foreground">
{selected.description}
</p>
{selected.pathogen && (
<p className="mt-2">
<strong>Patogen:</strong> {selected.pathogen}
</p>
)}
<h4 className="mt-3 font-medium">Gejala</h4>
<ul className="list-disc list-inside text-sm mt-2">
{(selected.symptoms || []).length > 0 ? (
selected.symptoms.map((s: string, i: number) => (
<li key={`${selected.id}-symptom-${i}`}>{s}</li>
))
) : (
<li>Tidak ada gejala khusus</li>
)}
</ul>
<h4 className="mt-3 font-medium">Pencegahan</h4>
<p className="text-sm mt-2">{selected.prevention}</p>
</div>
</div>
) : null}
</Modal>
</main>
); );
} }
+1 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
@@ -7,9 +7,6 @@ import { useAuthStore } from "@/store/auth-store";
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
+1 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
@@ -7,9 +7,6 @@ import { useAuthStore } from "@/store/auth-store";
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
+108 -67
View File
@@ -1,9 +1,12 @@
import React, { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import type { DiagnosisRecord } from "@zeavis/shared"; import type { DiagnosisRecord } from "@zeavis/shared";
import { apiClient } from "@/lib/api-client";
import { import {
AlignCenter, AlignCenter,
Camera, Camera,
@@ -16,6 +19,7 @@ import {
export function ScanPage() { export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -24,16 +28,28 @@ export function ScanPage() {
mutationFn: (file: File) => apiClient.createDiagnosis(file), mutationFn: (file: File) => apiClient.createDiagnosis(file),
onSuccess: (diagnosis) => { onSuccess: (diagnosis) => {
queryClient.invalidateQueries({ queryKey: ["diagnoses"] }); queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
// show preview modal instead of immediate navigation
setDiagnosisPreview(diagnosis); setDiagnosisPreview(diagnosis);
setPreviewOpen(true); setPreviewOpen(true);
setFileName(null);
setPreviewUrl(null);
if (inputRef.current) inputRef.current.value = "";
}, },
}); });
const handleFile = (f?: File) => { const handleFile = (f?: File) => {
if (!f) return; if (!f) return;
if (f.size > 5 * 1024 * 1024) {
alert("Ukuran file melebihi batas 5MB");
return;
}
setFileName(f.name); setFileName(f.name);
mutation.mutate(f); setPreviewUrl(URL.createObjectURL(f));
};
const handleUpload = () => {
const file = inputRef.current?.files?.[0];
if (!file) return;
mutation.mutate(file);
}; };
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
@@ -290,95 +306,120 @@ export function ScanPage() {
setPreviewOpen(false); setPreviewOpen(false);
setDiagnosisPreview(null); setDiagnosisPreview(null);
}} }}
title={diagnosisPreview?.predictedDiseaseSlug ?? "Hasil Diagnosis"} title={diagnosisPreview?.disease?.commonName ?? "Hasil Diagnosis"}
size="sm" size="md"
footer={ footer={
diagnosisPreview && ( diagnosisPreview && (
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<button <Button
className="px-3 py-2 rounded bg-green-600 text-white text-sm" variant="outline"
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
>
Lihat detail
</button>
<button
className="px-3 py-2 rounded bg-gray-200 text-sm"
onClick={() => { onClick={() => {
setPreviewOpen(false); setPreviewOpen(false);
setDiagnosisPreview(null); setDiagnosisPreview(null);
}} }}
> >
Tutup Tutup
</button> </Button>
<Button
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
className="bg-green-600 hover:bg-green-700"
>
Lihat Detail Lengkap
</Button>
</div> </div>
) )
} }
> >
{diagnosisPreview ? ( {diagnosisPreview ? (
<div className="grid grid-cols-1 gap-4"> <div className="space-y-4">
{diagnosisPreview.imageUrl && ( <div className="flex items-center gap-2">
<img <DiagnosisStatusBadge status={diagnosisPreview.status} />
src={diagnosisPreview.imageUrl} {diagnosisPreview.confidence !== null && (
alt="hasil" <span className="text-sm font-medium">
className="w-full rounded-md object-cover" Confidence: {(diagnosisPreview.confidence * 100).toFixed(1)}%
/> </span>
)} )}
<div>
<p className="text-sm text-muted-foreground">
Prediksi:{" "}
<strong>{diagnosisPreview.predictedDiseaseSlug}</strong>
</p>
<p className="text-sm text-muted-foreground">
Confidence:{" "}
<strong>
{Math.round((diagnosisPreview.confidence ?? 0) * 100)}%
</strong>
</p>
</div> </div>
{diagnosisPreview.disease && (
<div>
<h3 className="font-semibold text-lg">
{diagnosisPreview.disease.commonName}
</h3>
<p className="text-sm text-muted-foreground">
{diagnosisPreview.disease.summary}
</p>
</div>
)}
{diagnosisPreview.failureReason && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{diagnosisPreview.failureReason}
</div>
)}
{diagnosisPreview.predictions.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Semua Prediksi</h4>
<div className="space-y-2">
{diagnosisPreview.predictions
.sort((a, b) => a.rank - b.rank)
.map((pred) => (
<div
key={pred.id}
className="flex items-center justify-between rounded-lg border p-2 text-sm"
>
<span>{pred.modelLabel}</span>
<span className="font-semibold">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div>
))}
</div>
</div>
)}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-2"> <h4 className="text-sm font-medium mb-3">
Daftar Diagnosis Terbaru Riwayat Diagnosis Terbaru
</h4> </h4>
{diagnosesQuery.isLoading && ( {diagnosesQuery.isLoading && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Memuat daftar... Memuat riwayat...
</div>
)}
{diagnosesQuery.isError && (
<div className="text-sm text-red-600">
Gagal memuat daftar diagnosis
</div> </div>
)} )}
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && ( {!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
<div className="space-y-2"> <div className="space-y-2">
{(diagnosesQuery.data ?? []).slice(0, 5).map((d) => ( {(diagnosesQuery.data ?? [])
<div .slice(0, 5)
key={d.id} .map((d) => (
className="flex items-center justify-between rounded-md p-2 hover:bg-muted" <div
> key={d.id}
<div className="flex items-center gap-3"> className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
<img >
src={d.imageUrl} <div className="flex items-center gap-3">
alt="thumb" <img
className="h-10 w-10 rounded object-cover" src={d.imageUrl}
/> alt="thumb"
<div className="text-sm"> className="h-10 w-10 rounded object-cover bg-muted"
<div className="font-medium"> />
{d.disease?.commonName ?? d.predictedDiseaseSlug} <div className="text-sm">
</div> <div className="font-medium">
<div className="text-xs text-muted-foreground"> {d.disease?.commonName ?? "Unknown"}
{new Date(d.createdAt).toLocaleString("id-ID")} </div>
<div className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
</div> </div>
</div> </div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div> </div>
<Link ))}
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div>
))}
</div> </div>
)} )}
</div> </div>
-31
View File
@@ -1,31 +0,0 @@
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"devDependencies": { "devDependencies": {
+4 -4
View File
@@ -14,7 +14,7 @@ services:
- .env - .env
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.http.routers.zeavis-web.rule: Host(`zeavisedu.asepharyana.tech`) traefik.http.routers.zeavis-web.rule: Host(`zeavisedu.asepharyana.my.id`)
traefik.http.routers.zeavis-web.entrypoints: websecure traefik.http.routers.zeavis-web.entrypoints: websecure
traefik.http.routers.zeavis-web.tls: "true" traefik.http.routers.zeavis-web.tls: "true"
traefik.http.routers.zeavis-web.tls.certresolver: letsencrypt traefik.http.routers.zeavis-web.tls.certresolver: letsencrypt
@@ -31,11 +31,11 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
API_PORT: "3000" API_PORT: "3000"
WEB_APP_URL: https://zeavisedu.asepharyana.tech WEB_APP_URL: https://zeavisedu.asepharyana.my.id
ML_SERVICE_URL: ${ML_SERVICE_URL:-http://zeavis-ml:8000} ML_SERVICE_URL: ${ML_SERVICE_URL:-http://zeavis-ml:8000}
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.http.routers.zeavis-api.rule: Host(`api.zeavisedu.asepharyana.tech`) traefik.http.routers.zeavis-api.rule: Host(`api-zeavisedu.asepharyana.my.id`)
traefik.http.routers.zeavis-api.entrypoints: websecure traefik.http.routers.zeavis-api.entrypoints: websecure
traefik.http.routers.zeavis-api.tls: "true" traefik.http.routers.zeavis-api.tls: "true"
traefik.http.routers.zeavis-api.tls.certresolver: letsencrypt traefik.http.routers.zeavis-api.tls.certresolver: letsencrypt
@@ -54,7 +54,7 @@ services:
MODEL_INPUT_SIZE: "224" MODEL_INPUT_SIZE: "224"
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.http.routers.zeavis-ml.rule: Host(`ml.zeavisedu.asepharyana.tech`) traefik.http.routers.zeavis-ml.rule: Host(`ml-zeavisedu.asepharyana.my.id`)
traefik.http.routers.zeavis-ml.entrypoints: websecure traefik.http.routers.zeavis-ml.entrypoints: websecure
traefik.http.routers.zeavis-ml.tls: "true" traefik.http.routers.zeavis-ml.tls: "true"
traefik.http.routers.zeavis-ml.tls.certresolver: letsencrypt traefik.http.routers.zeavis-ml.tls.certresolver: letsencrypt