diff --git a/generated/api.ts b/generated/api.ts index 49d875c..52ab782 100644 --- a/generated/api.ts +++ b/generated/api.ts @@ -358,6 +358,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/dashboard/budget": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getApiDashboardBudget"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dashboard/sdgs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getApiDashboardSdgs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dashboard/satisfaction": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getApiDashboardSatisfaction"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1551,4 +1599,124 @@ export interface operations { }; }; }; + getApiDashboardBudget: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + category: string; + amount: number; + percentage: number; + color: string; + }[]; + }; + "multipart/form-data": { + data: { + category: string; + amount: number; + percentage: number; + color: string; + }[]; + }; + "text/plain": { + data: { + category: string; + amount: number; + percentage: number; + color: string; + }[]; + }; + }; + }; + }; + }; + getApiDashboardSdgs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + title: string; + score: number; + image: (string | null) | null; + }[]; + }; + "multipart/form-data": { + data: { + title: string; + score: number; + image: (string | null) | null; + }[]; + }; + "text/plain": { + data: { + title: string; + score: number; + image: (string | null) | null; + }[]; + }; + }; + }; + }; + }; + getApiDashboardSatisfaction: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + category: string; + value: number; + color: string; + }[]; + }; + "multipart/form-data": { + data: { + category: string; + value: number; + color: string; + }[]; + }; + "text/plain": { + data: { + category: string; + value: number; + color: string; + }[]; + }; + }; + }; + }; + }; } diff --git a/generated/schema.json b/generated/schema.json index efe6eab..d487fb9 100644 --- a/generated/schema.json +++ b/generated/schema.json @@ -3041,6 +3041,363 @@ "operationId": "getApiEventToday", "summary": "Get events for today" } + }, + "/api/dashboard/budget": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "amount", + "percentage", + "color" + ], + "properties": { + "category": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "percentage": { + "type": "number" + }, + "color": { + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "amount", + "percentage", + "color" + ], + "properties": { + "category": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "percentage": { + "type": "number" + }, + "color": { + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] + } + }, + "text/plain": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "amount", + "percentage", + "color" + ], + "properties": { + "category": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "percentage": { + "type": "number" + }, + "color": { + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] + } + } + } + } + }, + "operationId": "getApiDashboardBudget" + } + }, + "/api/dashboard/sdgs": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "title", + "score", + "image" + ], + "properties": { + "title": { + "type": "string" + }, + "score": { + "type": "number" + }, + "image": { + "nullable": true, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "required": [ + "data" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "title", + "score", + "image" + ], + "properties": { + "title": { + "type": "string" + }, + "score": { + "type": "number" + }, + "image": { + "nullable": true, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "required": [ + "data" + ] + } + }, + "text/plain": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "title", + "score", + "image" + ], + "properties": { + "title": { + "type": "string" + }, + "score": { + "type": "number" + }, + "image": { + "nullable": true, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "required": [ + "data" + ] + } + } + } + } + }, + "operationId": "getApiDashboardSdgs" + } + }, + "/api/dashboard/satisfaction": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "value", + "color" + ], + "properties": { + "category": { + "type": "string" + }, + "value": { + "type": "number" + }, + "color": { + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "value", + "color" + ], + "properties": { + "category": { + "type": "string" + }, + "value": { + "type": "number" + }, + "color": { + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] + } + }, + "text/plain": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "category", + "value", + "color" + ], + "properties": { + "category": { + "type": "string" + }, + "value": { + "type": "number" + }, + "color": { + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] + } + } + } + } + }, + "operationId": "getApiDashboardSatisfaction" + } } }, "components": { diff --git a/package.json b/package.json index 3b9e967..e756e96 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,12 @@ "test:e2e": "bun run build && playwright test", "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*' && cp -r public/* dist/ 2>/dev/null || true", "start": "NODE_ENV=production bun src/index.ts", - "seed": "bun prisma/seed.ts" + "seed": "bun prisma/seed.ts", + "seed:auth": "bun prisma/seed.ts auth", + "seed:demographics": "bun prisma/seed.ts demographics", + "seed:divisions": "bun prisma/seed.ts divisions", + "seed:services": "bun prisma/seed.ts services", + "seed:dashboard": "bun prisma/seed.ts dashboard" }, "dependencies": { "@better-auth/cli": "^1.4.18", diff --git a/prisma/migrations/20260326092503_add_dashboard_metrics/migration.sql b/prisma/migrations/20260326092503_add_dashboard_metrics/migration.sql new file mode 100644 index 0000000..d8608f8 --- /dev/null +++ b/prisma/migrations/20260326092503_add_dashboard_metrics/migration.sql @@ -0,0 +1,58 @@ +/* + Warnings: + + - You are about to drop the `Budget` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Budget" DROP CONSTRAINT "Budget_approvedBy_fkey"; + +-- DropTable +DROP TABLE "Budget"; + +-- CreateTable +CREATE TABLE "budget" ( + "id" TEXT NOT NULL, + "category" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "percentage" DOUBLE PRECISION NOT NULL DEFAULT 0, + "color" TEXT NOT NULL DEFAULT '#3B82F6', + "fiscalYear" INTEGER NOT NULL DEFAULT 2025, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "budget_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sdgs_score" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "score" DOUBLE PRECISION NOT NULL DEFAULT 0, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sdgs_score_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "satisfaction_rating" ( + "id" TEXT NOT NULL, + "category" TEXT NOT NULL, + "value" INTEGER NOT NULL DEFAULT 0, + "color" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "satisfaction_rating_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "budget_category_fiscalYear_key" ON "budget"("category", "fiscalYear"); + +-- CreateIndex +CREATE UNIQUE INDEX "sdgs_score_title_key" ON "sdgs_score"("title"); + +-- CreateIndex +CREATE UNIQUE INDEX "satisfaction_rating_category_key" ON "satisfaction_rating"("category"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d7c6cd5..829c764 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,7 +31,6 @@ model User { innovationIdeas InnovationIdea[] @relation("IdeaReviewer") healthRecords HealthRecord[] populationDynamics PopulationDynamic[] - budgets Budget[] budgetTransactions BudgetTransaction[] posyandus Posyandu[] securityReports SecurityReport[] @@ -315,6 +314,46 @@ model Banjar { @@map("banjar") } +// --- KATEGORI 4: KEUANGAN & ANGGARAN --- + +model Budget { + id String @id @default(cuid()) + category String // "Belanja", "Pangan", "Pembiayaan", "Pendapatan" + amount Float @default(0) + percentage Float @default(0) + color String @default("#3B82F6") + fiscalYear Int @default(2025) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([category, fiscalYear]) + @@map("budget") +} + +// --- KATEGORI 5: METRIK DASHBOARD & SDGS --- + +model SdgsScore { + id String @id @default(cuid()) + title String @unique + score Float @default(0) + image String? // filename in public folder + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("sdgs_score") +} + +model SatisfactionRating { + id String @id @default(cuid()) + category String @unique // "Sangat Puas", "Puas", "Cukup", "Kurang" + value Int @default(0) + color String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("satisfaction_rating") +} + // --- STUBS FOR PHASE 2+ (To maintain relations) --- model HealthRecord { @@ -337,12 +376,6 @@ model PopulationDynamic { documentor User @relation(fields: [documentedBy], references: [id]) } -model Budget { - id String @id @default(cuid()) - approvedBy String? - approver User? @relation(fields: [approvedBy], references: [id]) -} - model BudgetTransaction { id String @id @default(cuid()) createdBy String diff --git a/prisma/seed.ts b/prisma/seed.ts index be5bf86..b807288 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,327 +1,144 @@ import "dotenv/config"; -import { hash } from "bcryptjs"; -import { generateId } from "better-auth"; +import { PrismaClient } from "../generated/prisma"; + +// Import all seeders +import { seedAdminUser, seedDemoUsers } from "./seeders/seed-auth"; +import { seedBanjars, seedResidents, getBanjarIds } from "./seeders/seed-demographics"; +import { seedDivisions, seedActivities, getDivisionIds } from "./seeders/seed-division-performance"; import { - ActivityStatus, - ComplaintCategory, - ComplaintStatus, - EventType, - Gender, - Priority, - PrismaClient, - Religion, -} from "../generated/prisma"; + seedComplaints, + seedServiceLetters, + seedEvents, + seedInnovationIdeas, +} from "./seeders/seed-public-services"; +import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics"; const prisma = new PrismaClient(); -async function seedAdminUser() { - const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com"; - const adminPassword = process.env.ADMIN_PASSWORD || "admin123"; - - console.log(`Checking admin user: ${adminEmail}`); - - const existingUser = await prisma.user.findUnique({ - where: { email: adminEmail }, - }); - - if (existingUser) { - if (existingUser.role !== "admin") { - await prisma.user.update({ - where: { email: adminEmail }, - data: { role: "admin" }, - }); - console.log("Updated existing user to admin role."); - } - return existingUser.id; - } - - const hashedPassword = await hash(adminPassword, 12); - const userId = generateId(); - - await prisma.user.create({ - data: { - id: userId, - email: adminEmail, - name: "Admin Desa Darmasaba", - role: "admin", - emailVerified: true, - accounts: { - create: { - id: generateId(), - accountId: userId, - providerId: "credential", - password: hashedPassword, - }, - }, - }, - }); - - console.log(`Admin user created: ${adminEmail}`); - return userId; -} - -async function seedBanjars() { - const banjars = [ - { - name: "Darmasaba", - code: "DSB", - totalPopulation: 1200, - totalKK: 300, - totalPoor: 45, - }, - { - name: "Manesa", - code: "MNS", - totalPopulation: 950, - totalKK: 240, - totalPoor: 32, - }, - { - name: "Cabe", - code: "CBE", - totalPopulation: 800, - totalKK: 200, - totalPoor: 28, - }, - { - name: "Penenjoan", - code: "PNJ", - totalPopulation: 1100, - totalKK: 280, - totalPoor: 50, - }, - { - name: "Baler Pasar", - code: "BPS", - totalPopulation: 850, - totalKK: 210, - totalPoor: 35, - }, - { - name: "Bucu", - code: "BCU", - totalPopulation: 734, - totalKK: 184, - totalPoor: 24, - }, - ]; - - console.log("Seeding Banjars..."); - for (const banjar of banjars) { - await prisma.banjar.upsert({ - where: { name: banjar.name }, - update: banjar, - create: banjar, - }); - } -} - -async function seedDivisions() { - const divisions = [ - { - name: "Pemerintahan", - description: "Urusan administrasi dan tata kelola desa", - color: "#1E3A5F", - }, - { - name: "Pembangunan", - description: "Infrastruktur dan sarana prasarana desa", - color: "#2E7D32", - }, - { - name: "Pemberdayaan", - description: "Pemberdayaan ekonomi dan masyarakat", - color: "#EF6C00", - }, - { - name: "Kesejahteraan", - description: "Kesehatan, pendidikan, dan sosial", - color: "#C62828", - }, - ]; - - console.log("Seeding Divisions..."); - const createdDivisions = []; - for (const div of divisions) { - const d = await prisma.division.upsert({ - where: { name: div.name }, - update: div, - create: div, - }); - createdDivisions.push(d); - } - return createdDivisions; -} - -async function seedResidents(banjarIds: string[]) { - console.log("Seeding Residents..."); - const residents = [ - { - nik: "5103010101700001", - kk: "5103010101700000", - name: "I Wayan Sudarsana", - birthDate: new Date("1970-05-15"), - birthPlace: "Badung", - gender: Gender.LAKI_LAKI, - religion: Religion.HINDU, - occupation: "Wiraswasta", - banjarId: banjarIds[0] || "", - rt: "001", - rw: "000", - address: "Jl. Raya Darmasaba No. 1", - isHeadOfHousehold: true, - }, - { - nik: "5103010101850002", - kk: "5103010101850000", - name: "Ni Made Arianti", - birthDate: new Date("1985-08-20"), - birthPlace: "Denpasar", - gender: Gender.PEREMPUAN, - religion: Religion.HINDU, - occupation: "Guru", - banjarId: banjarIds[1] || banjarIds[0] || "", - rt: "002", - rw: "000", - address: "Gg. Manesa No. 5", - isPoor: true, - }, - ]; - - for (const res of residents) { - await prisma.resident.upsert({ - where: { nik: res.nik }, - update: res, - create: res, - }); - } -} - -async function seedActivities(divisionIds: string[]) { - console.log("Seeding Activities..."); - const activities = [ - { - title: "Rapat Koordinasi 2025", - description: "Penyusunan rencana kerja tahunan", - divisionId: divisionIds[0] || "", - progress: 100, - status: ActivityStatus.SELESAI, - priority: Priority.TINGGI, - }, - { - title: "Pemutakhiran Indeks Desa", - description: "Pendataan SDG's Desa 2025", - divisionId: divisionIds[0] || "", - progress: 65, - status: ActivityStatus.BERJALAN, - priority: Priority.SEDANG, - }, - { - title: "Pembangunan Jalan Banjar Cabe", - description: "Pengaspalan jalan utama", - divisionId: divisionIds[1] || divisionIds[0] || "", - progress: 40, - status: ActivityStatus.BERJALAN, - priority: Priority.DARURAT, - }, - ]; - - for (const act of activities) { - await prisma.activity.create({ - data: act, - }); - } -} - -async function seedComplaints(adminId: string) { - console.log("Seeding Complaints..."); - const complaints = [ - { - complaintNumber: `COMP-20250326-001`, - title: "Lampu Jalan Mati", - description: - "Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.", - category: ComplaintCategory.INFRASTRUKTUR, - status: ComplaintStatus.BARU, - priority: Priority.SEDANG, - location: "Banjar Manesa", - reporterId: adminId, - }, - { - complaintNumber: `COMP-20250326-002`, - title: "Sampah Menumpuk", - description: "Tumpukan sampah di area pasar Darmasaba belum diangkut.", - category: ComplaintCategory.KETERTIBAN_UMUM, - status: ComplaintStatus.DIPROSES, - priority: Priority.TINGGI, - location: "Pasar Darmasaba", - assignedTo: adminId, - }, - ]; - - for (const comp of complaints) { - await prisma.complaint.upsert({ - where: { complaintNumber: comp.complaintNumber }, - update: comp, - create: comp, - }); - } -} - -async function seedEvents(adminId: string) { - console.log("Seeding Events..."); - const events = [ - { - title: "Rapat Pleno Desa", - description: "Pembahasan anggaran belanja desa", - eventType: EventType.RAPAT, - startDate: new Date(), - location: "Balai Desa Darmasaba", - createdBy: adminId, - }, - { - title: "Gotong Royong Kebersihan", - description: "Kegiatan rutin mingguan", - eventType: EventType.SOSIAL, - startDate: new Date(Date.now() + 86400000), // Besok - location: "Seluruh Banjar", - createdBy: adminId, - }, - ]; - - for (const event of events) { - await prisma.event.create({ - data: event, - }); - } -} - +/** + * Run All Seeders + * Executes all seeder functions in the correct order + */ export async function runSeed() { - console.log("Starting seed..."); + console.log("🌱 Starting seed...\n"); + // 1. Seed Authentication (Admin & Demo Users) + console.log("šŸ“ [1/6] Authentication & Users"); const adminId = await seedAdminUser(); - await seedBanjars(); - const banjars = await prisma.banjar.findMany(); - const banjarIds = banjars.map((b) => b.id); + await seedDemoUsers(); + console.log(); + // 2. Seed Demographics (Banjars & Residents) + console.log("šŸ“ [2/6] Demographics & Population"); + await seedBanjars(); + const banjarIds = await getBanjarIds(); + await seedResidents(banjarIds); + console.log(); + + // 3. Seed Division Performance (Divisions & Activities) + console.log("šŸ“ [3/6] Division Performance"); const divisions = await seedDivisions(); const divisionIds = divisions.map((d) => d.id); - - await seedResidents(banjarIds); await seedActivities(divisionIds); + console.log(); + + // 4. Seed Public Services (Complaints, Service Letters, Events, Innovation) + console.log("šŸ“ [4/6] Public Services"); await seedComplaints(adminId); + await seedServiceLetters(adminId); await seedEvents(adminId); + await seedInnovationIdeas(adminId); + console.log(); - console.log("Seed finished successfully!"); + // 5. Seed Dashboard Metrics (Budget, SDGs, Satisfaction) + console.log("šŸ“ [5/6] Dashboard Metrics"); + await seedDashboardMetrics(); + console.log(); + + console.log("āœ… Seed finished successfully!\n"); } -if (import.meta.main) { - runSeed() - .catch((e) => { - console.error(e); +/** + * Run Specific Seeder + * Allows running individual seeders by name + */ +export async function runSpecificSeeder(name: string) { + console.log(`🌱 Running specific seeder: ${name}\n`); + + switch (name) { + case "auth": + case "users": + console.log("šŸ“ Authentication & Users"); + await seedAdminUser(); + await seedDemoUsers(); + break; + + case "demographics": + case "population": + console.log("šŸ“ Demographics & Population"); + await seedBanjars(); + const banjarIds = await getBanjarIds(); + await seedResidents(banjarIds); + break; + + case "divisions": + case "performance": + console.log("šŸ“ Division Performance"); + const divisions = await seedDivisions(); + const divisionIds = divisions.map((d) => d.id); + await seedActivities(divisionIds); + break; + + case "complaints": + case "services": + case "public": + console.log("šŸ“ Public Services"); + const adminId = await seedAdminUser(); + await seedComplaints(adminId); + await seedServiceLetters(adminId); + await seedEvents(adminId); + await seedInnovationIdeas(adminId); + break; + + case "dashboard": + case "metrics": + console.log("šŸ“ Dashboard Metrics"); + await seedDashboardMetrics(); + break; + + default: + console.error(`āŒ Unknown seeder: ${name}`); + console.log("Available seeders: auth, demographics, divisions, complaints, dashboard"); process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); + } + + console.log("\nāœ… Seeder finished successfully!\n"); +} + +// Main execution +if (import.meta.main) { + const args = process.argv.slice(2); + const seederName = args[0]; + + if (seederName) { + // Run specific seeder + runSpecificSeeder(seederName) + .catch((e) => { + console.error("āŒ Seeder error:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); + } else { + // Run all seeders + runSeed() + .catch((e) => { + console.error("āŒ Seed error:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); + } } diff --git a/prisma/seeders/seed-auth.ts b/prisma/seeders/seed-auth.ts new file mode 100644 index 0000000..0a907d1 --- /dev/null +++ b/prisma/seeders/seed-auth.ts @@ -0,0 +1,119 @@ +import "dotenv/config"; +import { hash } from "bcryptjs"; +import { generateId } from "better-auth"; +import { PrismaClient } from "../../generated/prisma"; + +const prisma = new PrismaClient(); + +/** + * Seed Admin User + * Creates or updates the admin user account + */ +export async function seedAdminUser() { + const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com"; + const adminPassword = process.env.ADMIN_PASSWORD || "admin123"; + + console.log(`Checking admin user: ${adminEmail}`); + + const existingUser = await prisma.user.findUnique({ + where: { email: adminEmail }, + }); + + if (existingUser) { + if (existingUser.role !== "admin") { + await prisma.user.update({ + where: { email: adminEmail }, + data: { role: "admin" }, + }); + console.log("Updated existing user to admin role."); + } + return existingUser.id; + } + + const hashedPassword = await hash(adminPassword, 12); + const userId = generateId(); + + await prisma.user.create({ + data: { + id: userId, + email: adminEmail, + name: "Admin Desa Darmasaba", + role: "admin", + emailVerified: true, + accounts: { + create: { + id: generateId(), + accountId: userId, + providerId: "credential", + password: hashedPassword, + }, + }, + }, + }); + + console.log(`āœ… Admin user created: ${adminEmail}`); + return userId; +} + +/** + * Seed Demo Users + * Creates demo users for testing (user, moderator roles) + */ +export async function seedDemoUsers() { + const demoUsers = [ + { + email: "demo1@example.com", + name: "Demo User 1", + password: "demo123", + role: "user", + }, + { + email: "demo2@example.com", + name: "Demo User 2", + password: "demo123", + role: "user", + }, + { + email: "moderator@example.com", + name: "Moderator Desa", + password: "demo123", + role: "moderator", + }, + ]; + + console.log("Seeding Demo Users..."); + + for (const demo of demoUsers) { + const existingUser = await prisma.user.findUnique({ + where: { email: demo.email }, + }); + + if (existingUser) { + console.log(`ā­ļø Demo user exists: ${demo.email}`); + continue; + } + + const hashedPassword = await hash(demo.password, 12); + const userId = generateId(); + + await prisma.user.create({ + data: { + id: userId, + email: demo.email, + name: demo.name, + role: demo.role, + emailVerified: true, + accounts: { + create: { + id: generateId(), + accountId: userId, + providerId: "credential", + password: hashedPassword, + }, + }, + }, + }); + + console.log(`āœ… Demo user created: ${demo.email}`); + } +} diff --git a/prisma/seeders/seed-dashboard-metrics.ts b/prisma/seeders/seed-dashboard-metrics.ts new file mode 100644 index 0000000..78f66c4 --- /dev/null +++ b/prisma/seeders/seed-dashboard-metrics.ts @@ -0,0 +1,109 @@ +import { PrismaClient } from "../../generated/prisma"; + +const prisma = new PrismaClient(); + +/** + * Seed Budget (APBDes) + * Creates village budget allocation data + */ +export async function seedBudget() { + console.log("Seeding Budget..."); + + const budgets = [ + { category: "Belanja", amount: 70, percentage: 70, color: "#3B82F6" }, + { category: "Pangan", amount: 45, percentage: 45, color: "#22C55E" }, + { category: "Pembiayaan", amount: 55, percentage: 55, color: "#FACC15" }, + { category: "Pendapatan", amount: 90, percentage: 90, color: "#3B82F6" }, + ]; + + for (const budget of budgets) { + await prisma.budget.upsert({ + where: { + category_fiscalYear: { + category: budget.category, + fiscalYear: 2025, + }, + }, + update: budget, + create: { ...budget, fiscalYear: 2025 }, + }); + } + + console.log("āœ… Budget seeded successfully"); +} + +/** + * Seed SDGs Scores + * Creates Sustainable Development Goals scores for dashboard + */ +export async function seedSdgsScores() { + console.log("Seeding SDGs Scores..."); + + const sdgs = [ + { + title: "Desa Berenergi Bersih dan Terbarukan", + score: 99.64, + image: "SDGS-7.png", + }, + { + title: "Desa Damai Berkeadilan", + score: 78.65, + image: "SDGS-16.png", + }, + { + title: "Desa Sehat dan Sejahtera", + score: 77.37, + image: "SDGS-3.png", + }, + { + title: "Desa Tanpa Kemiskinan", + score: 52.62, + image: "SDGS-1.png", + }, + ]; + + for (const sdg of sdgs) { + await prisma.sdgsScore.upsert({ + where: { title: sdg.title }, + update: sdg, + create: sdg, + }); + } + + console.log("āœ… SDGs Scores seeded successfully"); +} + +/** + * Seed Satisfaction Ratings + * Creates public satisfaction survey data + */ +export async function seedSatisfactionRatings() { + console.log("Seeding Satisfaction Ratings..."); + + const satisfactions = [ + { category: "Sangat Puas", value: 25, color: "#4E5BA6" }, + { category: "Puas", value: 25, color: "#F4C542" }, + { category: "Cukup", value: 25, color: "#8CC63F" }, + { category: "Kurang", value: 25, color: "#E57373" }, + ]; + + for (const sat of satisfactions) { + await prisma.satisfactionRating.upsert({ + where: { category: sat.category }, + update: sat, + create: sat, + }); + } + + console.log("āœ… Satisfaction Ratings seeded successfully"); +} + +/** + * Seed All Dashboard Metrics + * Main function to run all dashboard metrics seeders + */ +export async function seedDashboardMetrics() { + await seedBudget(); + await seedSdgsScores(); + await seedSatisfactionRatings(); +} diff --git a/prisma/seeders/seed-demographics.ts b/prisma/seeders/seed-demographics.ts new file mode 100644 index 0000000..d20ca34 --- /dev/null +++ b/prisma/seeders/seed-demographics.ts @@ -0,0 +1,124 @@ +import { PrismaClient, Gender, Religion } from "../../generated/prisma"; + +const prisma = new PrismaClient(); + +/** + * Seed Banjars (Village Hamlets) + * Creates 6 banjars in Darmasaba village + */ +export async function seedBanjars() { + const banjars = [ + { + name: "Darmasaba", + code: "DSB", + totalPopulation: 1200, + totalKK: 300, + totalPoor: 45, + }, + { + name: "Manesa", + code: "MNS", + totalPopulation: 950, + totalKK: 240, + totalPoor: 32, + }, + { + name: "Cabe", + code: "CBE", + totalPopulation: 800, + totalKK: 200, + totalPoor: 28, + }, + { + name: "Penenjoan", + code: "PNJ", + totalPopulation: 1100, + totalKK: 280, + totalPoor: 50, + }, + { + name: "Baler Pasar", + code: "BPS", + totalPopulation: 850, + totalKK: 210, + totalPoor: 35, + }, + { + name: "Bucu", + code: "BCU", + totalPopulation: 734, + totalKK: 184, + totalPoor: 24, + }, + ]; + + console.log("Seeding Banjars..."); + for (const banjar of banjars) { + await prisma.banjar.upsert({ + where: { name: banjar.name }, + update: banjar, + create: banjar, + }); + } + console.log("āœ… Banjars seeded successfully"); +} + +/** + * Get all Banjar IDs + * Helper function to retrieve banjar IDs for other seeders + */ +export async function getBanjarIds(): Promise { + const banjars = await prisma.banjar.findMany(); + return banjars.map((b) => b.id); +} + +/** + * Seed Residents + * Creates sample resident data for demographics + */ +export async function seedResidents(banjarIds: string[]) { + console.log("Seeding Residents..."); + + const residents = [ + { + nik: "5103010101700001", + kk: "5103010101700000", + name: "I Wayan Sudarsana", + birthDate: new Date("1970-05-15"), + birthPlace: "Badung", + gender: Gender.LAKI_LAKI, + religion: Religion.HINDU, + occupation: "Wiraswasta", + banjarId: banjarIds[0] || "", + rt: "001", + rw: "000", + address: "Jl. Raya Darmasaba No. 1", + isHeadOfHousehold: true, + }, + { + nik: "5103010101850002", + kk: "5103010101850000", + name: "Ni Made Arianti", + birthDate: new Date("1985-08-20"), + birthPlace: "Denpasar", + gender: Gender.PEREMPUAN, + religion: Religion.HINDU, + occupation: "Guru", + banjarId: banjarIds[1] || banjarIds[0] || "", + rt: "002", + rw: "000", + address: "Gg. Manesa No. 5", + isPoor: true, + }, + ]; + + for (const res of residents) { + await prisma.resident.upsert({ + where: { nik: res.nik }, + update: res, + create: res, + }); + } + + console.log("āœ… Residents seeded successfully"); +} diff --git a/prisma/seeders/seed-division-performance.ts b/prisma/seeders/seed-division-performance.ts new file mode 100644 index 0000000..1163d58 --- /dev/null +++ b/prisma/seeders/seed-division-performance.ts @@ -0,0 +1,101 @@ +import { + ActivityStatus, + Priority, + PrismaClient, +} from "../../generated/prisma"; + +const prisma = new PrismaClient(); + +/** + * Seed Divisions + * Creates 4 main village divisions/departments + */ +export async function seedDivisions() { + const divisions = [ + { + name: "Pemerintahan", + description: "Urusan administrasi dan tata kelola desa", + color: "#1E3A5F", + }, + { + name: "Pembangunan", + description: "Infrastruktur dan sarana prasarana desa", + color: "#2E7D32", + }, + { + name: "Pemberdayaan", + description: "Pemberdayaan ekonomi dan masyarakat", + color: "#EF6C00", + }, + { + name: "Kesejahteraan", + description: "Kesehatan, pendidikan, dan sosial", + color: "#C62828", + }, + ]; + + console.log("Seeding Divisions..."); + const createdDivisions = []; + for (const div of divisions) { + const d = await prisma.division.upsert({ + where: { name: div.name }, + update: div, + create: div, + }); + createdDivisions.push(d); + } + console.log("āœ… Divisions seeded successfully"); + return createdDivisions; +} + +/** + * Get all Division IDs + * Helper function to retrieve division IDs for other seeders + */ +export async function getDivisionIds(): Promise { + const divisions = await prisma.division.findMany(); + return divisions.map((d) => d.id); +} + +/** + * Seed Activities + * Creates sample activities for each division + */ +export async function seedActivities(divisionIds: string[]) { + console.log("Seeding Activities..."); + + const activities = [ + { + title: "Rapat Koordinasi 2025", + description: "Penyusunan rencana kerja tahunan", + divisionId: divisionIds[0] || "", + progress: 100, + status: ActivityStatus.SELESAI, + priority: Priority.TINGGI, + }, + { + title: "Pemutakhiran Indeks Desa", + description: "Pendataan SDG's Desa 2025", + divisionId: divisionIds[0] || "", + progress: 65, + status: ActivityStatus.BERJALAN, + priority: Priority.SEDANG, + }, + { + title: "Pembangunan Jalan Banjar Cabe", + description: "Pengaspalan jalan utama", + divisionId: divisionIds[1] || divisionIds[0] || "", + progress: 40, + status: ActivityStatus.BERJALAN, + priority: Priority.DARURAT, + }, + ]; + + for (const act of activities) { + await prisma.activity.create({ + data: act, + }); + } + + console.log("āœ… Activities seeded successfully"); +} diff --git a/prisma/seeders/seed-public-services.ts b/prisma/seeders/seed-public-services.ts new file mode 100644 index 0000000..b012f9b --- /dev/null +++ b/prisma/seeders/seed-public-services.ts @@ -0,0 +1,174 @@ +import { + ComplaintCategory, + ComplaintStatus, + EventType, + Priority, + PrismaClient, +} from "../../generated/prisma"; + +const prisma = new PrismaClient(); + +/** + * Seed Complaints + * Creates sample citizen complaints for testing + */ +export async function seedComplaints(adminId: string) { + console.log("Seeding Complaints..."); + + const complaints = [ + { + complaintNumber: `COMP-20250326-001`, + title: "Lampu Jalan Mati", + description: + "Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.", + category: ComplaintCategory.INFRASTRUKTUR, + status: ComplaintStatus.BARU, + priority: Priority.SEDANG, + location: "Banjar Manesa", + reporterId: adminId, + }, + { + complaintNumber: `COMP-20250326-002`, + title: "Sampah Menumpuk", + description: "Tumpukan sampah di area pasar Darmasaba belum diangkut.", + category: ComplaintCategory.KETERTIBAN_UMUM, + status: ComplaintStatus.DIPROSES, + priority: Priority.TINGGI, + location: "Pasar Darmasaba", + assignedTo: adminId, + }, + ]; + + for (const comp of complaints) { + await prisma.complaint.upsert({ + where: { complaintNumber: comp.complaintNumber }, + update: comp, + create: comp, + }); + } + + console.log("āœ… Complaints seeded successfully"); +} + +/** + * Seed Service Letters + * Creates sample administrative letter requests + */ +export async function seedServiceLetters(adminId: string) { + console.log("Seeding Service Letters..."); + + const serviceLetters = [ + { + letterNumber: "SKT-2025-001", + letterType: "KTP", + applicantName: "I Wayan Sudarsana", + applicantNik: "5103010101700001", + applicantAddress: "Jl. Raya Darmasaba No. 1", + purpose: "Pembuatan KTP baru", + status: "SELESAI", + processedBy: adminId, + completedAt: new Date(), + }, + { + letterNumber: "SKT-2025-002", + letterType: "KK", + applicantName: "Ni Made Arianti", + applicantNik: "5103010101850002", + applicantAddress: "Gg. Manesa No. 5", + purpose: "Perubahan data KK", + status: "DIPROSES", + processedBy: adminId, + }, + { + letterNumber: "SKT-2025-003", + letterType: "DOMISILI", + applicantName: "I Ketut Arsana", + applicantNik: "5103010101900003", + applicantAddress: "Jl. Cabe No. 10", + purpose: "Surat keterangan domisili", + status: "BARU", + }, + ]; + + for (const letter of serviceLetters) { + await prisma.serviceLetter.upsert({ + where: { letterNumber: letter.letterNumber }, + update: letter, + create: letter, + }); + } + + console.log("āœ… Service Letters seeded successfully"); +} + +/** + * Seed Events + * Creates sample village events and meetings + */ +export async function seedEvents(adminId: string) { + console.log("Seeding Events..."); + + const events = [ + { + title: "Rapat Pleno Desa", + description: "Pembahasan anggaran belanja desa", + eventType: EventType.RAPAT, + startDate: new Date(), + location: "Balai Desa Darmasaba", + createdBy: adminId, + }, + { + title: "Gotong Royong Kebersihan", + description: "Kegiatan rutin mingguan", + eventType: EventType.SOSIAL, + startDate: new Date(Date.now() + 86400000), // Besok + location: "Seluruh Banjar", + createdBy: adminId, + }, + ]; + + for (const event of events) { + await prisma.event.create({ + data: event, + }); + } + + console.log("āœ… Events seeded successfully"); +} + +/** + * Seed Innovation Ideas + * Creates sample citizen innovation submissions + */ +export async function seedInnovationIdeas(adminId: string) { + console.log("Seeding Innovation Ideas..."); + + const innovationIdeas = [ + { + title: "Sistem Informasi Desa Digital", + description: "Platform digital untuk layanan administrasi desa", + category: "Teknologi", + submitterName: "I Made Wijaya", + submitterContact: "081234567890", + status: "DIKAJI", + reviewedBy: adminId, + notes: "Perlu kajian lebih lanjut tentang anggaran", + }, + { + title: "Program Bank Sampah", + description: "Pengelolaan sampah berbasis bank sampah", + category: "Lingkungan", + submitterName: "Ni Putu Sari", + submitterContact: "081234567891", + status: "BARU", + }, + ]; + + for (const idea of innovationIdeas) { + await prisma.innovationIdea.create({ + data: idea, + }); + } + + console.log("āœ… Innovation Ideas seeded successfully"); +} diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts new file mode 100644 index 0000000..c501068 --- /dev/null +++ b/src/api/dashboard.ts @@ -0,0 +1,72 @@ +import { Elysia, t } from "elysia"; +import { prisma } from "../utils/db"; + +export const dashboard = new Elysia({ prefix: "/dashboard" }) + .get( + "/budget", + async () => { + const data = await prisma.budget.findMany({ + where: { fiscalYear: 2025 }, + orderBy: { category: "asc" }, + }); + return { data }; + }, + { + response: { + 200: t.Object({ + data: t.Array( + t.Object({ + category: t.String(), + amount: t.Number(), + percentage: t.Number(), + color: t.String(), + }), + ), + }), + }, + }, + ) + .get( + "/sdgs", + async () => { + const data = await prisma.sdgsScore.findMany({ + orderBy: { score: "desc" }, + }); + return { data }; + }, + { + response: { + 200: t.Object({ + data: t.Array( + t.Object({ + title: t.String(), + score: t.Number(), + image: t.Nullable(t.String()), + }), + ), + }), + }, + }, + ) + .get( + "/satisfaction", + async () => { + const data = await prisma.satisfactionRating.findMany({ + orderBy: { value: "desc" }, + }); + return { data }; + }, + { + response: { + 200: t.Object({ + data: t.Array( + t.Object({ + category: t.String(), + value: t.Number(), + color: t.String(), + }), + ), + }), + }, + }, + ); diff --git a/src/api/index.tsx b/src/api/index.tsx index 0fa23c1..c5ee92f 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -5,6 +5,7 @@ import { apiMiddleware } from "../middleware/apiMiddleware"; import { auth } from "../utils/auth"; import { apikey } from "./apikey"; import { complaint } from "./complaint"; +import { dashboard } from "./dashboard"; import { division } from "./division"; import { event } from "./event"; import { profile } from "./profile"; @@ -40,7 +41,8 @@ const api = new Elysia({ .use(division) .use(complaint) .use(resident) - .use(event); + .use(event) + .use(dashboard); if (!isProduction) { api.use( diff --git a/src/components/dashboard-content.tsx b/src/components/dashboard-content.tsx index 64665ae..9fcefe8 100644 --- a/src/components/dashboard-content.tsx +++ b/src/components/dashboard-content.tsx @@ -1,4 +1,4 @@ -import { Grid, Image, Stack } from "@mantine/core"; +import { Grid, Image, Loader, Stack, Center } from "@mantine/core"; import { CheckCircle, FileText, MessageCircle, Users } from "lucide-react"; import { useEffect, useState } from "react"; import { apiClient } from "@/utils/api-client"; @@ -10,29 +10,6 @@ import { SatisfactionChart } from "./dashboard/satisfaction-chart"; import { SDGSCard } from "./dashboard/sdgs-card"; import { StatCard } from "./dashboard/stat-card"; -const sdgsData = [ - { - title: "Desa Berenergi Bersih dan Terbarukan", - score: 99.64, - image: "SDGS-7.png", - }, - { - title: "Desa Damai Berkeadilan", - score: 78.65, - image: "SDGS-16.png", - }, - { - title: "Desa Sehat dan Sejahtera", - score: 77.37, - image: "SDGS-3.png", - }, - { - title: "Desa Tanpa Kemiskinan", - score: 52.62, - image: "SDGS-1.png", - }, -]; - export function DashboardContent() { const [stats, setStats] = useState({ complaints: { total: 0, baru: 0, proses: 0, selesai: 0 }, @@ -41,14 +18,18 @@ export function DashboardContent() { loading: true, }); + const [sdgsData, setSdgsData] = useState<{ title: string; score: number; image: string | null }[]>([]); + const [sdgsLoading, setSdgsLoading] = useState(true); + useEffect(() => { async function fetchStats() { try { - const [complaintRes, residentRes, weeklyServiceRes] = await Promise.all( + const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] = await Promise.all( [ apiClient.GET("/api/complaint/stats"), apiClient.GET("/api/resident/stats"), apiClient.GET("/api/complaint/service-weekly"), + apiClient.GET("/api/dashboard/sdgs"), ], ); @@ -71,9 +52,15 @@ export function DashboardContent() { ?.count || 0, loading: false, }); + + if (sdgsRes.data?.data) { + setSdgsData(sdgsRes.data.data); + } + setSdgsLoading(false); } catch (error) { - console.error("Failed to fetch stats", error); + console.error("Failed to fetch dashboard content", error); setStats((prev) => ({ ...prev, loading: false })); + setSdgsLoading(false); } } @@ -146,17 +133,23 @@ export function DashboardContent() { {/* Section 6: SDGs Desa Cards */} - - {sdgsData.map((sdg) => ( - - } - title={sdg.title} - score={sdg.score} - /> - - ))} - + {sdgsLoading ? ( +
+ +
+ ) : ( + + {sdgsData.map((sdg) => ( + + : null} + title={sdg.title} + score={sdg.score} + /> + + ))} + + )} ); } diff --git a/src/components/dashboard/chart-apbdes.tsx b/src/components/dashboard/chart-apbdes.tsx index 929874f..318ab5b 100644 --- a/src/components/dashboard/chart-apbdes.tsx +++ b/src/components/dashboard/chart-apbdes.tsx @@ -1,11 +1,13 @@ import { Card, Group, + Loader, Stack, Text, Title, useMantineColorScheme, } from "@mantine/core"; +import { useEffect, useState } from "react"; import { Bar, BarChart, @@ -15,18 +17,44 @@ import { XAxis, YAxis, } from "recharts"; +import { apiClient } from "@/utils/api-client"; -const apbdesData = [ - { name: "Belanja", value: 70, color: "#3B82F6" }, - { name: "Pangan", value: 45, color: "#22C55E" }, - { name: "Pembiayaan", value: 55, color: "#FACC15" }, - { name: "Pendapatan", value: 90, color: "#3B82F6" }, -]; +interface ApbdesData { + name: string; + value: number; + color: string; +} export function ChartAPBDes() { const { colorScheme } = useMantineColorScheme(); const dark = colorScheme === "dark"; + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchApbdes() { + try { + const res = await apiClient.GET("/api/dashboard/budget"); + if (res.data?.data) { + setData( + res.data.data.map((d) => ({ + name: d.category, + value: d.percentage, + color: d.color, + })), + ); + } + } catch (error) { + console.error("Failed to fetch APBDes data", error); + } finally { + setLoading(false); + } + } + + fetchApbdes(); + }, []); + return ( - {apbdesData.map((item) => ( - - - {item.name} - - - - - - [ - `${value}%`, - "", - ]} - contentStyle={{ - backgroundColor: dark ? "#1E293B" : "white", - borderColor: dark ? "#334155" : "#e5e7eb", - borderRadius: "8px", - }} - /> - - - - - + {loading ? ( + + - ))} + ) : data.length > 0 ? ( + data.map((item) => ( + + + {item.name} + + + + + + + + + + + + {item.value}% + + + )) + ) : ( + + Tidak ada data APBDes + + )} ); diff --git a/src/components/dashboard/satisfaction-chart.tsx b/src/components/dashboard/satisfaction-chart.tsx index 862ccfa..51b8f27 100644 --- a/src/components/dashboard/satisfaction-chart.tsx +++ b/src/components/dashboard/satisfaction-chart.tsx @@ -2,23 +2,51 @@ import { Box, Card, Group, + Loader, Text, Title, useMantineColorScheme, } from "@mantine/core"; +import { useEffect, useState } from "react"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; +import { apiClient } from "@/utils/api-client"; -const satisfactionData = [ - { name: "Sangat Puas", value: 25, color: "#4E5BA6" }, - { name: "Puas", value: 25, color: "#F4C542" }, - { name: "Cukup", value: 25, color: "#8CC63F" }, - { name: "Kurang", value: 25, color: "#E57373" }, -]; +interface SatisfactionData { + name: string; + value: number; + color: string; +} export function SatisfactionChart() { const { colorScheme } = useMantineColorScheme(); const dark = colorScheme === "dark"; + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchSatisfaction() { + try { + const res = await apiClient.GET("/api/dashboard/satisfaction"); + if (res.data?.data) { + setData( + res.data.data.map((d) => ({ + name: d.category, + value: d.value, + color: d.color, + })), + ); + } + } catch (error) { + console.error("Failed to fetch satisfaction data", error); + } finally { + setLoading(false); + } + } + + fetchSatisfaction(); + }, []); + return ( - - - {satisfactionData.map((entry) => ( - - ))} - - - + {loading ? ( + + + + ) : ( + + + {data.map((entry) => ( + + ))} + + + + )} - {satisfactionData.map((item) => ( + {data.map((item) => ( navigate({ to: "/signin" })} + onClick={() => navigate({ to: "/admin" })} />