Implement fullstack education catalog with disease detail pages, API routes, and shared types

- Add disease detail page component with data fetching and error handling
- Create shared types for diseases and classifications
- Implement API routes for diseases, classifications, and dashboard summary
- Develop reusable components for risk badge and disease card
- Build catalog page with search and filter functionality
- Update dashboard page with data-backed summary and manual classification form
- Register new routes in the web application
- Ensure type safety and consistency across shared modules
This commit is contained in:
Asep Haryana Saputra
2026-05-22 14:26:13 +00:00
parent d0c17d67b2
commit 84c1f46841
24 changed files with 2378 additions and 54 deletions
+42
View File
@@ -0,0 +1,42 @@
import { Link } from 'react-router-dom';
import type { DiseaseCatalogItem } from '@zeavis/shared';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';
export interface DiseaseCardProps {
disease: DiseaseCatalogItem;
}
export function DiseaseCard({ disease }: DiseaseCardProps) {
const firstTwoSymptoms = disease.symptoms.slice(0, 2);
return (
<Link to={`/catalog/${disease.slug}`} className="block transition-transform hover:scale-105">
<Card className="h-full">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-xl">{disease.commonName}</CardTitle>
<CardDescription className="mt-1">{disease.label}</CardDescription>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{disease.summary}</p>
<div>
<h4 className="mb-2 text-sm font-semibold">Gejala:</h4>
<ul className="space-y-1">
{firstTwoSymptoms.map((symptom, index) => (
<li key={index} className="text-sm text-muted-foreground">
{symptom}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
</Link>
);
}
@@ -0,0 +1,132 @@
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>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { RiskLevel } from '@zeavis/shared';
import { cn } from '@/lib/utils';
const riskLevelConfig: Record<RiskLevel, { label: string; className: string }> = {
low: {
label: 'Risiko Rendah',
className: 'bg-green-100 text-green-800 border-green-300',
},
medium: {
label: 'Risiko Sedang',
className: 'bg-yellow-100 text-yellow-800 border-yellow-300',
},
high: {
label: 'Risiko Tinggi',
className: 'bg-red-100 text-red-800 border-red-300',
},
};
export interface RiskBadgeProps {
level: RiskLevel;
className?: string;
}
export function RiskBadge({ level, className }: RiskBadgeProps) {
const config = riskLevelConfig[level];
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold',
config.className,
className,
)}
>
{config.label}
</span>
);
}