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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user