[darmasaba-dashboard][2026-03-27] feat: modular seeders and database-backed dashboard
- Split seeders into modular files per feature category - Added seed:auth, seed:demographics, seed:divisions, seed:services, seed:dashboard commands - Connected dashboard components to live database (Budget, SDGs, Satisfaction) - Added API endpoints: /api/dashboard/budget, /api/dashboard/sdgs, /api/dashboard/satisfaction - Updated prisma schema with dashboard metrics models - Added loading states to dashboard components - Fixed header navigation to /admin Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
168
generated/api.ts
168
generated/api.ts
@@ -358,6 +358,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
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;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3041,6 +3041,363 @@
|
|||||||
"operationId": "getApiEventToday",
|
"operationId": "getApiEventToday",
|
||||||
"summary": "Get events for today"
|
"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": {
|
"components": {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
"test:e2e": "bun run build && playwright test",
|
"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",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@better-auth/cli": "^1.4.18",
|
"@better-auth/cli": "^1.4.18",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -31,7 +31,6 @@ model User {
|
|||||||
innovationIdeas InnovationIdea[] @relation("IdeaReviewer")
|
innovationIdeas InnovationIdea[] @relation("IdeaReviewer")
|
||||||
healthRecords HealthRecord[]
|
healthRecords HealthRecord[]
|
||||||
populationDynamics PopulationDynamic[]
|
populationDynamics PopulationDynamic[]
|
||||||
budgets Budget[]
|
|
||||||
budgetTransactions BudgetTransaction[]
|
budgetTransactions BudgetTransaction[]
|
||||||
posyandus Posyandu[]
|
posyandus Posyandu[]
|
||||||
securityReports SecurityReport[]
|
securityReports SecurityReport[]
|
||||||
@@ -315,6 +314,46 @@ model Banjar {
|
|||||||
@@map("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) ---
|
// --- STUBS FOR PHASE 2+ (To maintain relations) ---
|
||||||
|
|
||||||
model HealthRecord {
|
model HealthRecord {
|
||||||
@@ -337,12 +376,6 @@ model PopulationDynamic {
|
|||||||
documentor User @relation(fields: [documentedBy], references: [id])
|
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 {
|
model BudgetTransaction {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdBy String
|
createdBy String
|
||||||
|
|||||||
433
prisma/seed.ts
433
prisma/seed.ts
@@ -1,327 +1,144 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { hash } from "bcryptjs";
|
import { PrismaClient } from "../generated/prisma";
|
||||||
import { generateId } from "better-auth";
|
|
||||||
|
// 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 {
|
import {
|
||||||
ActivityStatus,
|
seedComplaints,
|
||||||
ComplaintCategory,
|
seedServiceLetters,
|
||||||
ComplaintStatus,
|
seedEvents,
|
||||||
EventType,
|
seedInnovationIdeas,
|
||||||
Gender,
|
} from "./seeders/seed-public-services";
|
||||||
Priority,
|
import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics";
|
||||||
PrismaClient,
|
|
||||||
Religion,
|
|
||||||
} from "../generated/prisma";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function seedAdminUser() {
|
/**
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || "admin@example.com";
|
* Run All Seeders
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
|
* Executes all seeder functions in the correct order
|
||||||
|
*/
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSeed() {
|
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();
|
const adminId = await seedAdminUser();
|
||||||
await seedBanjars();
|
await seedDemoUsers();
|
||||||
const banjars = await prisma.banjar.findMany();
|
console.log();
|
||||||
const banjarIds = banjars.map((b) => b.id);
|
|
||||||
|
|
||||||
|
// 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 divisions = await seedDivisions();
|
||||||
const divisionIds = divisions.map((d) => d.id);
|
const divisionIds = divisions.map((d) => d.id);
|
||||||
|
|
||||||
await seedResidents(banjarIds);
|
|
||||||
await seedActivities(divisionIds);
|
await seedActivities(divisionIds);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// 4. Seed Public Services (Complaints, Service Letters, Events, Innovation)
|
||||||
|
console.log("📁 [4/6] Public Services");
|
||||||
await seedComplaints(adminId);
|
await seedComplaints(adminId);
|
||||||
|
await seedServiceLetters(adminId);
|
||||||
await seedEvents(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()
|
* Run Specific Seeder
|
||||||
.catch((e) => {
|
* Allows running individual seeders by name
|
||||||
console.error(e);
|
*/
|
||||||
|
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);
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
prisma/seeders/seed-auth.ts
Normal file
119
prisma/seeders/seed-auth.ts
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
prisma/seeders/seed-dashboard-metrics.ts
Normal file
109
prisma/seeders/seed-dashboard-metrics.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
124
prisma/seeders/seed-demographics.ts
Normal file
124
prisma/seeders/seed-demographics.ts
Normal file
@@ -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<string[]> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
101
prisma/seeders/seed-division-performance.ts
Normal file
101
prisma/seeders/seed-division-performance.ts
Normal file
@@ -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<string[]> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
174
prisma/seeders/seed-public-services.ts
Normal file
174
prisma/seeders/seed-public-services.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
72
src/api/dashboard.ts
Normal file
72
src/api/dashboard.ts
Normal file
@@ -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(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -5,6 +5,7 @@ import { apiMiddleware } from "../middleware/apiMiddleware";
|
|||||||
import { auth } from "../utils/auth";
|
import { auth } from "../utils/auth";
|
||||||
import { apikey } from "./apikey";
|
import { apikey } from "./apikey";
|
||||||
import { complaint } from "./complaint";
|
import { complaint } from "./complaint";
|
||||||
|
import { dashboard } from "./dashboard";
|
||||||
import { division } from "./division";
|
import { division } from "./division";
|
||||||
import { event } from "./event";
|
import { event } from "./event";
|
||||||
import { profile } from "./profile";
|
import { profile } from "./profile";
|
||||||
@@ -40,7 +41,8 @@ const api = new Elysia({
|
|||||||
.use(division)
|
.use(division)
|
||||||
.use(complaint)
|
.use(complaint)
|
||||||
.use(resident)
|
.use(resident)
|
||||||
.use(event);
|
.use(event)
|
||||||
|
.use(dashboard);
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
api.use(
|
api.use(
|
||||||
|
|||||||
@@ -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 { CheckCircle, FileText, MessageCircle, Users } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
@@ -10,29 +10,6 @@ import { SatisfactionChart } from "./dashboard/satisfaction-chart";
|
|||||||
import { SDGSCard } from "./dashboard/sdgs-card";
|
import { SDGSCard } from "./dashboard/sdgs-card";
|
||||||
import { StatCard } from "./dashboard/stat-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() {
|
export function DashboardContent() {
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
|
complaints: { total: 0, baru: 0, proses: 0, selesai: 0 },
|
||||||
@@ -41,14 +18,18 @@ export function DashboardContent() {
|
|||||||
loading: true,
|
loading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [sdgsData, setSdgsData] = useState<{ title: string; score: number; image: string | null }[]>([]);
|
||||||
|
const [sdgsLoading, setSdgsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
try {
|
try {
|
||||||
const [complaintRes, residentRes, weeklyServiceRes] = await Promise.all(
|
const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] = await Promise.all(
|
||||||
[
|
[
|
||||||
apiClient.GET("/api/complaint/stats"),
|
apiClient.GET("/api/complaint/stats"),
|
||||||
apiClient.GET("/api/resident/stats"),
|
apiClient.GET("/api/resident/stats"),
|
||||||
apiClient.GET("/api/complaint/service-weekly"),
|
apiClient.GET("/api/complaint/service-weekly"),
|
||||||
|
apiClient.GET("/api/dashboard/sdgs"),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,9 +52,15 @@ export function DashboardContent() {
|
|||||||
?.count || 0,
|
?.count || 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sdgsRes.data?.data) {
|
||||||
|
setSdgsData(sdgsRes.data.data);
|
||||||
|
}
|
||||||
|
setSdgsLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch stats", error);
|
console.error("Failed to fetch dashboard content", error);
|
||||||
setStats((prev) => ({ ...prev, loading: false }));
|
setStats((prev) => ({ ...prev, loading: false }));
|
||||||
|
setSdgsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,17 +133,23 @@ export function DashboardContent() {
|
|||||||
<ChartAPBDes />
|
<ChartAPBDes />
|
||||||
|
|
||||||
{/* Section 6: SDGs Desa Cards */}
|
{/* Section 6: SDGs Desa Cards */}
|
||||||
<Grid gutter="md">
|
{sdgsLoading ? (
|
||||||
{sdgsData.map((sdg) => (
|
<Center py="xl">
|
||||||
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
|
<Loader />
|
||||||
<SDGSCard
|
</Center>
|
||||||
image={<Image src={sdg.image} alt={sdg.title} />}
|
) : (
|
||||||
title={sdg.title}
|
<Grid gutter="md">
|
||||||
score={sdg.score}
|
{sdgsData.map((sdg) => (
|
||||||
/>
|
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
|
||||||
</Grid.Col>
|
<SDGSCard
|
||||||
))}
|
image={sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null}
|
||||||
</Grid>
|
title={sdg.title}
|
||||||
|
score={sdg.score}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -15,18 +17,44 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
const apbdesData = [
|
interface ApbdesData {
|
||||||
{ name: "Belanja", value: 70, color: "#3B82F6" },
|
name: string;
|
||||||
{ name: "Pangan", value: 45, color: "#22C55E" },
|
value: number;
|
||||||
{ name: "Pembiayaan", value: 55, color: "#FACC15" },
|
color: string;
|
||||||
{ name: "Pendapatan", value: 90, color: "#3B82F6" },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export function ChartAPBDes() {
|
export function ChartAPBDes() {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const dark = colorScheme === "dark";
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<ApbdesData[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
@@ -45,33 +73,39 @@ export function ChartAPBDes() {
|
|||||||
Grafik APBDes
|
Grafik APBDes
|
||||||
</Title>
|
</Title>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{apbdesData.map((item) => (
|
{loading ? (
|
||||||
<Group key={item.name} align="center" gap="md">
|
<Group justify="center" py="xl">
|
||||||
<Text size="sm" fw={500} w={100} c={dark ? "white" : "gray.7"}>
|
<Loader />
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
<ResponsiveContainer width="100%" height={20}>
|
|
||||||
<BarChart layout="vertical" data={[item]}>
|
|
||||||
<XAxis type="number" hide domain={[0, 100]} />
|
|
||||||
<YAxis type="category" hide dataKey="name" />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number | string | undefined) => [
|
|
||||||
`${value}%`,
|
|
||||||
"",
|
|
||||||
]}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: dark ? "#1E293B" : "white",
|
|
||||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="value" radius={[4, 4, 4, 4]}>
|
|
||||||
<Cell fill={item.color} />
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
) : data.length > 0 ? (
|
||||||
|
data.map((item) => (
|
||||||
|
<Group key={item.name} align="center" gap="md">
|
||||||
|
<Text size="sm" fw={500} w={100} c={dark ? "white" : "gray.7"}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<ResponsiveContainer width="100%" height={12} style={{ flex: 1 }}>
|
||||||
|
<BarChart
|
||||||
|
layout="vertical"
|
||||||
|
data={[item]}
|
||||||
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
|
<YAxis type="category" hide dataKey="name" />
|
||||||
|
<Bar dataKey="value" radius={[10, 10, 10, 10]} barSize={12}>
|
||||||
|
<Cell fill={item.color} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Text size="sm" fw={600} w={40} ta="right" c={dark ? "white" : "gray.9"}>
|
||||||
|
{item.value}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
Tidak ada data APBDes
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,23 +2,51 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
|
||||||
const satisfactionData = [
|
interface SatisfactionData {
|
||||||
{ name: "Sangat Puas", value: 25, color: "#4E5BA6" },
|
name: string;
|
||||||
{ name: "Puas", value: 25, color: "#F4C542" },
|
value: number;
|
||||||
{ name: "Cukup", value: 25, color: "#8CC63F" },
|
color: string;
|
||||||
{ name: "Kurang", value: 25, color: "#E57373" },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export function SatisfactionChart() {
|
export function SatisfactionChart() {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const dark = colorScheme === "dark";
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const [data, setData] = useState<SatisfactionData[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
p="md"
|
p="md"
|
||||||
@@ -40,31 +68,37 @@ export function SatisfactionChart() {
|
|||||||
Tingkat kepuasan layanan
|
Tingkat kepuasan layanan
|
||||||
</Text>
|
</Text>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
{loading ? (
|
||||||
<Pie
|
<Group justify="center" align="center" h="100%">
|
||||||
data={satisfactionData}
|
<Loader />
|
||||||
cx="50%"
|
</Group>
|
||||||
cy="50%"
|
) : (
|
||||||
innerRadius={80}
|
<PieChart>
|
||||||
outerRadius={120}
|
<Pie
|
||||||
paddingAngle={2}
|
data={data}
|
||||||
dataKey="value"
|
cx="50%"
|
||||||
>
|
cy="50%"
|
||||||
{satisfactionData.map((entry) => (
|
innerRadius={80}
|
||||||
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
outerRadius={120}
|
||||||
))}
|
paddingAngle={2}
|
||||||
</Pie>
|
dataKey="value"
|
||||||
<Tooltip
|
>
|
||||||
contentStyle={{
|
{data.map((entry) => (
|
||||||
backgroundColor: dark ? "#1E293B" : "white",
|
<Cell key={`cell-${entry.name}`} fill={entry.color} />
|
||||||
borderColor: dark ? "#334155" : "#e5e7eb",
|
))}
|
||||||
borderRadius: "8px",
|
</Pie>
|
||||||
}}
|
<Tooltip
|
||||||
/>
|
contentStyle={{
|
||||||
</PieChart>
|
backgroundColor: dark ? "#1E293B" : "white",
|
||||||
|
borderColor: dark ? "#334155" : "#e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
)}
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<Group justify="center" gap="md" mt="md">
|
<Group justify="center" gap="md" mt="md">
|
||||||
{satisfactionData.map((item) => (
|
{data.map((item) => (
|
||||||
<Group key={item.name} gap="xs">
|
<Group key={item.name} gap="xs">
|
||||||
<Box
|
<Box
|
||||||
w={12}
|
w={12}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function Header({ onSidebarToggle }: HeaderProps) {
|
|||||||
<IconUserShield
|
<IconUserShield
|
||||||
color="white"
|
color="white"
|
||||||
style={{ width: "70%", height: "70%" }}
|
style={{ width: "70%", height: "70%" }}
|
||||||
onClick={() => navigate({ to: "/signin" })}
|
onClick={() => navigate({ to: "/admin" })}
|
||||||
/>
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
Reference in New Issue
Block a user