From 84c1f468414bccb336be96b00cd9ace14811cb46 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 14:26:13 +0000 Subject: [PATCH] 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 --- apps/api/drizzle/0000_harsh_arachne.sql | 51 ++ apps/api/drizzle/meta/0000_snapshot.json | 198 +++++ apps/api/drizzle/meta/_journal.json | 13 + apps/api/src/db/client.ts | 6 +- apps/api/src/db/schema.ts | 27 +- apps/api/src/db/seed-data.ts | 18 + apps/api/src/index.ts | 11 +- apps/api/src/lib/http-errors.ts | 20 + apps/api/src/routes/classifications.ts | 121 +++ apps/api/src/routes/dashboard.ts | 87 ++ apps/api/src/routes/diseases.ts | 65 ++ apps/web/src/app.tsx | 10 + apps/web/src/components/disease-card.tsx | 42 + .../components/manual-classification-form.tsx | 132 +++ apps/web/src/components/risk-badge.tsx | 38 + apps/web/src/lib/api-client.ts | 59 ++ apps/web/src/pages/catalog-page.tsx | 109 +++ apps/web/src/pages/dashboard-page.tsx | 278 +++++- apps/web/src/pages/disease-detail-page.tsx | 146 ++++ apps/web/src/pages/landing-page.tsx | 24 +- .../2026-05-22-fullstack-education-catalog.md | 804 ++++++++++++++++++ packages/shared/src/classifications.ts | 23 + packages/shared/src/diseases.ts | 129 +++ packages/shared/src/index.ts | 21 + 24 files changed, 2378 insertions(+), 54 deletions(-) create mode 100644 apps/api/drizzle/0000_harsh_arachne.sql create mode 100644 apps/api/drizzle/meta/0000_snapshot.json create mode 100644 apps/api/drizzle/meta/_journal.json create mode 100644 apps/api/src/db/seed-data.ts create mode 100644 apps/api/src/lib/http-errors.ts create mode 100644 apps/api/src/routes/classifications.ts create mode 100644 apps/api/src/routes/dashboard.ts create mode 100644 apps/api/src/routes/diseases.ts create mode 100644 apps/web/src/components/disease-card.tsx create mode 100644 apps/web/src/components/manual-classification-form.tsx create mode 100644 apps/web/src/components/risk-badge.tsx create mode 100644 apps/web/src/lib/api-client.ts create mode 100644 apps/web/src/pages/catalog-page.tsx create mode 100644 apps/web/src/pages/disease-detail-page.tsx create mode 100644 docs/superpowers/plans/2026-05-22-fullstack-education-catalog.md create mode 100644 packages/shared/src/classifications.ts create mode 100644 packages/shared/src/diseases.ts diff --git a/apps/api/drizzle/0000_harsh_arachne.sql b/apps/api/drizzle/0000_harsh_arachne.sql new file mode 100644 index 0000000..46a07d4 --- /dev/null +++ b/apps/api/drizzle/0000_harsh_arachne.sql @@ -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(); diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..84a18fc --- /dev/null +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json new file mode 100644 index 0000000..f6b8a6b --- /dev/null +++ b/apps/api/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1779458573080, + "tag": "0000_harsh_arachne", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts index 5c2c11b..f85d76f 100644 --- a/apps/api/src/db/client.ts +++ b/apps/api/src/db/client.ts @@ -3,11 +3,13 @@ import postgres from 'postgres'; import { env } from '../config/env'; import * as schema from './schema'; +let db: ReturnType> | 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; } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index d3ff328..79dca78 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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(), +}); diff --git a/apps/api/src/db/seed-data.ts b/apps/api/src/db/seed-data.ts new file mode 100644 index 0000000..2380cc5 --- /dev/null +++ b/apps/api/src/db/seed-data.ts @@ -0,0 +1,18 @@ +import { diseaseCatalogSeed } from '@zeavis/shared'; +import type { InferInsertModel } from 'drizzle-orm'; +import { diseaseCatalog } from './schema'; + +export type DiseaseCatalogRow = InferInsertModel; + +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, +})); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9add3f1..d903997 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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}`); diff --git a/apps/api/src/lib/http-errors.ts b/apps/api/src/lib/http-errors.ts new file mode 100644 index 0000000..85a8393 --- /dev/null +++ b/apps/api/src/lib/http-errors.ts @@ -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' }, + }); +} diff --git a/apps/api/src/routes/classifications.ts b/apps/api/src/routes/classifications.ts new file mode 100644 index 0000000..ceede97 --- /dev/null +++ b/apps/api/src/routes/classifications.ts @@ -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 | 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'); + } + }); diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts new file mode 100644 index 0000000..90c87f8 --- /dev/null +++ b/apps/api/src/routes/dashboard.ts @@ -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 = { + 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'); + } +}); diff --git a/apps/api/src/routes/diseases.ts b/apps/api/src/routes/diseases.ts new file mode 100644 index 0000000..d469ed1 --- /dev/null +++ b/apps/api/src/routes/diseases.ts @@ -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'); + } + }); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index e66e83a..b667f42 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -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: , }, + { + path: '/catalog', + element: , + }, + { + path: '/catalog/:slug', + element: , + }, ]); export function App() { diff --git a/apps/web/src/components/disease-card.tsx b/apps/web/src/components/disease-card.tsx new file mode 100644 index 0000000..9a74b2f --- /dev/null +++ b/apps/web/src/components/disease-card.tsx @@ -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 ( + + + +
+
+ {disease.commonName} + {disease.label} +
+ +
+
+ +

{disease.summary}

+ +
+

Gejala:

+
    + {firstTwoSymptoms.map((symptom, index) => ( +
  • + • {symptom} +
  • + ))} +
+
+
+
+ + ); +} diff --git a/apps/web/src/components/manual-classification-form.tsx b/apps/web/src/components/manual-classification-form.tsx new file mode 100644 index 0000000..96d47b9 --- /dev/null +++ b/apps/web/src/components/manual-classification-form.tsx @@ -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; + isSubmitting: boolean; +} + +export function ManualClassificationForm({ + diseases, + onSubmit, + isSubmitting, +}: ManualClassificationFormProps) { + const [selectedSlug, setSelectedSlug] = useState(''); + const [observation, setObservation] = useState(''); + const [location, setLocation] = useState(''); + const [error, setError] = useState(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 ( + + + Klasifikasi Manual + Laporkan penyakit daun jagung yang Anda temukan + + +
+ {error && ( +
{error}
+ )} + +
+ + +
+ +
+ +