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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Asep Haryana Saputra
2026-06-02 07:12:49 +00:00
co-authored by Claude Opus 4.7
parent a28d06cb02
commit ba24cf1a05
15 changed files with 852 additions and 1068 deletions
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
export function AuthInitializer() {
const setUser = useAuthStore((state) => state.setUser);
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(),
retry: false,
});
useEffect(() => {
if (query.data) {
setUser(query.data.user);
}
}, [query.data, setUser]);
return null;
}
@@ -1,138 +0,0 @@
import { ChangeEvent, FormEvent, useRef, useState, useEffect } from 'react';
import type { DiagnosisRecord } from '@zeavis/shared';
import { Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
type ImageClassificationFormProps = {
onSubmit: (file: File) => Promise<void>;
isSubmitting: boolean;
latestResult: DiagnosisRecord | null;
};
export function ImageClassificationForm({
onSubmit,
isSubmitting,
latestResult,
}: ImageClassificationFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const previewUrlRef = useRef<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Revoke object URL on component unmount
useEffect(() => {
return () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
};
}, []);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const selectedFile = event.target.files?.[0] ?? null;
setError(null);
setFile(selectedFile);
// Revoke previous preview URL before replacing it
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
const newUrl = selectedFile ? URL.createObjectURL(selectedFile) : null;
previewUrlRef.current = newUrl;
setPreviewUrl(newUrl);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file) {
setError('Pilih gambar terlebih dahulu');
return;
}
try {
await onSubmit(file);
// Revoke current preview URL after successful submit before setting null
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setFile(null);
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Gagal mengirim gambar');
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> Diagnosis Gambar
</CardTitle>
<CardDescription>
Upload gambar daun jagung untuk klasifikasi AI dan review pakar jika confidence rendah.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="image-file" className="block text-sm font-medium">
Pilih Gambar
</label>
<input
ref={fileInputRef}
id="image-file"
type="file"
accept="image/jpeg,image/png"
onChange={handleFileChange}
disabled={isSubmitting}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50"
/>
{file && (
<p className="mt-2 text-sm text-muted-foreground">
File dipilih: {file.name}
</p>
)}
</div>
{previewUrl && (
<img src={previewUrl} alt="Pratinjau gambar daun jagung untuk diagnosis" className="h-48 rounded-lg object-cover" />
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? 'Memproses...' : 'Upload dan Diagnosis'}
</Button>
</form>
{latestResult && (
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="font-semibold">
{latestResult.disease?.commonName ?? 'Diagnosis gagal'}
</h3>
<DiagnosisStatusBadge status={latestResult.status} />
</div>
<p className="text-sm text-muted-foreground">
{latestResult.confidence === null
? 'Tidak ada confidence'
: `Confidence ${(latestResult.confidence * 100).toFixed(1)}%`}
</p>
</div>
)}
</CardContent>
</Card>
);
}
+28 -24
View File
@@ -10,14 +10,14 @@ export function Navbar() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
const isDashboard = location.pathname === "/dashboard";
const isScan = location.pathname === "/scan";
const isLibrary = location.pathname === "/library";
const user = useAuthStore((state) => state.user);
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",
isActive
"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(" ");
@@ -29,12 +29,10 @@ export function Navbar() {
onSuccess: () => {
setUser(null);
queryClient.clear();
navigate("/");
navigate("/login");
},
});
const user = useAuthStore((state) => state.user);
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">
@@ -52,27 +50,33 @@ export function Navbar() {
</div>
</div>
<nav className="flex items-center gap-3">
<Button asChild variant="ghost" className="rounded-full">
<Link to="/dashboard" className={navLinkClassName(isDashboard)}>
Dashboard
<nav className="flex items-center gap-2">
<Link to="/dashboard" className={navLinkClassName(isActive("/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>
</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 && (
<Button
variant="outline"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
className="ml-2 bg-white/10 border-white/20 text-white hover:bg-white/20"
>
{logoutMutation.isPending ? "Keluar..." : "Keluar"}
</Button>
@@ -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>
);
}