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:
co-authored by
Claude Opus 4.7
parent
a28d06cb02
commit
ba24cf1a05
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user