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,51 @@
|
||||
CREATE TABLE IF NOT EXISTS "app_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(120) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "disease_catalog" (
|
||||
"slug" varchar(80) PRIMARY KEY NOT NULL,
|
||||
"label" varchar(80) NOT NULL,
|
||||
"common_name" varchar(120) NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"symptoms" text[] NOT NULL,
|
||||
"recommendations" text[] NOT NULL,
|
||||
"risk_level" varchar(20) NOT NULL,
|
||||
"accent_color" varchar(40) NOT NULL,
|
||||
"display_order" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "manual_classifications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"disease_slug" varchar(80) NOT NULL,
|
||||
"observation" text NOT NULL,
|
||||
"location" varchar(160) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "manual_classifications" ADD CONSTRAINT "manual_classifications_disease_slug_disease_catalog_slug_fk" FOREIGN KEY ("disease_slug") REFERENCES "public"."disease_catalog"("slug") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "disease_catalog" ("slug", "label", "common_name", "summary", "description", "symptoms", "recommendations", "risk_level", "accent_color", "display_order") VALUES
|
||||
('bercak-daun', 'Bercak Daun', 'Gray Leaf Spot', 'Penyakit jamur yang menyebabkan bercak abu-abu pada daun jagung.', 'Bercak Daun adalah penyakit jamur yang disebabkan oleh Cercospora zeae-maydis. Penyakit ini ditandai dengan munculnya bercak berbentuk persegi panjang berwarna abu-abu dengan tepi coklat pada daun jagung. Penyakit ini berkembang pesat dalam kondisi lembab dan dapat mengurangi hasil panen secara signifikan.', ARRAY['Bercak berbentuk persegi panjang berwarna abu-abu','Tepi bercak berwarna coklat atau merah','Bercak biasanya muncul pada daun bagian bawah terlebih dahulu','Dalam kondisi lembab, bercak dapat berkembang dengan cepat'], ARRAY['Gunakan varietas jagung yang tahan terhadap penyakit ini','Terapkan rotasi tanaman dengan tanaman non-inang','Kurangi kelembaban dengan meningkatkan jarak tanam','Aplikasikan fungisida jika diperlukan','Buang sisa tanaman yang terinfeksi setelah panen'], 'high', '#9CA3AF', 1),
|
||||
('hawar-daun', 'Hawar Daun', 'Northern/Southern Leaf Blight', 'Penyakit jamur yang menyebabkan hawar pada daun jagung dengan gejala bercak memanjang.', 'Hawar Daun adalah penyakit jamur yang disebabkan oleh Exserohilum turcicum (Northern Leaf Blight) atau Bipolaris maydis (Southern Leaf Blight). Penyakit ini ditandai dengan munculnya bercak memanjang berwarna coklat atau abu-abu pada daun jagung. Penyakit ini dapat menyebabkan kerusakan daun yang parah dan mengurangi hasil panen.', ARRAY['Bercak memanjang berwarna coklat atau abu-abu','Bercak dapat mencapai panjang 10-15 cm','Tepi bercak sering berwarna lebih gelap','Dalam kondisi lembab, bercak dapat berkembang dengan cepat','Daun dapat mati jika terinfeksi parah'], ARRAY['Gunakan varietas jagung yang tahan terhadap penyakit ini','Terapkan rotasi tanaman dengan tanaman non-inang','Kurangi kelembaban dengan meningkatkan jarak tanam','Aplikasikan fungisida jika diperlukan','Buang sisa tanaman yang terinfeksi setelah panen'], 'high', '#8B4513', 2),
|
||||
('karat-daun', 'Karat Daun', 'Common Rust', 'Penyakit jamur yang menyebabkan pustula berwarna coklat kemerahan pada daun jagung.', 'Karat Daun adalah penyakit jamur yang disebabkan oleh Puccinia sorghi. Penyakit ini ditandai dengan munculnya pustula kecil berwarna coklat kemerahan pada permukaan daun jagung. Penyakit ini berkembang dalam kondisi lembab dan dapat mengurangi hasil panen jika tidak dikendalikan.', ARRAY['Pustula kecil berwarna coklat kemerahan pada permukaan daun','Pustula biasanya muncul pada daun bagian bawah terlebih dahulu','Dalam kondisi lembab, pustula dapat berkembang dengan cepat','Daun dapat menjadi kuning dan mati jika terinfeksi parah'], ARRAY['Gunakan varietas jagung yang tahan terhadap penyakit ini','Terapkan rotasi tanaman dengan tanaman non-inang','Kurangi kelembaban dengan meningkatkan jarak tanam','Aplikasikan fungisida jika diperlukan','Buang sisa tanaman yang terinfeksi setelah panen'], 'medium', '#DC2626', 3),
|
||||
('daun-sehat', 'Daun Sehat', 'Healthy Leaf', 'Daun jagung yang sehat tanpa tanda-tanda penyakit atau kerusakan.', 'Daun Sehat menunjukkan kondisi daun jagung yang optimal tanpa adanya gejala penyakit jamur atau kerusakan lainnya. Daun yang sehat memiliki warna hijau cerah, tekstur normal, dan tidak menunjukkan bercak, pustula, atau tanda-tanda kerusakan lainnya.', ARRAY['Warna daun hijau cerah dan seragam','Tekstur daun normal dan tidak ada bercak','Tidak ada pustula atau tanda-tanda penyakit lainnya','Daun terlihat kuat dan tidak layu'], ARRAY['Pertahankan praktik budidaya yang baik','Lakukan monitoring rutin untuk mendeteksi penyakit sejak dini','Terapkan rotasi tanaman untuk menjaga kesehatan tanah','Berikan nutrisi yang cukup untuk pertumbuhan optimal','Jaga kelembaban tanah yang sesuai'], 'low', '#22C55E', 4)
|
||||
ON CONFLICT ("slug") DO UPDATE SET
|
||||
"label" = EXCLUDED."label",
|
||||
"common_name" = EXCLUDED."common_name",
|
||||
"summary" = EXCLUDED."summary",
|
||||
"description" = EXCLUDED."description",
|
||||
"symptoms" = EXCLUDED."symptoms",
|
||||
"recommendations" = EXCLUDED."recommendations",
|
||||
"risk_level" = EXCLUDED."risk_level",
|
||||
"accent_color" = EXCLUDED."accent_color",
|
||||
"display_order" = EXCLUDED."display_order",
|
||||
"updated_at" = now();
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"id": "71e2eeba-4f8f-48bc-8bcf-63201334aa65",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.app_events": {
|
||||
"name": "app_events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(120)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.disease_catalog": {
|
||||
"name": "disease_catalog",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(80)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "varchar(80)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"common_name": {
|
||||
"name": "common_name",
|
||||
"type": "varchar(120)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"symptoms": {
|
||||
"name": "symptoms",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"recommendations": {
|
||||
"name": "recommendations",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"risk_level": {
|
||||
"name": "risk_level",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"accent_color": {
|
||||
"name": "accent_color",
|
||||
"type": "varchar(40)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_order": {
|
||||
"name": "display_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.manual_classifications": {
|
||||
"name": "manual_classifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"disease_slug": {
|
||||
"name": "disease_slug",
|
||||
"type": "varchar(80)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"observation": {
|
||||
"name": "observation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "varchar(160)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"manual_classifications_disease_slug_disease_catalog_slug_fk": {
|
||||
"name": "manual_classifications_disease_slug_disease_catalog_slug_fk",
|
||||
"tableFrom": "manual_classifications",
|
||||
"tableTo": "disease_catalog",
|
||||
"columnsFrom": [
|
||||
"disease_slug"
|
||||
],
|
||||
"columnsTo": [
|
||||
"slug"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1779458573080,
|
||||
"tag": "0000_harsh_arachne",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import postgres from 'postgres';
|
||||
import { env } from '../config/env';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | undefined;
|
||||
|
||||
export function createDbClient() {
|
||||
if (!env.databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required to create a database client');
|
||||
}
|
||||
|
||||
const client = postgres(env.databaseUrl);
|
||||
return drizzle(client, { schema });
|
||||
db ??= drizzle(postgres(env.databaseUrl), { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import { pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, timestamp, uuid, varchar, text, integer } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const appEvents = pgTable('app_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 120 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const diseaseCatalog = pgTable('disease_catalog', {
|
||||
slug: varchar('slug', { length: 80 }).primaryKey(),
|
||||
label: varchar('label', { length: 80 }).notNull(),
|
||||
commonName: varchar('common_name', { length: 120 }).notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
description: text('description').notNull(),
|
||||
symptoms: text('symptoms').array().notNull(),
|
||||
recommendations: text('recommendations').array().notNull(),
|
||||
riskLevel: varchar('risk_level', { length: 20 }).notNull(),
|
||||
accentColor: varchar('accent_color', { length: 40 }).notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const manualClassifications = pgTable('manual_classifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
diseaseSlug: varchar('disease_slug', { length: 80 })
|
||||
.notNull()
|
||||
.references(() => diseaseCatalog.slug),
|
||||
observation: text('observation').notNull(),
|
||||
location: varchar('location', { length: 160 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { diseaseCatalogSeed } from '@zeavis/shared';
|
||||
import type { InferInsertModel } from 'drizzle-orm';
|
||||
import { diseaseCatalog } from './schema';
|
||||
|
||||
export type DiseaseCatalogRow = InferInsertModel<typeof diseaseCatalog>;
|
||||
|
||||
export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((item) => ({
|
||||
slug: item.slug,
|
||||
label: item.label,
|
||||
commonName: item.commonName,
|
||||
summary: item.summary,
|
||||
description: item.description,
|
||||
symptoms: item.symptoms,
|
||||
recommendations: item.recommendations,
|
||||
riskLevel: item.riskLevel,
|
||||
accentColor: item.accentColor,
|
||||
displayOrder: item.displayOrder,
|
||||
}));
|
||||
+10
-1
@@ -2,8 +2,17 @@ import { Elysia } from 'elysia';
|
||||
import { env } from './config/env';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { statusRoutes } from './routes/status';
|
||||
import { diseaseRoutes } from './routes/diseases';
|
||||
import { classificationRoutes } from './routes/classifications';
|
||||
import { dashboardRoutes } from './routes/dashboard';
|
||||
|
||||
const app = new Elysia().use(healthRoutes).use(statusRoutes).listen(env.port);
|
||||
const app = new Elysia()
|
||||
.use(healthRoutes)
|
||||
.use(statusRoutes)
|
||||
.use(diseaseRoutes)
|
||||
.use(classificationRoutes)
|
||||
.use(dashboardRoutes)
|
||||
.listen(env.port);
|
||||
|
||||
console.log(`ZeaVis Edu API running at http://${app.server?.hostname}:${app.server?.port}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function badRequest(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function notFound(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceUnavailable(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import type { ManualClassificationRequest, ManualClassificationRecord } from '@zeavis/shared';
|
||||
import { isDiseaseSlug } from '@zeavis/shared';
|
||||
import { createDbClient } from '../db/client';
|
||||
import { diseaseCatalog, manualClassifications } from '../db/schema';
|
||||
import { badRequest, serviceUnavailable } from '../lib/http-errors';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
.get('/classifications/manual', async () => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: manualClassifications.id,
|
||||
diseaseSlug: manualClassifications.diseaseSlug,
|
||||
observation: manualClassifications.observation,
|
||||
location: manualClassifications.location,
|
||||
createdAt: manualClassifications.createdAt,
|
||||
disease: {
|
||||
slug: diseaseCatalog.slug,
|
||||
label: diseaseCatalog.label,
|
||||
commonName: diseaseCatalog.commonName,
|
||||
summary: diseaseCatalog.summary,
|
||||
description: diseaseCatalog.description,
|
||||
symptoms: diseaseCatalog.symptoms,
|
||||
recommendations: diseaseCatalog.recommendations,
|
||||
riskLevel: diseaseCatalog.riskLevel,
|
||||
accentColor: diseaseCatalog.accentColor,
|
||||
displayOrder: diseaseCatalog.displayOrder,
|
||||
},
|
||||
})
|
||||
.from(manualClassifications)
|
||||
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
|
||||
.orderBy(desc(manualClassifications.createdAt))
|
||||
.limit(20);
|
||||
|
||||
const records: ManualClassificationRecord[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
diseaseSlug: row.diseaseSlug as any,
|
||||
observation: row.observation,
|
||||
location: row.location,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
disease: {
|
||||
slug: row.disease.slug as any,
|
||||
label: row.disease.label as any,
|
||||
commonName: row.disease.commonName,
|
||||
summary: row.disease.summary,
|
||||
description: row.disease.description,
|
||||
symptoms: row.disease.symptoms,
|
||||
recommendations: row.disease.recommendations,
|
||||
riskLevel: row.disease.riskLevel as any,
|
||||
accentColor: row.disease.accentColor,
|
||||
displayOrder: row.disease.displayOrder,
|
||||
},
|
||||
}));
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
})
|
||||
.post('/classifications/manual', async ({ body }) => {
|
||||
const req = body as Partial<ManualClassificationRequest> | undefined;
|
||||
|
||||
if (!req || typeof req !== 'object' || !req.diseaseSlug || !req.observation || !req.location) {
|
||||
return badRequest('Missing required fields: diseaseSlug, observation, location');
|
||||
}
|
||||
|
||||
if (!isDiseaseSlug(req.diseaseSlug)) {
|
||||
return badRequest(`Unknown disease slug: "${req.diseaseSlug}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
const db = createDbClient();
|
||||
|
||||
const diseaseExists = await db
|
||||
.select()
|
||||
.from(diseaseCatalog)
|
||||
.where(eq(diseaseCatalog.slug, req.diseaseSlug))
|
||||
.limit(1);
|
||||
|
||||
if (diseaseExists.length === 0) {
|
||||
return badRequest(`Disease with slug "${req.diseaseSlug}" does not exist`);
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(manualClassifications)
|
||||
.values({
|
||||
diseaseSlug: req.diseaseSlug,
|
||||
observation: req.observation,
|
||||
location: req.location,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const disease = diseaseExists[0];
|
||||
const record: ManualClassificationRecord = {
|
||||
id: inserted[0].id,
|
||||
diseaseSlug: inserted[0].diseaseSlug as any,
|
||||
observation: inserted[0].observation,
|
||||
location: inserted[0].location,
|
||||
createdAt: inserted[0].createdAt.toISOString(),
|
||||
disease: {
|
||||
slug: disease.slug as any,
|
||||
label: disease.label as any,
|
||||
commonName: disease.commonName,
|
||||
summary: disease.summary,
|
||||
description: disease.description,
|
||||
symptoms: disease.symptoms,
|
||||
recommendations: disease.recommendations,
|
||||
riskLevel: disease.riskLevel as any,
|
||||
accentColor: disease.accentColor,
|
||||
displayOrder: disease.displayOrder,
|
||||
},
|
||||
};
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import type { DashboardSummary, RiskLevel } from '@zeavis/shared';
|
||||
import { createDbClient } from '../db/client';
|
||||
import { diseaseCatalog, manualClassifications } from '../db/schema';
|
||||
import { serviceUnavailable } from '../lib/http-errors';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
|
||||
const diseaseCount = await db.select().from(diseaseCatalog);
|
||||
const classificationCount = await db.select().from(manualClassifications);
|
||||
|
||||
const latestClassificationRow = await db
|
||||
.select({
|
||||
id: manualClassifications.id,
|
||||
diseaseSlug: manualClassifications.diseaseSlug,
|
||||
observation: manualClassifications.observation,
|
||||
location: manualClassifications.location,
|
||||
createdAt: manualClassifications.createdAt,
|
||||
disease: {
|
||||
slug: diseaseCatalog.slug,
|
||||
label: diseaseCatalog.label,
|
||||
commonName: diseaseCatalog.commonName,
|
||||
summary: diseaseCatalog.summary,
|
||||
description: diseaseCatalog.description,
|
||||
symptoms: diseaseCatalog.symptoms,
|
||||
recommendations: diseaseCatalog.recommendations,
|
||||
riskLevel: diseaseCatalog.riskLevel,
|
||||
accentColor: diseaseCatalog.accentColor,
|
||||
displayOrder: diseaseCatalog.displayOrder,
|
||||
},
|
||||
})
|
||||
.from(manualClassifications)
|
||||
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
|
||||
.orderBy(desc(manualClassifications.createdAt))
|
||||
.limit(1);
|
||||
|
||||
const latestClassification = latestClassificationRow[0]
|
||||
? {
|
||||
id: latestClassificationRow[0].id,
|
||||
diseaseSlug: latestClassificationRow[0].diseaseSlug as any,
|
||||
observation: latestClassificationRow[0].observation,
|
||||
location: latestClassificationRow[0].location,
|
||||
createdAt: latestClassificationRow[0].createdAt.toISOString(),
|
||||
disease: {
|
||||
slug: latestClassificationRow[0].disease.slug as any,
|
||||
label: latestClassificationRow[0].disease.label as any,
|
||||
commonName: latestClassificationRow[0].disease.commonName,
|
||||
summary: latestClassificationRow[0].disease.summary,
|
||||
description: latestClassificationRow[0].disease.description,
|
||||
symptoms: latestClassificationRow[0].disease.symptoms,
|
||||
recommendations: latestClassificationRow[0].disease.recommendations,
|
||||
riskLevel: latestClassificationRow[0].disease.riskLevel as any,
|
||||
accentColor: latestClassificationRow[0].disease.accentColor,
|
||||
displayOrder: latestClassificationRow[0].disease.displayOrder,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const riskDistributionRows = await db.select().from(diseaseCatalog);
|
||||
const riskDistribution: Record<RiskLevel, number> = {
|
||||
low: 0,
|
||||
medium: 0,
|
||||
high: 0,
|
||||
};
|
||||
|
||||
for (const row of riskDistributionRows) {
|
||||
const risk = row.riskLevel as RiskLevel;
|
||||
if (risk in riskDistribution) {
|
||||
riskDistribution[risk]++;
|
||||
}
|
||||
}
|
||||
|
||||
const summary: DashboardSummary = {
|
||||
diseaseCount: diseaseCount.length,
|
||||
classificationCount: classificationCount.length,
|
||||
latestClassification,
|
||||
riskDistribution,
|
||||
};
|
||||
|
||||
return summary;
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import type { DiseaseCatalogItem, DiseaseSlug, DiseaseLabel, RiskLevel } from '@zeavis/shared';
|
||||
import { createDbClient } from '../db/client';
|
||||
import { diseaseCatalog } from '../db/schema';
|
||||
import { notFound, serviceUnavailable } from '../lib/http-errors';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
|
||||
export const diseaseRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
.get('/diseases', async () => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(diseaseCatalog)
|
||||
.orderBy(asc(diseaseCatalog.displayOrder));
|
||||
|
||||
const items: DiseaseCatalogItem[] = rows.map((row) => ({
|
||||
slug: row.slug as DiseaseSlug,
|
||||
label: row.label as DiseaseLabel,
|
||||
commonName: row.commonName,
|
||||
summary: row.summary,
|
||||
description: row.description,
|
||||
symptoms: row.symptoms,
|
||||
recommendations: row.recommendations,
|
||||
riskLevel: row.riskLevel as RiskLevel,
|
||||
accentColor: row.accentColor,
|
||||
displayOrder: row.displayOrder,
|
||||
}));
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
})
|
||||
.get('/diseases/:slug', async ({ params }) => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const row = await db
|
||||
.select()
|
||||
.from(diseaseCatalog)
|
||||
.where(eq(diseaseCatalog.slug, params.slug))
|
||||
.limit(1);
|
||||
|
||||
if (row.length === 0) {
|
||||
return notFound(`Disease with slug "${params.slug}" not found`);
|
||||
}
|
||||
|
||||
const item: DiseaseCatalogItem = {
|
||||
slug: row[0].slug as DiseaseSlug,
|
||||
label: row[0].label as DiseaseLabel,
|
||||
commonName: row[0].commonName,
|
||||
summary: row[0].summary,
|
||||
description: row[0].description,
|
||||
symptoms: row[0].symptoms,
|
||||
recommendations: row[0].recommendations,
|
||||
riskLevel: row[0].riskLevel as RiskLevel,
|
||||
accentColor: row[0].accentColor,
|
||||
displayOrder: row[0].displayOrder,
|
||||
};
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { DashboardPage } from '@/pages/dashboard-page';
|
||||
import { LandingPage } from '@/pages/landing-page';
|
||||
import { CatalogPage } from '@/pages/catalog-page';
|
||||
import { DiseaseDetailPage } from '@/pages/disease-detail-page';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -14,6 +16,14 @@ const router = createBrowserRouter([
|
||||
path: '/dashboard',
|
||||
element: <DashboardPage />,
|
||||
},
|
||||
{
|
||||
path: '/catalog',
|
||||
element: <CatalogPage />,
|
||||
},
|
||||
{
|
||||
path: '/catalog/:slug',
|
||||
element: <DiseaseDetailPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
export function App() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
DiseaseSlug,
|
||||
DiseaseCatalogItem,
|
||||
ManualClassificationRequest,
|
||||
ManualClassificationRecord,
|
||||
DashboardSummary,
|
||||
} from '@zeavis/shared';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${apiBaseUrl}${endpoint}`;
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch {
|
||||
// Response is not JSON, use default error message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
async getDiseases(): Promise<DiseaseCatalogItem[]> {
|
||||
return fetchApi('/api/v1/diseases');
|
||||
},
|
||||
|
||||
async getDisease(slug: DiseaseSlug): Promise<DiseaseCatalogItem> {
|
||||
return fetchApi(`/api/v1/diseases/${slug}`);
|
||||
},
|
||||
|
||||
async getManualClassifications(): Promise<ManualClassificationRecord[]> {
|
||||
return fetchApi('/api/v1/classifications/manual');
|
||||
},
|
||||
|
||||
async createManualClassification(
|
||||
payload: ManualClassificationRequest,
|
||||
): Promise<ManualClassificationRecord> {
|
||||
return fetchApi('/api/v1/classifications/manual', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async getDashboardSummary(): Promise<DashboardSummary> {
|
||||
return fetchApi('/api/v1/dashboard/summary');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { RiskLevel } from '@zeavis/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { DiseaseCard } from '@/components/disease-card';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export function CatalogPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [riskFilter, setRiskFilter] = useState<RiskLevel | 'all'>('all');
|
||||
|
||||
const { data: diseases, isLoading, error } = useQuery({
|
||||
queryKey: ['diseases'],
|
||||
queryFn: () => apiClient.getDiseases(),
|
||||
});
|
||||
|
||||
const filteredDiseases = (diseases || []).filter((disease) => {
|
||||
const matchesSearch =
|
||||
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
disease.summary.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesRisk = riskFilter === 'all' || disease.riskLevel === riskFilter;
|
||||
|
||||
return matchesSearch && matchesRisk;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<header className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
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">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="search" className="block text-sm font-medium mb-2">
|
||||
Cari penyakit
|
||||
</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder="Cari berdasarkan nama atau gejala..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
|
||||
Filter risiko
|
||||
</label>
|
||||
<select
|
||||
id="risk-filter"
|
||||
value={riskFilter}
|
||||
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | 'all')}
|
||||
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>
|
||||
|
||||
{isLoading && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Memuat katalog...
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="p-8 text-center text-red-600">
|
||||
Katalog belum tersedia
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredDiseases.length === 0 && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Tidak ada penyakit yang cocok dengan filter ini.
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredDiseases.length > 0 && (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredDiseases.map((disease) => (
|
||||
<DiseaseCard key={disease.slug} disease={disease} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,50 @@
|
||||
import { BookOpen, History, Leaf, LayoutDashboard } from 'lucide-react';
|
||||
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BookOpen, History, Leaf, LayoutDashboard, TrendingUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ManualClassificationForm } from '@/components/manual-classification-form';
|
||||
import { RiskBadge } from '@/components/risk-badge';
|
||||
import { useUiStore } from '@/store/ui-store';
|
||||
|
||||
const dashboardCards = [
|
||||
{
|
||||
icon: Leaf,
|
||||
title: 'Deteksi Penyakit',
|
||||
description: 'Area ini akan menjadi pintu masuk analisis gambar daun jagung.',
|
||||
},
|
||||
{
|
||||
icon: History,
|
||||
title: 'Riwayat Analisis',
|
||||
description: 'Hasil deteksi sebelumnya akan ditampilkan saat fitur data tersedia.',
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Materi Edukasi',
|
||||
description: 'Konten edukasi penyakit jagung akan terhubung ke modul pembelajaran.',
|
||||
},
|
||||
];
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [diseasesQuery, summaryQuery, classificationsQuery] = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['diseases'],
|
||||
queryFn: () => apiClient.getDiseases(),
|
||||
},
|
||||
{
|
||||
queryKey: ['dashboard-summary'],
|
||||
queryFn: () => apiClient.getDashboardSummary(),
|
||||
},
|
||||
{
|
||||
queryKey: ['manual-classifications'],
|
||||
queryFn: () => apiClient.getManualClassifications(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createClassificationMutation = useMutation({
|
||||
mutationFn: async (payload: Parameters<typeof apiClient.createManualClassification>[0]) => {
|
||||
await apiClient.createManualClassification(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['manual-classifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const diseases = diseasesQuery.data || [];
|
||||
const summary = summaryQuery.data;
|
||||
const classifications = classificationsQuery.data || [];
|
||||
|
||||
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
|
||||
const hasError = diseasesQuery.error || summaryQuery.error;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
@@ -35,7 +56,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Placeholder awal untuk fitur deteksi, riwayat analisis, dan edukasi.
|
||||
Pantau penyakit daun jagung dan laporkan pengamatan Anda
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -48,24 +69,205 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-3' : 'grid gap-6 md:grid-cols-3'}>
|
||||
{dashboardCards.map((item) => (
|
||||
<Card key={item.title}>
|
||||
<CardHeader>
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-primary">
|
||||
<item.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{item.title}</CardTitle>
|
||||
<CardDescription className="leading-6">{item.description}</CardDescription>
|
||||
{isLoadingData && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Memuat data dashboard...
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<Card className="p-8 text-center text-red-600">
|
||||
Gagal memuat data dashboard
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoadingData && !hasError && (
|
||||
<>
|
||||
{summary && (
|
||||
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Penyakit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Belum ada data. Fitur akan dihubungkan pada iterasi berikutnya.
|
||||
<div className="text-3xl font-bold">{summary.diseaseCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Laporan
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{summary.classificationCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Risiko Tinggi
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
{summary.riskDistribution.high}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Risiko Sedang
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-yellow-600">
|
||||
{summary.riskDistribution.medium}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Katalog Penyakit
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pelajari tentang {diseases.length} penyakit daun jagung
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/catalog">Buka Katalog</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Distribusi Risiko
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Penyakit berdasarkan tingkat risiko
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Risiko Tinggi</span>
|
||||
<span className="font-semibold">{summary.riskDistribution.high}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Risiko Sedang</span>
|
||||
<span className="font-semibold">{summary.riskDistribution.medium}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Risiko Rendah</span>
|
||||
<span className="font-semibold">{summary.riskDistribution.low}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{summary?.latestClassification && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Laporan Terbaru
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pengamatan penyakit terakhir yang dilaporkan
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h4 className="font-semibold">{summary.latestClassification.disease.commonName}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.latestClassification.disease.label}
|
||||
</p>
|
||||
</div>
|
||||
<RiskBadge level={summary.latestClassification.disease.riskLevel} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{summary.latestClassification.observation}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lokasi: {summary.latestClassification.location}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{new Date(summary.latestClassification.createdAt).toLocaleDateString('id-ID')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ManualClassificationForm
|
||||
diseases={diseases}
|
||||
onSubmit={async (payload) => {
|
||||
await createClassificationMutation.mutateAsync(payload);
|
||||
}}
|
||||
isSubmitting={createClassificationMutation.isPending}
|
||||
/>
|
||||
|
||||
{classifications.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Leaf className="h-5 w-5" />
|
||||
Riwayat Laporan
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{classifications.length} laporan pengamatan penyakit
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{classifications.slice(0, 5).map((classification) => (
|
||||
<div key={classification.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{classification.disease.commonName}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{classification.disease.label}
|
||||
</p>
|
||||
</div>
|
||||
<RiskBadge level={classification.disease.riskLevel} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{classification.observation}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{classification.location} • {new Date(classification.createdAt).toLocaleDateString('id-ID')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isDiseaseSlug } from '@zeavis/shared';
|
||||
import { ArrowLeft, BookOpen, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { RiskBadge } from '@/components/risk-badge';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export function DiseaseDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
|
||||
const validatedSlug = slug && isDiseaseSlug(slug) ? slug : null;
|
||||
|
||||
const { data: disease, isLoading, error } = useQuery({
|
||||
queryKey: ['disease', slug],
|
||||
queryFn: () => apiClient.getDisease(validatedSlug!),
|
||||
enabled: validatedSlug !== null,
|
||||
});
|
||||
|
||||
if (!validatedSlug || error || (!isLoading && !disease)) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Button asChild variant="ghost" className="mb-8">
|
||||
<Link to="/catalog">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Kembali ke Katalog
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground">Materi tidak ditemukan</p>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Button asChild variant="ghost" className="mb-8">
|
||||
<Link to="/catalog">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Kembali ke Katalog
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground">Memuat materi...</p>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!disease) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button asChild variant="ghost">
|
||||
<Link to="/catalog">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Kembali ke Katalog
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/dashboard">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -6,18 +6,18 @@ import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/ca
|
||||
const features = [
|
||||
{
|
||||
icon: Leaf,
|
||||
title: 'Deteksi penyakit daun jagung',
|
||||
description: 'Fondasi aplikasi siap untuk integrasi model klasifikasi ZeaVis Edu.',
|
||||
title: 'Katalog Penyakit Lengkap',
|
||||
description: 'Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Edukasi berbasis data',
|
||||
description: 'Materi dan hasil analisis dapat dikembangkan di atas dashboard awal.',
|
||||
title: 'Pantau Risiko Penyakit',
|
||||
description: 'Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.',
|
||||
},
|
||||
{
|
||||
icon: Sprout,
|
||||
title: 'Siap tumbuh bersama produk',
|
||||
description: 'Monorepo memisahkan frontend, backend, dan shared types dengan jelas.',
|
||||
title: 'Laporkan Pengamatan',
|
||||
description: 'Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,11 +44,11 @@ export function LandingPage() {
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-3xl text-5xl font-bold tracking-tight sm:text-6xl">
|
||||
Belajar mengenali penyakit daun jagung dengan alur digital yang rapi.
|
||||
Belajar mengenali penyakit daun jagung dan pantau pengamatan Anda.
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
Scaffold ini menyiapkan fondasi aplikasi ZeaVis Edu untuk antarmuka edukasi,
|
||||
API, dan integrasi model machine learning berikutnya.
|
||||
Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara penanganannya,
|
||||
serta laporkan pengamatan Anda untuk membantu penelitian dan edukasi.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
@@ -58,9 +58,9 @@ export function LandingPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<a href="https://elysiajs.com" target="_blank" rel="noreferrer">
|
||||
Lihat Stack API
|
||||
</a>
|
||||
<Link to="/catalog">
|
||||
Lihat Katalog Penyakit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
# Fullstack Education Catalog Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a PostgreSQL-backed ZeaVis Edu education catalog with dashboard, disease detail pages, manual classification submission, history, and statistics.
|
||||
|
||||
**Architecture:** Shared TypeScript types define the disease/classification contract. The Elysia API exposes `/api/v1` catalog, classification, and dashboard endpoints backed by Drizzle/PostgreSQL. The React app uses TanStack Query and React Router to render landing, dashboard, catalog, detail, and manual classification flows.
|
||||
|
||||
**Tech Stack:** Bun workspaces, Moon tasks, TypeScript, Elysia, Drizzle ORM, PostgreSQL, React, Vite, React Router, TanStack Query, Tailwind CSS.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- Modify `packages/shared/src/app-status.ts`: keep existing app status helper.
|
||||
- Create `packages/shared/src/diseases.ts`: labels, slugs, risk levels, domain types, seed records, mapping helpers.
|
||||
- Create `packages/shared/src/classifications.ts`: manual classification request/response and dashboard summary types.
|
||||
- Modify `packages/shared/src/index.ts`: export new shared modules.
|
||||
- Modify `apps/api/src/db/schema.ts`: add `diseaseCatalog` and `manualClassifications` Drizzle tables.
|
||||
- Create `apps/api/src/db/seed-data.ts`: map shared seed catalog to insertable database rows.
|
||||
- Create `apps/api/src/lib/http-errors.ts`: small JSON error helpers.
|
||||
- Create `apps/api/src/routes/diseases.ts`: catalog and detail endpoints.
|
||||
- Create `apps/api/src/routes/classifications.ts`: manual submission and history endpoints.
|
||||
- Create `apps/api/src/routes/dashboard.ts`: summary endpoint.
|
||||
- Modify `apps/api/src/index.ts`: register new routes.
|
||||
- Create `apps/web/src/lib/api-client.ts`: typed fetch helpers.
|
||||
- Create `apps/web/src/components/risk-badge.tsx`: reusable risk badge.
|
||||
- Create `apps/web/src/components/disease-card.tsx`: reusable disease card.
|
||||
- Create `apps/web/src/components/manual-classification-form.tsx`: submission form.
|
||||
- Create `apps/web/src/pages/catalog-page.tsx`: searchable/filterable catalog.
|
||||
- Create `apps/web/src/pages/disease-detail-page.tsx`: disease detail page.
|
||||
- Modify `apps/web/src/pages/dashboard-page.tsx`: data-backed summary/history/manual form.
|
||||
- Modify `apps/web/src/pages/landing-page.tsx`: product-ready landing content.
|
||||
- Modify `apps/web/src/app.tsx`: add routes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared disease domain
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/shared/src/diseases.ts`
|
||||
- Create: `packages/shared/src/classifications.ts`
|
||||
- Modify: `packages/shared/src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create disease domain types and seed records**
|
||||
|
||||
Write `packages/shared/src/diseases.ts`:
|
||||
|
||||
```ts
|
||||
export const diseaseLabels = ['Bercak Daun', 'Hawar Daun', 'Karat Daun', 'Daun Sehat'] as const;
|
||||
export type DiseaseLabel = (typeof diseaseLabels)[number];
|
||||
|
||||
export const diseaseSlugs = ['bercak-daun', 'hawar-daun', 'karat-daun', 'daun-sehat'] as const;
|
||||
export type DiseaseSlug = (typeof diseaseSlugs)[number];
|
||||
|
||||
export const riskLevels = ['low', 'medium', 'high'] as const;
|
||||
export type RiskLevel = (typeof riskLevels)[number];
|
||||
|
||||
export type DiseaseCatalogItem = {
|
||||
slug: DiseaseSlug;
|
||||
label: DiseaseLabel;
|
||||
commonName: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
symptoms: string[];
|
||||
recommendations: string[];
|
||||
riskLevel: RiskLevel;
|
||||
accentColor: string;
|
||||
displayOrder: number;
|
||||
};
|
||||
|
||||
export const diseaseCatalogSeed: DiseaseCatalogItem[] = [
|
||||
{
|
||||
slug: 'bercak-daun',
|
||||
label: 'Bercak Daun',
|
||||
commonName: 'Gray Leaf Spot',
|
||||
summary: 'Penyakit jamur yang membentuk bercak abu-abu kecokelatan memanjang pada daun jagung.',
|
||||
description:
|
||||
'Bercak Daun biasanya berkembang pada kondisi lembap dan dapat mengurangi area fotosintesis jika tidak dipantau sejak awal.',
|
||||
symptoms: ['Bercak memanjang berwarna abu-abu kecokelatan', 'Lesi dibatasi tulang daun', 'Daun tampak mengering pada infeksi berat'],
|
||||
recommendations: ['Pantau kelembapan lahan', 'Gunakan varietas tahan bila tersedia', 'Bersihkan sisa tanaman terinfeksi'],
|
||||
riskLevel: 'medium',
|
||||
accentColor: 'slate',
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
slug: 'hawar-daun',
|
||||
label: 'Hawar Daun',
|
||||
commonName: 'Leaf Blight',
|
||||
summary: 'Penyakit hawar yang menimbulkan area nekrotik luas dan dapat menyebar cepat pada daun.',
|
||||
description:
|
||||
'Hawar Daun perlu ditangani serius karena gejalanya dapat meluas dan menurunkan kesehatan tanaman secara signifikan.',
|
||||
symptoms: ['Bercak lonjong besar berwarna cokelat', 'Tepi bercak terlihat basah atau menguning', 'Daun mengering dari area infeksi'],
|
||||
recommendations: ['Pisahkan tanaman dengan gejala berat', 'Perbaiki sirkulasi udara tanaman', 'Konsultasikan penggunaan fungisida sesuai rekomendasi setempat'],
|
||||
riskLevel: 'high',
|
||||
accentColor: 'amber',
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
slug: 'karat-daun',
|
||||
label: 'Karat Daun',
|
||||
commonName: 'Common Rust',
|
||||
summary: 'Infeksi jamur yang terlihat sebagai pustula kecil berwarna jingga hingga cokelat pada permukaan daun.',
|
||||
description:
|
||||
'Karat Daun sering mudah dikenali dari pustula seperti serbuk karat dan perlu dipantau agar tidak menyebar di lahan.',
|
||||
symptoms: ['Bintik/pustula jingga kecokelatan', 'Serbuk spora menempel di permukaan daun', 'Daun menguning pada serangan lanjut'],
|
||||
recommendations: ['Pantau daun bagian bawah dan tengah', 'Kurangi kelembapan berlebih', 'Gunakan benih atau varietas yang lebih tahan'],
|
||||
riskLevel: 'medium',
|
||||
accentColor: 'orange',
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
slug: 'daun-sehat',
|
||||
label: 'Daun Sehat',
|
||||
commonName: 'Healthy Leaf',
|
||||
summary: 'Daun jagung tampak normal tanpa gejala utama penyakit target ZeaVis Edu.',
|
||||
description:
|
||||
'Daun sehat tetap perlu dipantau berkala karena kondisi lahan dapat berubah dan gejala awal sering terlihat samar.',
|
||||
symptoms: ['Warna hijau merata', 'Tidak ada lesi atau pustula', 'Bentuk daun normal dan tidak mengering'],
|
||||
recommendations: ['Lanjutkan pemantauan rutin', 'Jaga sanitasi lahan', 'Catat perubahan visual sejak dini'],
|
||||
riskLevel: 'low',
|
||||
accentColor: 'emerald',
|
||||
displayOrder: 4,
|
||||
},
|
||||
];
|
||||
|
||||
export function isDiseaseSlug(value: string): value is DiseaseSlug {
|
||||
return diseaseSlugs.includes(value as DiseaseSlug);
|
||||
}
|
||||
|
||||
export function getDiseaseBySlug(slug: string): DiseaseCatalogItem | undefined {
|
||||
return diseaseCatalogSeed.find((item) => item.slug === slug);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create classification and dashboard shared types**
|
||||
|
||||
Write `packages/shared/src/classifications.ts`:
|
||||
|
||||
```ts
|
||||
import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from './diseases';
|
||||
|
||||
export type ManualClassificationRequest = {
|
||||
diseaseSlug: DiseaseSlug;
|
||||
observation: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
export type ManualClassificationRecord = {
|
||||
id: string;
|
||||
diseaseSlug: DiseaseSlug;
|
||||
observation: string;
|
||||
location: string;
|
||||
createdAt: string;
|
||||
disease: DiseaseCatalogItem;
|
||||
};
|
||||
|
||||
export type DashboardSummary = {
|
||||
diseaseCount: number;
|
||||
classificationCount: number;
|
||||
latestClassification: ManualClassificationRecord | null;
|
||||
riskDistribution: Record<RiskLevel, number>;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Export shared modules**
|
||||
|
||||
Modify `packages/shared/src/index.ts` to:
|
||||
|
||||
```ts
|
||||
export type { AppStatus } from './app-status';
|
||||
export { createAppStatus } from './app-status';
|
||||
export type { DiseaseCatalogItem, DiseaseLabel, DiseaseSlug, RiskLevel } from './diseases';
|
||||
export { diseaseCatalogSeed, diseaseLabels, diseaseSlugs, getDiseaseBySlug, isDiseaseSlug, riskLevels } from './diseases';
|
||||
export type { DashboardSummary, ManualClassificationRecord, ManualClassificationRequest } from './classifications';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify shared package typecheck**
|
||||
|
||||
Run: `bun --filter @zeavis/shared run typecheck`
|
||||
|
||||
Expected: TypeScript exits with code 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/diseases.ts packages/shared/src/classifications.ts packages/shared/src/index.ts
|
||||
git commit -m "Add shared education catalog domain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Database schema and API routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/db/schema.ts`
|
||||
- Create: `apps/api/src/db/seed-data.ts`
|
||||
- Create: `apps/api/src/lib/http-errors.ts`
|
||||
- Create: `apps/api/src/routes/diseases.ts`
|
||||
- Create: `apps/api/src/routes/classifications.ts`
|
||||
- Create: `apps/api/src/routes/dashboard.ts`
|
||||
- Modify: `apps/api/src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Replace database schema**
|
||||
|
||||
Write `apps/api/src/db/schema.ts`:
|
||||
|
||||
```ts
|
||||
import { pgTable, text, timestamp, uuid, varchar, integer } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const diseaseCatalog = pgTable('disease_catalog', {
|
||||
slug: varchar('slug', { length: 80 }).primaryKey(),
|
||||
label: varchar('label', { length: 80 }).notNull(),
|
||||
commonName: varchar('common_name', { length: 120 }).notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
description: text('description').notNull(),
|
||||
symptoms: text('symptoms').array().notNull(),
|
||||
recommendations: text('recommendations').array().notNull(),
|
||||
riskLevel: varchar('risk_level', { length: 20 }).notNull(),
|
||||
accentColor: varchar('accent_color', { length: 40 }).notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const manualClassifications = pgTable('manual_classifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
diseaseSlug: varchar('disease_slug', { length: 80 }).notNull().references(() => diseaseCatalog.slug),
|
||||
observation: text('observation').notNull(),
|
||||
location: varchar('location', { length: 160 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add seed data mapper**
|
||||
|
||||
Write `apps/api/src/db/seed-data.ts`:
|
||||
|
||||
```ts
|
||||
import { diseaseCatalogSeed } from '@zeavis/shared';
|
||||
|
||||
export const diseaseCatalogRows = diseaseCatalogSeed.map((item) => ({
|
||||
slug: item.slug,
|
||||
label: item.label,
|
||||
commonName: item.commonName,
|
||||
summary: item.summary,
|
||||
description: item.description,
|
||||
symptoms: item.symptoms,
|
||||
recommendations: item.recommendations,
|
||||
riskLevel: item.riskLevel,
|
||||
accentColor: item.accentColor,
|
||||
displayOrder: item.displayOrder,
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add HTTP error helpers**
|
||||
|
||||
Write `apps/api/src/lib/http-errors.ts`:
|
||||
|
||||
```ts
|
||||
export function badRequest(message: string) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function notFound(message: string) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceUnavailable(message: string) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add diseases route**
|
||||
|
||||
Write `apps/api/src/routes/diseases.ts`:
|
||||
|
||||
```ts
|
||||
import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from '@zeavis/shared';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { Elysia } from 'elysia';
|
||||
import { createDbClient } from '../db/client';
|
||||
import { diseaseCatalog } from '../db/schema';
|
||||
import { notFound, serviceUnavailable } from '../lib/http-errors';
|
||||
|
||||
function toDisease(row: typeof diseaseCatalog.$inferSelect): DiseaseCatalogItem {
|
||||
return {
|
||||
slug: row.slug as DiseaseSlug,
|
||||
label: row.label as DiseaseCatalogItem['label'],
|
||||
commonName: row.commonName,
|
||||
summary: row.summary,
|
||||
description: row.description,
|
||||
symptoms: row.symptoms,
|
||||
recommendations: row.recommendations,
|
||||
riskLevel: row.riskLevel as RiskLevel,
|
||||
accentColor: row.accentColor,
|
||||
displayOrder: row.displayOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export const diseaseRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
.get('/diseases', async () => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const rows = await db.select().from(diseaseCatalog).orderBy(asc(diseaseCatalog.displayOrder));
|
||||
return rows.map(toDisease);
|
||||
} catch {
|
||||
return serviceUnavailable('Database is unavailable');
|
||||
}
|
||||
})
|
||||
.get('/diseases/:slug', async ({ params }) => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const [row] = await db.select().from(diseaseCatalog).where(eq(diseaseCatalog.slug, params.slug)).limit(1);
|
||||
if (!row) {
|
||||
return notFound('Disease was not found');
|
||||
}
|
||||
return toDisease(row);
|
||||
} catch {
|
||||
return serviceUnavailable('Database is unavailable');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add classifications route**
|
||||
|
||||
Write `apps/api/src/routes/classifications.ts`:
|
||||
|
||||
```ts
|
||||
import type { DiseaseCatalogItem, DiseaseSlug, ManualClassificationRecord, RiskLevel } from '@zeavis/shared';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { Elysia } from 'elysia';
|
||||
import { createDbClient } from '../db/client';
|
||||
import { diseaseCatalog, manualClassifications } from '../db/schema';
|
||||
import { badRequest, serviceUnavailable } from '../lib/http-errors';
|
||||
|
||||
function toRecord(row: { manual_classifications: typeof manualClassifications.$inferSelect; disease_catalog: typeof diseaseCatalog.$inferSelect }): ManualClassificationRecord {
|
||||
return {
|
||||
id: row.manual_classifications.id,
|
||||
diseaseSlug: row.manual_classifications.diseaseSlug as DiseaseSlug,
|
||||
observation: row.manual_classifications.observation,
|
||||
location: row.manual_classifications.location,
|
||||
createdAt: row.manual_classifications.createdAt.toISOString(),
|
||||
disease: {
|
||||
slug: row.disease_catalog.slug as DiseaseSlug,
|
||||
label: row.disease_catalog.label as DiseaseCatalogItem['label'],
|
||||
commonName: row.disease_catalog.commonName,
|
||||
summary: row.disease_catalog.summary,
|
||||
description: row.disease_catalog.description,
|
||||
symptoms: row.disease_catalog.symptoms,
|
||||
recommendations: row.disease_catalog.recommendations,
|
||||
riskLevel: row.disease_catalog.riskLevel as RiskLevel,
|
||||
accentColor: row.disease_catalog.accentColor,
|
||||
displayOrder: row.disease_catalog.displayOrder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
.get('/classifications/manual', async () => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(manualClassifications)
|
||||
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
|
||||
.orderBy(desc(manualClassifications.createdAt))
|
||||
.limit(20);
|
||||
return rows.map(toRecord);
|
||||
} catch {
|
||||
return serviceUnavailable('Database is unavailable');
|
||||
}
|
||||
})
|
||||
.post('/classifications/manual', async ({ body }) => {
|
||||
const payload = body as { diseaseSlug?: string; observation?: string; location?: string };
|
||||
if (!payload.diseaseSlug || !payload.observation || !payload.location) {
|
||||
return badRequest('diseaseSlug, observation, and location are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const [disease] = await db.select().from(diseaseCatalog).where(eq(diseaseCatalog.slug, payload.diseaseSlug)).limit(1);
|
||||
if (!disease) {
|
||||
return badRequest('diseaseSlug is not registered in the catalog');
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(manualClassifications)
|
||||
.values({ diseaseSlug: payload.diseaseSlug, observation: payload.observation, location: payload.location })
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
diseaseSlug: created.diseaseSlug as DiseaseSlug,
|
||||
observation: created.observation,
|
||||
location: created.location,
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
disease: {
|
||||
slug: disease.slug as DiseaseSlug,
|
||||
label: disease.label as DiseaseCatalogItem['label'],
|
||||
commonName: disease.commonName,
|
||||
summary: disease.summary,
|
||||
description: disease.description,
|
||||
symptoms: disease.symptoms,
|
||||
recommendations: disease.recommendations,
|
||||
riskLevel: disease.riskLevel as RiskLevel,
|
||||
accentColor: disease.accentColor,
|
||||
displayOrder: disease.displayOrder,
|
||||
},
|
||||
} satisfies ManualClassificationRecord;
|
||||
} catch {
|
||||
return serviceUnavailable('Database is unavailable');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add dashboard route**
|
||||
|
||||
Write `apps/api/src/routes/dashboard.ts`:
|
||||
|
||||
```ts
|
||||
import type { DashboardSummary, DiseaseCatalogItem, DiseaseSlug, ManualClassificationRecord, RiskLevel } from '@zeavis/shared';
|
||||
import { count, desc, eq } from 'drizzle-orm';
|
||||
import { Elysia } from 'elysia';
|
||||
import { createDbClient } from '../db/client';
|
||||
import { diseaseCatalog, manualClassifications } from '../db/schema';
|
||||
import { serviceUnavailable } from '../lib/http-errors';
|
||||
|
||||
export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const diseases = await db.select().from(diseaseCatalog);
|
||||
const [{ value: classificationCount }] = await db.select({ value: count() }).from(manualClassifications);
|
||||
const [latestRow] = await db
|
||||
.select()
|
||||
.from(manualClassifications)
|
||||
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
|
||||
.orderBy(desc(manualClassifications.createdAt))
|
||||
.limit(1);
|
||||
|
||||
const riskDistribution = diseases.reduce<DashboardSummary['riskDistribution']>(
|
||||
(totals, disease) => {
|
||||
totals[disease.riskLevel as RiskLevel] += 1;
|
||||
return totals;
|
||||
},
|
||||
{ low: 0, medium: 0, high: 0 },
|
||||
);
|
||||
|
||||
const latestClassification: ManualClassificationRecord | null = latestRow
|
||||
? {
|
||||
id: latestRow.manual_classifications.id,
|
||||
diseaseSlug: latestRow.manual_classifications.diseaseSlug as DiseaseSlug,
|
||||
observation: latestRow.manual_classifications.observation,
|
||||
location: latestRow.manual_classifications.location,
|
||||
createdAt: latestRow.manual_classifications.createdAt.toISOString(),
|
||||
disease: {
|
||||
slug: latestRow.disease_catalog.slug as DiseaseSlug,
|
||||
label: latestRow.disease_catalog.label as DiseaseCatalogItem['label'],
|
||||
commonName: latestRow.disease_catalog.commonName,
|
||||
summary: latestRow.disease_catalog.summary,
|
||||
description: latestRow.disease_catalog.description,
|
||||
symptoms: latestRow.disease_catalog.symptoms,
|
||||
recommendations: latestRow.disease_catalog.recommendations,
|
||||
riskLevel: latestRow.disease_catalog.riskLevel as RiskLevel,
|
||||
accentColor: latestRow.disease_catalog.accentColor,
|
||||
displayOrder: latestRow.disease_catalog.displayOrder,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
return { diseaseCount: diseases.length, classificationCount, latestClassification, riskDistribution } satisfies DashboardSummary;
|
||||
} catch {
|
||||
return serviceUnavailable('Database is unavailable');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Register API routes**
|
||||
|
||||
Modify `apps/api/src/index.ts`:
|
||||
|
||||
```ts
|
||||
import { Elysia } from 'elysia';
|
||||
import { env } from './config/env';
|
||||
import { dashboardRoutes } from './routes/dashboard';
|
||||
import { classificationRoutes } from './routes/classifications';
|
||||
import { diseaseRoutes } from './routes/diseases';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { statusRoutes } from './routes/status';
|
||||
|
||||
const app = new Elysia().use(healthRoutes).use(statusRoutes).use(diseaseRoutes).use(classificationRoutes).use(dashboardRoutes).listen(env.port);
|
||||
|
||||
console.log(`ZeaVis Edu API running at http://${app.server?.hostname}:${app.server?.port}`);
|
||||
|
||||
export type App = typeof app;
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Generate migration and typecheck API**
|
||||
|
||||
Run: `bun --filter @zeavis/api run db:generate`
|
||||
|
||||
Expected: Drizzle creates a migration under `apps/api/drizzle/`.
|
||||
|
||||
Run: `bun --filter @zeavis/api run typecheck`
|
||||
|
||||
Expected: TypeScript exits with code 0.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src apps/api/drizzle
|
||||
git commit -m "Add education catalog API and schema"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Web API client and reusable components
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/lib/api-client.ts`
|
||||
- Create: `apps/web/src/components/risk-badge.tsx`
|
||||
- Create: `apps/web/src/components/disease-card.tsx`
|
||||
- Create: `apps/web/src/components/manual-classification-form.tsx`
|
||||
|
||||
- [ ] **Step 1: Add typed API client**
|
||||
|
||||
Write `apps/web/src/lib/api-client.ts`:
|
||||
|
||||
```ts
|
||||
import type { DashboardSummary, DiseaseCatalogItem, DiseaseSlug, ManualClassificationRecord, ManualClassificationRequest } from '@zeavis/shared';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = `Request failed with status ${response.status}`;
|
||||
const payload = (await response.json().catch(() => ({ error: fallback }))) as { error?: string };
|
||||
throw new Error(payload.error ?? fallback);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
getDiseases: () => request<DiseaseCatalogItem[]>('/api/v1/diseases'),
|
||||
getDisease: (slug: DiseaseSlug) => request<DiseaseCatalogItem>(`/api/v1/diseases/${slug}`),
|
||||
getManualClassifications: () => request<ManualClassificationRecord[]>('/api/v1/classifications/manual'),
|
||||
createManualClassification: (payload: ManualClassificationRequest) =>
|
||||
request<ManualClassificationRecord>('/api/v1/classifications/manual', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
getDashboardSummary: () => request<DashboardSummary>('/api/v1/dashboard/summary'),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add risk badge**
|
||||
|
||||
Write `apps/web/src/components/risk-badge.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RiskLevel } from '@zeavis/shared';
|
||||
|
||||
const riskLabels: Record<RiskLevel, string> = {
|
||||
low: 'Risiko rendah',
|
||||
medium: 'Risiko sedang',
|
||||
high: 'Risiko tinggi',
|
||||
};
|
||||
|
||||
const riskClasses: Record<RiskLevel, string> = {
|
||||
low: 'bg-emerald-100 text-emerald-800',
|
||||
medium: 'bg-amber-100 text-amber-800',
|
||||
high: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export function RiskBadge({ riskLevel }: { riskLevel: RiskLevel }) {
|
||||
return <span className={`rounded-full px-3 py-1 text-xs font-semibold ${riskClasses[riskLevel]}`}>{riskLabels[riskLevel]}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add disease card**
|
||||
|
||||
Write `apps/web/src/components/disease-card.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { DiseaseCatalogItem } from '@zeavis/shared';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { RiskBadge } from '@/components/risk-badge';
|
||||
|
||||
export function DiseaseCard({ disease }: { disease: DiseaseCatalogItem }) {
|
||||
return (
|
||||
<Card className="h-full transition hover:-translate-y-1 hover:shadow-md">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<RiskBadge riskLevel={disease.riskLevel} />
|
||||
<span className="text-sm text-muted-foreground">{disease.commonName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{disease.label}</CardTitle>
|
||||
<CardDescription className="mt-2 leading-6">{disease.summary}</CardDescription>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{disease.symptoms.slice(0, 2).map((symptom) => (
|
||||
<li key={symptom}>• {symptom}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="inline-flex items-center text-sm font-semibold text-primary" to={`/catalog/${disease.slug}`}>
|
||||
Pelajari detail <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add manual classification form**
|
||||
|
||||
Write `apps/web/src/components/manual-classification-form.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { DiseaseCatalogItem, DiseaseSlug, ManualClassificationRequest } from '@zeavis/shared';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ManualClassificationForm({ diseases, onSubmit, isSubmitting }: { diseases: DiseaseCatalogItem[]; onSubmit: (payload: ManualClassificationRequest) => void; isSubmitting: boolean }) {
|
||||
const [diseaseSlug, setDiseaseSlug] = useState<DiseaseSlug>(diseases[0]?.slug ?? 'daun-sehat');
|
||||
const [observation, setObservation] = useState('Daun diamati secara manual melalui aplikasi ZeaVis Edu.');
|
||||
const [location, setLocation] = useState('Lahan observasi');
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4 rounded-3xl border bg-card p-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit({ diseaseSlug, observation, location });
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="diseaseSlug">Label hasil pengamatan</label>
|
||||
<select id="diseaseSlug" className="mt-2 w-full rounded-2xl border bg-background px-4 py-3" value={diseaseSlug} onChange={(event) => setDiseaseSlug(event.target.value as DiseaseSlug)}>
|
||||
{diseases.map((disease) => (
|
||||
<option key={disease.slug} value={disease.slug}>{disease.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="observation">Catatan observasi</label>
|
||||
<textarea id="observation" className="mt-2 min-h-24 w-full rounded-2xl border bg-background px-4 py-3" value={observation} onChange={(event) => setObservation(event.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="location">Lokasi/catatan lahan</label>
|
||||
<input id="location" className="mt-2 w-full rounded-2xl border bg-background px-4 py-3" value={location} onChange={(event) => setLocation(event.target.value)} />
|
||||
</div>
|
||||
<Button disabled={isSubmitting || diseases.length === 0} type="submit">Simpan klasifikasi manual</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck web components**
|
||||
|
||||
Run: `bun --filter @zeavis/web run typecheck`
|
||||
|
||||
Expected: TypeScript exits with code 0.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/lib/api-client.ts apps/web/src/components
|
||||
git commit -m "Add web catalog API client and components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Web pages and routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/app.tsx`
|
||||
- Modify: `apps/web/src/pages/landing-page.tsx`
|
||||
- Modify: `apps/web/src/pages/dashboard-page.tsx`
|
||||
- Create: `apps/web/src/pages/catalog-page.tsx`
|
||||
- Create: `apps/web/src/pages/disease-detail-page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add catalog page**
|
||||
|
||||
Write `apps/web/src/pages/catalog-page.tsx` with a TanStack Query call to `apiClient.getDiseases()`, a search input, risk filter select, and `DiseaseCard` grid. The page must show loading text `Memuat katalog...`, error text `Katalog belum tersedia`, and an empty state `Tidak ada penyakit yang cocok dengan filter ini.`.
|
||||
|
||||
- [ ] **Step 2: Add disease detail page**
|
||||
|
||||
Write `apps/web/src/pages/disease-detail-page.tsx` using `useParams`, `isDiseaseSlug`, and `apiClient.getDisease(slug)`. Render description, symptoms, recommendations, risk badge, and links back to `/catalog` and `/dashboard`. Invalid slugs should render `Materi tidak ditemukan`.
|
||||
|
||||
- [ ] **Step 3: Replace dashboard page with data-backed dashboard**
|
||||
|
||||
Modify `apps/web/src/pages/dashboard-page.tsx` to query diseases, summary, and manual classification history. Include summary cards, risk distribution, recent history, and `ManualClassificationForm`. Use `useMutation` for `apiClient.createManualClassification`, then invalidate `dashboard-summary` and `manual-classifications` queries.
|
||||
|
||||
- [ ] **Step 4: Update landing page**
|
||||
|
||||
Modify `apps/web/src/pages/landing-page.tsx` so the primary CTA links to `/dashboard`, secondary CTA links to `/catalog`, and copy describes education catalog plus manual observation tracking instead of scaffold placeholders.
|
||||
|
||||
- [ ] **Step 5: Register routes**
|
||||
|
||||
Modify `apps/web/src/app.tsx`:
|
||||
|
||||
```tsx
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { CatalogPage } from '@/pages/catalog-page';
|
||||
import { DashboardPage } from '@/pages/dashboard-page';
|
||||
import { DiseaseDetailPage } from '@/pages/disease-detail-page';
|
||||
import { LandingPage } from '@/pages/landing-page';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <LandingPage /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/catalog', element: <CatalogPage /> },
|
||||
{ path: '/catalog/:slug', element: <DiseaseDetailPage /> },
|
||||
]);
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Typecheck web pages**
|
||||
|
||||
Run: `bun --filter @zeavis/web run typecheck`
|
||||
|
||||
Expected: TypeScript exits with code 0.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/app.tsx apps/web/src/pages
|
||||
git commit -m "Build education catalog web experience"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verification and manual run
|
||||
|
||||
**Files:**
|
||||
- Modify only if verification exposes a concrete bug.
|
||||
|
||||
- [ ] **Step 1: Run full typecheck**
|
||||
|
||||
Run: `bun run typecheck`
|
||||
|
||||
Expected: all Moon typecheck tasks pass.
|
||||
|
||||
- [ ] **Step 2: Run production build**
|
||||
|
||||
Run: `bun run build`
|
||||
|
||||
Expected: API/shared/web builds complete successfully.
|
||||
|
||||
- [ ] **Step 3: Start API and web**
|
||||
|
||||
Run API in one terminal: `bun --filter @zeavis/api run start`
|
||||
|
||||
Run web in another terminal: `bun --filter @zeavis/web run dev`
|
||||
|
||||
Expected: API logs `ZeaVis Edu API running...`; Vite logs a local URL.
|
||||
|
||||
- [ ] **Step 4: Browser-check frontend flow**
|
||||
|
||||
Open the Vite URL. Check `/`, `/dashboard`, `/catalog`, and one `/catalog/:slug` page. If no PostgreSQL database is configured, verify error states render instead of a blank screen. If `DATABASE_URL` and migrated/seeded data are available, submit one manual classification and verify it appears in dashboard/history.
|
||||
|
||||
- [ ] **Step 5: Final commit for verification fixes**
|
||||
|
||||
If fixes were required:
|
||||
|
||||
```bash
|
||||
git add <fixed-files>
|
||||
git commit -m "Fix education catalog verification issues"
|
||||
```
|
||||
|
||||
If no fixes were required, do not create an empty commit.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- Spec coverage: shared contract, backend schema/routes, frontend pages/manual flow, error states, and verification are all covered.
|
||||
- Placeholder scan: no TBD/TODO/fill-later placeholders are present. Task 4 uses explicit behavior requirements for page files because page markup is lengthy, but all required states and wiring are specified.
|
||||
- Type consistency: shared names (`DiseaseSlug`, `DiseaseCatalogItem`, `ManualClassificationRequest`, `ManualClassificationRecord`, `DashboardSummary`) are consistent across tasks.
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from './diseases';
|
||||
|
||||
export type ManualClassificationRequest = {
|
||||
diseaseSlug: DiseaseSlug;
|
||||
observation: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
export type ManualClassificationRecord = {
|
||||
id: string;
|
||||
diseaseSlug: DiseaseSlug;
|
||||
observation: string;
|
||||
location: string;
|
||||
createdAt: string;
|
||||
disease: DiseaseCatalogItem;
|
||||
};
|
||||
|
||||
export type DashboardSummary = {
|
||||
diseaseCount: number;
|
||||
classificationCount: number;
|
||||
latestClassification: ManualClassificationRecord | null;
|
||||
riskDistribution: Record<RiskLevel, number>;
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
export const diseaseLabels = ['Bercak Daun', 'Hawar Daun', 'Karat Daun', 'Daun Sehat'] as const;
|
||||
export type DiseaseLabel = (typeof diseaseLabels)[number];
|
||||
|
||||
export const diseaseSlugs = ['bercak-daun', 'hawar-daun', 'karat-daun', 'daun-sehat'] as const;
|
||||
export type DiseaseSlug = (typeof diseaseSlugs)[number];
|
||||
|
||||
export const riskLevels = ['low', 'medium', 'high'] as const;
|
||||
export type RiskLevel = (typeof riskLevels)[number];
|
||||
|
||||
export type DiseaseCatalogItem = {
|
||||
slug: DiseaseSlug;
|
||||
label: DiseaseLabel;
|
||||
commonName: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
symptoms: string[];
|
||||
recommendations: string[];
|
||||
riskLevel: RiskLevel;
|
||||
accentColor: string;
|
||||
displayOrder: number;
|
||||
};
|
||||
|
||||
export const diseaseCatalogSeed: DiseaseCatalogItem[] = [
|
||||
{
|
||||
slug: 'bercak-daun',
|
||||
label: 'Bercak Daun',
|
||||
commonName: 'Gray Leaf Spot',
|
||||
summary: 'Penyakit jamur yang menyebabkan bercak abu-abu pada daun jagung.',
|
||||
description:
|
||||
'Bercak Daun adalah penyakit jamur yang disebabkan oleh Cercospora zeae-maydis. Penyakit ini ditandai dengan munculnya bercak berbentuk persegi panjang berwarna abu-abu dengan tepi coklat pada daun jagung. Penyakit ini berkembang pesat dalam kondisi lembab dan dapat mengurangi hasil panen secara signifikan.',
|
||||
symptoms: [
|
||||
'Bercak berbentuk persegi panjang berwarna abu-abu',
|
||||
'Tepi bercak berwarna coklat atau merah',
|
||||
'Bercak biasanya muncul pada daun bagian bawah terlebih dahulu',
|
||||
'Dalam kondisi lembab, bercak dapat berkembang dengan cepat',
|
||||
],
|
||||
recommendations: [
|
||||
'Gunakan varietas jagung yang tahan terhadap penyakit ini',
|
||||
'Terapkan rotasi tanaman dengan tanaman non-inang',
|
||||
'Kurangi kelembaban dengan meningkatkan jarak tanam',
|
||||
'Aplikasikan fungisida jika diperlukan',
|
||||
'Buang sisa tanaman yang terinfeksi setelah panen',
|
||||
],
|
||||
riskLevel: 'high',
|
||||
accentColor: '#9CA3AF',
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
slug: 'hawar-daun',
|
||||
label: 'Hawar Daun',
|
||||
commonName: 'Northern/Southern Leaf Blight',
|
||||
summary: 'Penyakit jamur yang menyebabkan hawar pada daun jagung dengan gejala bercak memanjang.',
|
||||
description:
|
||||
'Hawar Daun adalah penyakit jamur yang disebabkan oleh Exserohilum turcicum (Northern Leaf Blight) atau Bipolaris maydis (Southern Leaf Blight). Penyakit ini ditandai dengan munculnya bercak memanjang berwarna coklat atau abu-abu pada daun jagung. Penyakit ini dapat menyebabkan kerusakan daun yang parah dan mengurangi hasil panen.',
|
||||
symptoms: [
|
||||
'Bercak memanjang berwarna coklat atau abu-abu',
|
||||
'Bercak dapat mencapai panjang 10-15 cm',
|
||||
'Tepi bercak sering berwarna lebih gelap',
|
||||
'Dalam kondisi lembab, bercak dapat berkembang dengan cepat',
|
||||
'Daun dapat mati jika terinfeksi parah',
|
||||
],
|
||||
recommendations: [
|
||||
'Gunakan varietas jagung yang tahan terhadap penyakit ini',
|
||||
'Terapkan rotasi tanaman dengan tanaman non-inang',
|
||||
'Kurangi kelembaban dengan meningkatkan jarak tanam',
|
||||
'Aplikasikan fungisida jika diperlukan',
|
||||
'Buang sisa tanaman yang terinfeksi setelah panen',
|
||||
],
|
||||
riskLevel: 'high',
|
||||
accentColor: '#8B4513',
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
slug: 'karat-daun',
|
||||
label: 'Karat Daun',
|
||||
commonName: 'Common Rust',
|
||||
summary: 'Penyakit jamur yang menyebabkan pustula berwarna coklat kemerahan pada daun jagung.',
|
||||
description:
|
||||
'Karat Daun adalah penyakit jamur yang disebabkan oleh Puccinia sorghi. Penyakit ini ditandai dengan munculnya pustula kecil berwarna coklat kemerahan pada permukaan daun jagung. Penyakit ini berkembang dalam kondisi lembab dan dapat mengurangi hasil panen jika tidak dikendalikan.',
|
||||
symptoms: [
|
||||
'Pustula kecil berwarna coklat kemerahan pada permukaan daun',
|
||||
'Pustula biasanya muncul pada daun bagian bawah terlebih dahulu',
|
||||
'Dalam kondisi lembab, pustula dapat berkembang dengan cepat',
|
||||
'Daun dapat menjadi kuning dan mati jika terinfeksi parah',
|
||||
],
|
||||
recommendations: [
|
||||
'Gunakan varietas jagung yang tahan terhadap penyakit ini',
|
||||
'Terapkan rotasi tanaman dengan tanaman non-inang',
|
||||
'Kurangi kelembaban dengan meningkatkan jarak tanam',
|
||||
'Aplikasikan fungisida jika diperlukan',
|
||||
'Buang sisa tanaman yang terinfeksi setelah panen',
|
||||
],
|
||||
riskLevel: 'medium',
|
||||
accentColor: '#DC2626',
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
slug: 'daun-sehat',
|
||||
label: 'Daun Sehat',
|
||||
commonName: 'Healthy Leaf',
|
||||
summary: 'Daun jagung yang sehat tanpa tanda-tanda penyakit atau kerusakan.',
|
||||
description:
|
||||
'Daun Sehat menunjukkan kondisi daun jagung yang optimal tanpa adanya gejala penyakit jamur atau kerusakan lainnya. Daun yang sehat memiliki warna hijau cerah, tekstur normal, dan tidak menunjukkan bercak, pustula, atau tanda-tanda kerusakan lainnya.',
|
||||
symptoms: [
|
||||
'Warna daun hijau cerah dan seragam',
|
||||
'Tekstur daun normal dan tidak ada bercak',
|
||||
'Tidak ada pustula atau tanda-tanda penyakit lainnya',
|
||||
'Daun terlihat kuat dan tidak layu',
|
||||
],
|
||||
recommendations: [
|
||||
'Pertahankan praktik budidaya yang baik',
|
||||
'Lakukan monitoring rutin untuk mendeteksi penyakit sejak dini',
|
||||
'Terapkan rotasi tanaman untuk menjaga kesehatan tanah',
|
||||
'Berikan nutrisi yang cukup untuk pertumbuhan optimal',
|
||||
'Jaga kelembaban tanah yang sesuai',
|
||||
],
|
||||
riskLevel: 'low',
|
||||
accentColor: '#22C55E',
|
||||
displayOrder: 4,
|
||||
},
|
||||
];
|
||||
|
||||
export function isDiseaseSlug(value: string): value is DiseaseSlug {
|
||||
return diseaseSlugs.includes(value as DiseaseSlug);
|
||||
}
|
||||
|
||||
export function getDiseaseBySlug(slug: string): DiseaseCatalogItem | undefined {
|
||||
return diseaseCatalogSeed.find((item) => item.slug === slug);
|
||||
}
|
||||
@@ -1,2 +1,23 @@
|
||||
export type { AppStatus } from './app-status';
|
||||
export { createAppStatus } from './app-status';
|
||||
|
||||
export type {
|
||||
DiseaseLabel,
|
||||
DiseaseSlug,
|
||||
RiskLevel,
|
||||
DiseaseCatalogItem,
|
||||
} from './diseases';
|
||||
export {
|
||||
diseaseLabels,
|
||||
diseaseSlugs,
|
||||
riskLevels,
|
||||
diseaseCatalogSeed,
|
||||
isDiseaseSlug,
|
||||
getDiseaseBySlug,
|
||||
} from './diseases';
|
||||
|
||||
export type {
|
||||
ManualClassificationRequest,
|
||||
ManualClassificationRecord,
|
||||
DashboardSummary,
|
||||
} from './classifications';
|
||||
|
||||
Reference in New Issue
Block a user