feat(noc): implement sync management UI and backend integration

This commit is contained in:
2026-03-30 14:48:47 +08:00
parent 3125bc1002
commit 65844bac7e
28 changed files with 2558 additions and 1339 deletions

View File

@@ -1,7 +1,5 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": ["Bash(bun *)"]
"Bash(bun *)" }
] }
}
}

View File

@@ -33,7 +33,9 @@ describe("NOC API Module", () => {
it("should return diagram jumlah document", async () => { it("should return diagram jumlah document", async () => {
const response = await api.handle( const response = await api.handle(
new Request(`http://localhost/api/noc/diagram-jumlah-document?idDesa=${idDesa}`), new Request(
`http://localhost/api/noc/diagram-jumlah-document?idDesa=${idDesa}`,
),
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
const data = await response.json(); const data = await response.json();
@@ -42,7 +44,9 @@ describe("NOC API Module", () => {
it("should return diagram progres kegiatan", async () => { it("should return diagram progres kegiatan", async () => {
const response = await api.handle( const response = await api.handle(
new Request(`http://localhost/api/noc/diagram-progres-kegiatan?idDesa=${idDesa}`), new Request(
`http://localhost/api/noc/diagram-progres-kegiatan?idDesa=${idDesa}`,
),
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
const data = await response.json(); const data = await response.json();
@@ -51,7 +55,9 @@ describe("NOC API Module", () => {
it("should return latest discussion", async () => { it("should return latest discussion", async () => {
const response = await api.handle( const response = await api.handle(
new Request(`http://localhost/api/noc/latest-discussion?idDesa=${idDesa}`), new Request(
`http://localhost/api/noc/latest-discussion?idDesa=${idDesa}`,
),
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
const data = await response.json(); const data = await response.json();

View File

@@ -36,6 +36,134 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/noc/sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["postApiNocSync"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/last-sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLast-sync"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/active-divisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocActive-divisions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLatest-projects"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/upcoming-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocUpcoming-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-jumlah-document": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocDiagram-jumlah-document"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-progres-kegiatan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocDiagram-progres-kegiatan"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-discussion": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLatest-discussion"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/apikey/": { "/api/apikey/": {
parameters: { parameters: {
query?: never; query?: never;
@@ -457,102 +585,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/noc/active-divisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocActive-divisions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLatest-projects"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/upcoming-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocUpcoming-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-jumlah-document": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocDiagram-jumlah-document"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-progres-kegiatan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocDiagram-progres-kegiatan"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-discussion": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLatest-discussion"];
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 {
@@ -619,6 +651,362 @@ export interface operations {
}; };
}; };
}; };
postApiNocSync: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
success: boolean;
message?: string;
error?: string;
lastSyncedAt?: string;
};
"multipart/form-data": {
success: boolean;
message?: string;
error?: string;
lastSyncedAt?: string;
};
"text/plain": {
success: boolean;
message?: string;
error?: string;
lastSyncedAt?: string;
};
};
};
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
error: string;
};
"multipart/form-data": {
error: string;
};
"text/plain": {
error: string;
};
};
};
};
};
"getApiNocLast-sync": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
lastSyncedAt: (string | null) | null;
};
"multipart/form-data": {
lastSyncedAt: (string | null) | null;
};
"text/plain": {
lastSyncedAt: (string | null) | null;
};
};
};
};
};
"getApiNocActive-divisions": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
"text/plain": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
};
};
};
};
"getApiNocLatest-projects": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
"text/plain": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
};
};
};
};
"getApiNocUpcoming-events": {
parameters: {
query: {
idDesa: string;
limit?: string;
filter?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
"text/plain": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
};
};
};
};
"getApiNocDiagram-jumlah-document": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
category: string;
count: number;
}[];
};
"multipart/form-data": {
data: {
category: string;
count: number;
}[];
};
"text/plain": {
data: {
category: string;
count: number;
}[];
};
};
};
};
};
"getApiNocDiagram-progres-kegiatan": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
"multipart/form-data": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
"text/plain": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
};
};
};
};
"getApiNocLatest-discussion": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
"text/plain": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
};
};
};
};
getApiApikey: { getApiApikey: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2070,279 +2458,4 @@ export interface operations {
}; };
}; };
}; };
"getApiNocActive-divisions": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
"text/plain": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
};
};
};
};
"getApiNocLatest-projects": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
"text/plain": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
};
};
};
};
"getApiNocUpcoming-events": {
parameters: {
query: {
idDesa: string;
limit?: string;
filter?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
"text/plain": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
};
};
};
};
"getApiNocDiagram-jumlah-document": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
category: string;
count: number;
}[];
};
"multipart/form-data": {
data: {
category: string;
count: number;
}[];
};
"text/plain": {
data: {
category: string;
count: number;
}[];
};
};
};
};
};
"getApiNocDiagram-progres-kegiatan": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
"multipart/form-data": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
"text/plain": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
};
};
};
};
"getApiNocLatest-discussion": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
"text/plain": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
};
};
};
};
} }

269
generated/noc-external.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/noc/active-divisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Divisi Teraktif
* @description Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.
*/
get: operations["getApiNocActive-divisions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Latest Projects General
* @description Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.
*/
get: operations["getApiNocLatest-projects"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/upcoming-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Events (Today & Upcoming)
* @description Menu beranda dan kinerja divisi - Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.
*/
get: operations["getApiNocUpcoming-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-jumlah-document": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Diagram Jumlah Document
* @description Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.
*/
get: operations["getApiNocDiagram-jumlah-document"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-progres-kegiatan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Diagram Progres Kegiatan
* @description Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.
*/
get: operations["getApiNocDiagram-progres-kegiatan"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-discussion": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Latest Discussion
* @description Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.
*/
get: operations["getApiNocLatest-discussion"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: never;
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
"getApiNocActive-divisions": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Jumlah maksimal data (default: 5) */
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocLatest-projects": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Jumlah maksimal proyek (default: 5, maks: 50) */
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocUpcoming-events": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Jumlah maksimal event (default: 10, maks: 50) */
limit?: string;
/** @description Filter event: 'today' atau 'upcoming' */
filter?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocDiagram-jumlah-document": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocDiagram-progres-kegiatan": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
"getApiNocLatest-discussion": {
parameters: {
query: {
/** @description ID Desa yang ingin dicari */
idDesa: string;
/** @description Limit data */
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"check": "biome check --write .", "check": "biome check --write .",
"format": "biome format --write .", "format": "biome format --write .",
"gen:api": "bun scripts/generate-schema.ts && bun x openapi-typescript generated/schema.json -o generated/api.ts", "gen:api": "bun scripts/generate-schema.ts && bun x openapi-typescript generated/schema.json -o generated/api.ts",
"sync:noc": "bun scripts/sync-noc.ts",
"test": "bun test __tests__/api", "test": "bun test __tests__/api",
"test:ui": "bun test --ui __tests__/api", "test:ui": "bun test --ui __tests__/api",
"test:e2e": "bun run build && playwright test", "test:e2e": "bun run build && playwright test",

View File

@@ -0,0 +1,44 @@
/*
Warnings:
- A unique constraint covering the columns `[externalId]` on the table `activity` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `discussion` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `division` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `document` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[externalId]` on the table `event` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "activity" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "discussion" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "division" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "document" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- AlterTable
ALTER TABLE "event" ADD COLUMN "externalId" TEXT,
ADD COLUMN "villageId" TEXT DEFAULT 'darmasaba';
-- CreateIndex
CREATE UNIQUE INDEX "activity_externalId_key" ON "activity"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "discussion_externalId_key" ON "discussion"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "division_externalId_key" ON "division"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "document_externalId_key" ON "document"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "event_externalId_key" ON "event"("externalId");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "division" ADD COLUMN "lastSyncedAt" TIMESTAMP(3);

View File

@@ -42,10 +42,13 @@ model User {
model Division { model Division {
id String @id @default(cuid()) id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("darmasaba") // ID Desa dari sistem NOC
name String @unique name String @unique
description String? description String?
color String @default("#1E3A5F") color String @default("#1E3A5F")
isActive Boolean @default(true) isActive Boolean @default(true)
lastSyncedAt DateTime? // Terakhir kali sinkronisasi dilakukan
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -59,6 +62,8 @@ model Division {
model Activity { model Activity {
id String @id @default(cuid()) id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("darmasaba")
title String title String
description String? description String?
divisionId String divisionId String
@@ -82,6 +87,8 @@ model Activity {
model Document { model Document {
id String @id @default(cuid()) id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("darmasaba")
title String title String
category DocumentCategory category DocumentCategory
type String // "Gambar", "Dokumen", "PDF", etc type String // "Gambar", "Dokumen", "PDF", etc
@@ -101,6 +108,8 @@ model Document {
model Discussion { model Discussion {
id String @id @default(cuid()) id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("darmasaba")
message String message String
senderId String senderId String
parentId String? // For threaded discussions parentId String? // For threaded discussions
@@ -121,6 +130,8 @@ model Discussion {
model Event { model Event {
id String @id @default(cuid()) id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC
villageId String? @default("darmasaba")
title String title String
description String? description String?
eventType EventType eventType EventType

View File

@@ -2,20 +2,32 @@ import "dotenv/config";
import { PrismaClient } from "../generated/prisma"; import { PrismaClient } from "../generated/prisma";
// Import all seeders // Import all seeders
import { seedAdminUser, seedDemoUsers, seedApiKeys } from "./seeders/seed-auth"; import { seedAdminUser, seedApiKeys, seedDemoUsers } from "./seeders/seed-auth";
import { seedBanjars, seedResidents, getBanjarIds } from "./seeders/seed-demographics"; import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics";
import { seedDivisions, seedActivities, getDivisionIds } from "./seeders/seed-division-performance";
import { import {
getBanjarIds,
seedBanjars,
seedResidents,
} from "./seeders/seed-demographics";
import {
seedDiscussions,
seedDivisionMetrics,
seedDocuments,
} from "./seeders/seed-discussions";
import {
getDivisionIds,
seedActivities,
seedDivisions,
} from "./seeders/seed-division-performance";
import { seedPhase2 } from "./seeders/seed-phase2";
import {
getComplaintIds,
seedComplaints, seedComplaints,
seedServiceLetters, seedComplaintUpdates,
seedEvents, seedEvents,
seedInnovationIdeas, seedInnovationIdeas,
seedComplaintUpdates, seedServiceLetters,
getComplaintIds,
} from "./seeders/seed-public-services"; } from "./seeders/seed-public-services";
import { seedDocuments, seedDiscussions, seedDivisionMetrics } from "./seeders/seed-discussions";
import { seedDashboardMetrics } from "./seeders/seed-dashboard-metrics";
import { seedPhase2 } from "./seeders/seed-phase2";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -45,7 +57,9 @@ export async function runSeed() {
// Check if data already exists // Check if data already exists
const existingData = await hasExistingData(); const existingData = await hasExistingData();
if (existingData) { if (existingData) {
console.log("⏭️ Existing data detected. Skipping seed to prevent duplicates.\n"); console.log(
"⏭️ Existing data detected. Skipping seed to prevent duplicates.\n",
);
console.log("💡 To re-seed, either:"); console.log("💡 To re-seed, either:");
console.log(" 1. Run: bun x prisma migrate reset (resets database)"); console.log(" 1. Run: bun x prisma migrate reset (resets database)");
console.log(" 2. Manually delete data from tables\n"); console.log(" 2. Manually delete data from tables\n");
@@ -114,7 +128,9 @@ export async function runSpecificSeeder(name: string) {
// Check if data already exists for specific seeder // Check if data already exists for specific seeder
const existingData = await hasExistingData(); const existingData = await hasExistingData();
if (existingData && name !== "auth") { if (existingData && name !== "auth") {
console.log("⚠️ Warning: Existing data detected for this seeder category.\n"); console.log(
"⚠️ Warning: Existing data detected for this seeder category.\n",
);
console.log("💡 To re-seed, either:"); console.log("💡 To re-seed, either:");
console.log(" 1. Run: bun x prisma migrate reset (resets database)"); console.log(" 1. Run: bun x prisma migrate reset (resets database)");
console.log(" 2. Manually delete data from tables\n"); console.log(" 2. Manually delete data from tables\n");
@@ -124,33 +140,36 @@ export async function runSpecificSeeder(name: string) {
switch (name) { switch (name) {
case "auth": case "auth":
case "users": case "users": {
console.log("📁 Authentication & Users"); console.log("📁 Authentication & Users");
const adminId = await seedAdminUser(); const adminId = await seedAdminUser();
await seedDemoUsers(); await seedDemoUsers();
await seedApiKeys(adminId); await seedApiKeys(adminId);
break; break;
}
case "demographics": case "demographics":
case "population": case "population": {
console.log("📁 Demographics & Population"); console.log("📁 Demographics & Population");
await seedBanjars(); await seedBanjars();
const banjarIds = await getBanjarIds(); const banjarIds = await getBanjarIds();
await seedResidents(banjarIds); await seedResidents(banjarIds);
break; break;
}
case "divisions": case "divisions":
case "performance": case "performance": {
console.log("📁 Division Performance"); console.log("📁 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 seedActivities(divisionIds); await seedActivities(divisionIds);
await seedDivisionMetrics(divisionIds); await seedDivisionMetrics(divisionIds);
break; break;
}
case "complaints": case "complaints":
case "services": case "services":
case "public": case "public": {
console.log("📁 Public Services"); console.log("📁 Public Services");
const pubAdminId = await seedAdminUser(); const pubAdminId = await seedAdminUser();
await seedComplaints(pubAdminId); await seedComplaints(pubAdminId);
@@ -160,9 +179,10 @@ export async function runSpecificSeeder(name: string) {
const compIds = await getComplaintIds(); const compIds = await getComplaintIds();
await seedComplaintUpdates(compIds, pubAdminId); await seedComplaintUpdates(compIds, pubAdminId);
break; break;
}
case "documents": case "documents":
case "discussions": case "discussions": {
console.log("📁 Documents & Discussions"); console.log("📁 Documents & Discussions");
const docAdminId = await seedAdminUser(); const docAdminId = await seedAdminUser();
const divs = await seedDivisions(); const divs = await seedDivisions();
@@ -170,6 +190,7 @@ export async function runSpecificSeeder(name: string) {
await seedDocuments(divIds, docAdminId); await seedDocuments(divIds, docAdminId);
await seedDiscussions(divIds, docAdminId); await seedDiscussions(divIds, docAdminId);
break; break;
}
case "dashboard": case "dashboard":
case "metrics": case "metrics":
@@ -178,17 +199,20 @@ export async function runSpecificSeeder(name: string) {
break; break;
case "phase2": case "phase2":
case "features": case "features": {
console.log("📁 Phase 2+ Features"); console.log("📁 Phase 2+ Features");
const p2AdminId = await seedAdminUser(); const p2AdminId = await seedAdminUser();
await seedBanjars(); await seedBanjars();
const p2BanjarIds = await getBanjarIds(); const p2BanjarIds = await getBanjarIds();
await seedPhase2(p2BanjarIds, p2AdminId); await seedPhase2(p2BanjarIds, p2AdminId);
break; break;
}
default: default:
console.error(`❌ Unknown seeder: ${name}`); console.error(`❌ Unknown seeder: ${name}`);
console.log("Available seeders: auth, demographics, divisions, complaints, documents, dashboard, phase2"); console.log(
"Available seeders: auth, demographics, divisions, complaints, documents, dashboard, phase2",
);
process.exit(1); process.exit(1);
} }

View File

@@ -1,4 +1,4 @@
import { PrismaClient, Gender, Religion } from "../../generated/prisma"; import { Gender, PrismaClient, Religion } from "../../generated/prisma";
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -1,8 +1,4 @@
import { import { ActivityStatus, Priority, PrismaClient } from "../../generated/prisma";
ActivityStatus,
Priority,
PrismaClient,
} from "../../generated/prisma";
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -25,13 +25,14 @@ export async function seedComplaints(adminId: string) {
console.log("Seeding Complaints..."); console.log("Seeding Complaints...");
const now = new Date(); const now = new Date();
const complaints = [ const complaints = [
// Recent complaints (this month) // Recent complaints (this month)
{ {
complaintNumber: `COMP-20260327-001`, complaintNumber: `COMP-20260327-001`,
title: "Lampu Jalan Mati", title: "Lampu Jalan Mati",
description: "Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.", description:
"Lampu jalan di depan Balai Banjar Manesa mati sejak 3 hari lalu.",
category: ComplaintCategory.INFRASTRUKTUR, category: ComplaintCategory.INFRASTRUKTUR,
status: ComplaintStatus.BARU, status: ComplaintStatus.BARU,
priority: Priority.SEDANG, priority: Priority.SEDANG,
@@ -187,7 +188,9 @@ export async function seedComplaints(adminId: string) {
}); });
} }
console.log("✅ Complaints seeded successfully (12 complaints across 7 months)"); console.log(
"✅ Complaints seeded successfully (12 complaints across 7 months)",
);
} }
/** /**
@@ -354,7 +357,10 @@ export async function seedInnovationIdeas(adminId: string) {
* Seed Complaint Updates * Seed Complaint Updates
* Creates status update history for complaints * Creates status update history for complaints
*/ */
export async function seedComplaintUpdates(complaintIds: string[], userId: string) { export async function seedComplaintUpdates(
complaintIds: string[],
userId: string,
) {
console.log("Seeding Complaint Updates..."); console.log("Seeding Complaint Updates...");
if (complaintIds.length === 0) { if (complaintIds.length === 0) {

225
scripts/sync-noc.ts Normal file
View File

@@ -0,0 +1,225 @@
import { prisma } from "../src/utils/db";
import { nocExternalClient } from "../src/utils/noc-external-client";
import logger from "../src/utils/logger";
const ID_DESA = "darmasaba";
/**
* Helper untuk mendapatkan system user ID untuk relasi
*/
async function getSystemUserId() {
const user = await prisma.user.findFirst({
where: { role: "admin" },
});
if (!user) {
// Buat system user jika tidak ada
const newUser = await prisma.user.create({
data: {
email: "system@darmasaba.id",
name: "System Sync",
role: "admin",
},
});
return newUser.id;
}
return user.id;
}
/**
* 1. Sync Divisions
*/
async function syncActiveDivisions() {
logger.info("Syncing Divisions...");
const { data, error } = await nocExternalClient.GET("/api/noc/active-divisions", {
params: { query: { idDesa: ID_DESA } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch divisions from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response is untyped
const divisions = (data as any).data;
for (const div of divisions) {
await prisma.division.upsert({
where: { externalId: div.id },
update: {
name: div.name,
color: div.color,
villageId: ID_DESA,
},
create: {
externalId: div.id,
name: div.name,
color: div.color,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${divisions.length} divisions`);
}
/**
* 2. Sync Activities
*/
async function syncLatestProjects() {
logger.info("Syncing Activities...");
const { data, error } = await nocExternalClient.GET("/api/noc/latest-projects", {
params: { query: { idDesa: ID_DESA, limit: "50" } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch projects from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const projects = (data as any).data;
for (const proj of projects) {
// Temukan divisi lokal berdasarkan nama atau externalId (asumsi externalId divisi sinkron)
// Karena kita sinkron divisi dulu, kita cari berdasarkan nama jika externalId belum pasti
const division = await prisma.division.findFirst({
where: { name: proj.divisionName },
});
if (!division) continue;
await prisma.activity.upsert({
where: { externalId: proj.id },
update: {
title: proj.title,
status: proj.status as any,
progress: proj.progress,
divisionId: division.id,
villageId: ID_DESA,
},
create: {
externalId: proj.id,
title: proj.title,
status: proj.status as any,
progress: proj.progress,
divisionId: division.id,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${projects.length} activities`);
}
/**
* 3. Sync Events
*/
async function syncUpcomingEvents() {
logger.info("Syncing Events...");
const systemUserId = await getSystemUserId();
const { data, error } = await nocExternalClient.GET("/api/noc/upcoming-events", {
params: { query: { idDesa: ID_DESA, limit: "50" } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch events from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const events = (data as any).data;
for (const event of events) {
await prisma.event.upsert({
where: { externalId: event.id },
update: {
title: event.title,
startDate: new Date(event.startDate),
location: event.location,
eventType: event.eventType as any,
villageId: ID_DESA,
},
create: {
externalId: event.id,
title: event.title,
startDate: new Date(event.startDate),
location: event.location,
eventType: event.eventType as any,
createdBy: systemUserId,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${events.length} events`);
}
/**
* 4. Sync Discussions
*/
async function syncLatestDiscussion() {
logger.info("Syncing Discussions...");
const systemUserId = await getSystemUserId();
const { data, error } = await nocExternalClient.GET("/api/noc/latest-discussion", {
params: { query: { idDesa: ID_DESA, limit: "50" } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch discussions from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const discussions = (data as any).data;
for (const disc of discussions) {
const division = await prisma.division.findFirst({
where: { name: disc.divisionName },
});
await prisma.discussion.upsert({
where: { externalId: disc.id },
update: {
message: disc.message,
divisionId: division?.id,
villageId: ID_DESA,
},
create: {
externalId: disc.id,
message: disc.message,
senderId: systemUserId,
divisionId: division?.id,
villageId: ID_DESA,
},
});
}
logger.info(`Synced ${discussions.length} discussions`);
}
/**
* 5. Update lastSyncedAt timestamp
*/
async function syncLastTimestamp() {
logger.info("Updating sync timestamp...");
await prisma.division.updateMany({
where: { villageId: ID_DESA },
data: { lastSyncedAt: new Date() },
});
}
/**
* Main Sync Function
*/
async function main() {
try {
logger.info("Starting NOC Data Synchronization...");
await syncActiveDivisions();
await syncLatestProjects();
await syncUpcomingEvents();
await syncLatestDiscussion();
await syncLastTimestamp();
logger.info("NOC Data Synchronization Completed Successfully");
} catch (err) {
logger.error({ err }, "Fatal error during NOC synchronization");
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -8,9 +8,9 @@ import { complaint } from "./complaint";
import { dashboard } from "./dashboard"; import { dashboard } from "./dashboard";
import { division } from "./division"; import { division } from "./division";
import { event } from "./event"; import { event } from "./event";
import { noc } from "./noc";
import { profile } from "./profile"; import { profile } from "./profile";
import { resident } from "./resident"; import { resident } from "./resident";
import { noc } from "./noc";
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";

View File

@@ -1,13 +1,67 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { $ } from "bun";
export const noc = new Elysia({ prefix: "/noc" }) export const noc = new Elysia({ prefix: "/noc" })
.post(
"/sync",
async ({ set, user }) => {
if (!user || user.role !== "admin") {
set.status = 401;
return { error: "Unauthorized" };
}
try {
// Jalankan script sinkronisasi
await $`bun run sync:noc`.quiet();
return {
success: true,
message: "Sinkronisasi berhasil diselesaikan",
lastSyncedAt: new Date().toISOString(),
};
} catch (error) {
return { success: false, error: "Sinkronisasi gagal dijalankan" };
}
},
{
response: {
200: t.Object({
success: t.Boolean(),
message: t.Optional(t.String()),
error: t.Optional(t.String()),
lastSyncedAt: t.Optional(t.String()),
}),
401: t.Object({ error: t.String() }),
},
},
)
.get(
"/last-sync",
async ({ query }) => {
const { idDesa } = query;
const latest = await prisma.division.findFirst({
where: { villageId: idDesa },
select: { lastSyncedAt: true },
orderBy: { lastSyncedAt: "desc" },
});
return { lastSyncedAt: latest?.lastSyncedAt?.toISOString() || null };
},
{
query: t.Object({ idDesa: t.String() }),
response: {
200: t.Object({
lastSyncedAt: t.Nullable(t.String()),
}),
},
},
)
.get( .get(
"/active-divisions", "/active-divisions",
async ({ query }) => { async ({ query }) => {
const { idDesa, limit } = query; const { idDesa, limit } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.division.findMany({ const data = await prisma.division.findMany({
where: { villageId: idDesa },
include: { include: {
_count: { _count: {
select: { activities: true }, select: { activities: true },
@@ -53,8 +107,8 @@ export const noc = new Elysia({ prefix: "/noc" })
"/latest-projects", "/latest-projects",
async ({ query }) => { async ({ query }) => {
const { idDesa, limit } = query; const { idDesa, limit } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.activity.findMany({ const data = await prisma.activity.findMany({
where: { villageId: idDesa },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: limit ? Number.parseInt(limit) : 5, take: limit ? Number.parseInt(limit) : 5,
include: { division: true }, include: { division: true },
@@ -96,9 +150,8 @@ export const noc = new Elysia({ prefix: "/noc" })
"/upcoming-events", "/upcoming-events",
async ({ query }) => { async ({ query }) => {
const { idDesa, limit, filter } = query; const { idDesa, limit, filter } = query;
// TODO: Filter by idDesa once schema supports it
const now = new Date(); const now = new Date();
const where: any = {}; const where: any = { villageId: idDesa };
if (filter === "today") { if (filter === "today") {
const startOfDay = new Date(now.setHours(0, 0, 0, 0)); const startOfDay = new Date(now.setHours(0, 0, 0, 0));
@@ -154,8 +207,8 @@ export const noc = new Elysia({ prefix: "/noc" })
"/diagram-jumlah-document", "/diagram-jumlah-document",
async ({ query }) => { async ({ query }) => {
const { idDesa } = query; const { idDesa } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.document.groupBy({ const data = await prisma.document.groupBy({
where: { villageId: idDesa },
by: ["category"], by: ["category"],
_count: { _count: {
_all: true, _all: true,
@@ -189,8 +242,8 @@ export const noc = new Elysia({ prefix: "/noc" })
"/diagram-progres-kegiatan", "/diagram-progres-kegiatan",
async ({ query }) => { async ({ query }) => {
const { idDesa } = query; const { idDesa } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.activity.groupBy({ const data = await prisma.activity.groupBy({
where: { villageId: idDesa },
by: ["status"], by: ["status"],
_avg: { _avg: {
progress: true, progress: true,
@@ -229,8 +282,8 @@ export const noc = new Elysia({ prefix: "/noc" })
"/latest-discussion", "/latest-discussion",
async ({ query }) => { async ({ query }) => {
const { idDesa, limit } = query; const { idDesa, limit } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.discussion.findMany({ const data = await prisma.discussion.findMany({
where: { villageId: idDesa },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: limit ? Number.parseInt(limit) : 5, take: limit ? Number.parseInt(limit) : 5,
include: { include: {
@@ -273,4 +326,5 @@ export const noc = new Elysia({ prefix: "/noc" })
), ),
}), }),
}, },
}); },
);

View File

@@ -1,4 +1,4 @@
import { Grid, Image, Loader, Stack, Center } from "@mantine/core"; import { Center, Grid, Image, Loader, Stack } 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";
@@ -18,20 +18,21 @@ export function DashboardContent() {
loading: true, loading: true,
}); });
const [sdgsData, setSdgsData] = useState<{ title: string; score: number; image: string | null }[]>([]); const [sdgsData, setSdgsData] = useState<
{ title: string; score: number; image: string | null }[]
>([]);
const [sdgsLoading, setSdgsLoading] = useState(true); const [sdgsLoading, setSdgsLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function fetchStats() { async function fetchStats() {
try { try {
const [complaintRes, residentRes, weeklyServiceRes, sdgsRes] = 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"), apiClient.GET("/api/dashboard/sdgs"),
], ]);
);
setStats({ setStats({
complaints: (complaintRes.data as { data: typeof stats.complaints }) complaints: (complaintRes.data as { data: typeof stats.complaints })
@@ -138,7 +139,9 @@ export function DashboardContent() {
{sdgsData.map((sdg) => ( {sdgsData.map((sdg) => (
<Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}> <Grid.Col key={sdg.title} span={{ base: 9, md: 3 }}>
<SDGSCard <SDGSCard
image={sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null} image={
sdg.image ? <Image src={sdg.image} alt={sdg.title} /> : null
}
title={sdg.title} title={sdg.title}
score={sdg.score} score={sdg.score}
/> />

View File

@@ -96,7 +96,13 @@ export function ChartAPBDes() {
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
<Text size="sm" fw={600} w={40} ta="right" c={dark ? "white" : "gray.9"}> <Text
size="sm"
fw={600}
w={40}
ta="right"
c={dark ? "white" : "gray.9"}
>
{item.value}% {item.value}%
</Text> </Text>
</Group> </Group>

View File

@@ -51,8 +51,14 @@ export function ChartSurat() {
console.log("📊 Service trends response:", res); console.log("📊 Service trends response:", res);
// Check if response has data // Check if response has data
if (res.data?.data && Array.isArray(res.data.data) && res.data.data.length > 0) { if (
const chartData = (res.data.data as { month: string; count: number }[]).map((d) => ({ res.data?.data &&
Array.isArray(res.data.data) &&
res.data.data.length > 0
) {
const chartData = (
res.data.data as { month: string; count: number }[]
).map((d) => ({
month: d.month, month: d.month,
value: Number(d.count), value: Number(d.count),
})); }));

View File

@@ -1,9 +1,16 @@
import { Card, Group, Loader, Stack, Text, useMantineColorScheme } from "@mantine/core"; import {
Card,
Group,
Loader,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { format } from "date-fns";
import { id } from "date-fns/locale";
import { MessageCircle } from "lucide-react"; import { MessageCircle } 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";
import { format } from "date-fns";
import { id } from "date-fns/locale";
interface DiscussionItem { interface DiscussionItem {
id: string; id: string;

View File

@@ -1,4 +1,10 @@
import { Card, Group, Loader, Text, useMantineColorScheme } from "@mantine/core"; import {
Card,
Group,
Loader,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Bar, Bar,

View File

@@ -47,10 +47,26 @@ export function ProgressChart() {
if (res.data?.data) { if (res.data?.data) {
const stats = res.data.data as ActivityStats; const stats = res.data.data as ActivityStats;
const chartData: ProgressData[] = [ const chartData: ProgressData[] = [
{ name: "Selesai", value: stats.percentages.selesai, color: "#22C55E" }, {
{ name: "Dikerjakan", value: stats.percentages.berjalan, color: "#F59E0B" }, name: "Selesai",
{ name: "Segera Dikerjakan", value: stats.percentages.tertunda, color: "#3B82F6" }, value: stats.percentages.selesai,
{ name: "Dibatalkan", value: stats.percentages.dibatalkan, color: "#EF4444" }, color: "#22C55E",
},
{
name: "Dikerjakan",
value: stats.percentages.berjalan,
color: "#F59E0B",
},
{
name: "Segera Dikerjakan",
value: stats.percentages.tertunda,
color: "#3B82F6",
},
{
name: "Dibatalkan",
value: stats.percentages.dibatalkan,
color: "#EF4444",
},
]; ];
setData(chartData); setData(chartData);
} }

View File

@@ -0,0 +1,176 @@
import {
Box,
Button,
Card,
Group,
Stack,
Text,
Title,
Alert,
Loader,
Badge,
Divider,
} from "@mantine/core";
import { IconRefresh, IconCheck, IconAlertCircle, IconClock } from "@tabler/icons-react";
import { useState, useEffect } from "react";
import { apiClient } from "@/utils/api-client";
import dayjs from "dayjs";
import "dayjs/locale/id";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
dayjs.locale("id");
const SinkronisasiSettings = () => {
const [loading, setLoading] = useState(false);
const [lastSync, setLastSync] = useState<string | null>(null);
const [status, setStatus] = useState<{
type: "success" | "error" | null;
message: string;
}>({ type: null, message: "" });
const fetchLastSync = async () => {
const { data } = await apiClient.GET("/api/noc/last-sync", {
params: { query: { idDesa: "darmasaba" } },
});
if (data?.lastSyncedAt) {
setLastSync(data.lastSyncedAt);
}
};
useEffect(() => {
fetchLastSync();
}, []);
const handleSync = async () => {
setLoading(true);
setStatus({ type: null, message: "" });
try {
const { data, error } = await apiClient.POST("/api/noc/sync");
if (error) {
setStatus({
type: "error",
message: (error as any).error || "Gagal melakukan sinkronisasi",
});
} else if (data?.success) {
setStatus({
type: "success",
message: data.message || "Sinkronisasi berhasil dilakukan",
});
if (data.lastSyncedAt) {
setLastSync(data.lastSyncedAt);
}
}
} catch (err) {
setStatus({
type: "error",
message: "Terjadi kesalahan sistem saat sinkronisasi",
});
} finally {
setLoading(false);
}
};
return (
<Box pr={"50%"}>
<Title order={2} mb="lg">
Sinkronisasi Data NOC
</Title>
<Text c="dimmed" mb="xl">
Gunakan fitur ini untuk memperbarui data dashboard dengan data terbaru dari
server Network Operation Center (NOC) darmasaba.muku.id.
</Text>
<Card withBorder padding="lg" radius="md" mb="xl">
<Stack gap="md">
<Group justify="space-between">
<Group>
<IconClock size={20} color="gray" />
<Text fw={500}>Status Terakhir</Text>
</Group>
<Badge color={lastSync ? "green" : "gray"} variant="light">
{lastSync ? "Terkoneksi" : "Belum Pernah Sinkron"}
</Badge>
</Group>
<Divider />
<Box>
<Text size="sm" c="dimmed">
Waktu Sinkronisasi Terakhir:
</Text>
<Text fw={700} size="lg">
{lastSync
? dayjs(lastSync).format("DD MMMM YYYY, HH:mm:ss")
: "Belum pernah dilakukan"}
</Text>
{lastSync && (
<Text size="xs" c="dimmed" mt={4}>
({dayjs(lastSync).fromNow()})
</Text>
)}
</Box>
{status.type && (
<Alert
icon={
status.type === "success" ? (
<IconCheck size={16} />
) : (
<IconAlertCircle size={16} />
)
}
title={status.type === "success" ? "Berhasil" : "Kesalahan"}
color={status.type === "success" ? "green" : "red"}
onClose={() => setStatus({ type: null, message: "" })}
withCloseButton
>
{status.message}
</Alert>
)}
<Button
leftSection={
loading ? <Loader size={16} color="white" /> : <IconRefresh size={16} />
}
onClick={handleSync}
loading={loading}
fullWidth
mt="md"
>
Sinkronkan Sekarang
</Button>
</Stack>
</Card>
<Title order={2} mb="lg">
Informasi API
</Title>
<Card withBorder padding="md" radius="md" bg="gray.0">
<Stack gap="xs">
<Group>
<Text fw={600} size="sm" w={100}>URL Sumber:</Text>
<Text size="sm" style={{ wordBreak: 'break-all' }}>https://darmasaba.muku.id/api/noc/</Text>
</Group>
<Group>
<Text fw={600} size="sm" w={100}>ID Desa:</Text>
<Text size="sm">darmasaba</Text>
</Group>
<Group>
<Text fw={600} size="sm" w={100}>Model Data:</Text>
<Badge size="xs" variant="outline">Divisi</Badge>
<Badge size="xs" variant="outline">Kegiatan</Badge>
<Badge size="xs" variant="outline">Event</Badge>
<Badge size="xs" variant="outline">Diskusi</Badge>
</Group>
</Stack>
</Card>
</Box>
);
};
export default SinkronisasiSettings;

View File

@@ -48,6 +48,7 @@ export function Sidebar({ className }: SidebarProps) {
{ name: "Notifikasi", path: "/pengaturan/notifikasi" }, { name: "Notifikasi", path: "/pengaturan/notifikasi" },
{ name: "Keamanan", path: "/pengaturan/keamanan" }, { name: "Keamanan", path: "/pengaturan/keamanan" },
{ name: "Akses & Tim", path: "/pengaturan/akses-dan-tim" }, { name: "Akses & Tim", path: "/pengaturan/akses-dan-tim" },
{ name: "Sinkronisasi NOC", path: "/pengaturan/sinkronisasi" },
]; ];
// Check if any settings submenu is active // Check if any settings submenu is active

View File

@@ -30,6 +30,7 @@ import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as UsersIdRouteImport } from './routes/users/$id' import { Route as UsersIdRouteImport } from './routes/users/$id'
import { Route as ProfileEditRouteImport } from './routes/profile/edit' import { Route as ProfileEditRouteImport } from './routes/profile/edit'
import { Route as PengaturanUmumRouteImport } from './routes/pengaturan/umum' import { Route as PengaturanUmumRouteImport } from './routes/pengaturan/umum'
import { Route as PengaturanSinkronisasiRouteImport } from './routes/pengaturan/sinkronisasi'
import { Route as PengaturanNotifikasiRouteImport } from './routes/pengaturan/notifikasi' import { Route as PengaturanNotifikasiRouteImport } from './routes/pengaturan/notifikasi'
import { Route as PengaturanKeamananRouteImport } from './routes/pengaturan/keamanan' import { Route as PengaturanKeamananRouteImport } from './routes/pengaturan/keamanan'
import { Route as PengaturanAksesDanTimRouteImport } from './routes/pengaturan/akses-dan-tim' import { Route as PengaturanAksesDanTimRouteImport } from './routes/pengaturan/akses-dan-tim'
@@ -142,6 +143,11 @@ const PengaturanUmumRoute = PengaturanUmumRouteImport.update({
path: '/umum', path: '/umum',
getParentRoute: () => PengaturanRouteRoute, getParentRoute: () => PengaturanRouteRoute,
} as any) } as any)
const PengaturanSinkronisasiRoute = PengaturanSinkronisasiRouteImport.update({
id: '/sinkronisasi',
path: '/sinkronisasi',
getParentRoute: () => PengaturanRouteRoute,
} as any)
const PengaturanNotifikasiRoute = PengaturanNotifikasiRouteImport.update({ const PengaturanNotifikasiRoute = PengaturanNotifikasiRouteImport.update({
id: '/notifikasi', id: '/notifikasi',
path: '/notifikasi', path: '/notifikasi',
@@ -195,6 +201,7 @@ export interface FileRoutesByFullPath {
'/pengaturan/akses-dan-tim': typeof PengaturanAksesDanTimRoute '/pengaturan/akses-dan-tim': typeof PengaturanAksesDanTimRoute
'/pengaturan/keamanan': typeof PengaturanKeamananRoute '/pengaturan/keamanan': typeof PengaturanKeamananRoute
'/pengaturan/notifikasi': typeof PengaturanNotifikasiRoute '/pengaturan/notifikasi': typeof PengaturanNotifikasiRoute
'/pengaturan/sinkronisasi': typeof PengaturanSinkronisasiRoute
'/pengaturan/umum': typeof PengaturanUmumRoute '/pengaturan/umum': typeof PengaturanUmumRoute
'/profile/edit': typeof ProfileEditRoute '/profile/edit': typeof ProfileEditRoute
'/users/$id': typeof UsersIdRoute '/users/$id': typeof UsersIdRoute
@@ -222,6 +229,7 @@ export interface FileRoutesByTo {
'/pengaturan/akses-dan-tim': typeof PengaturanAksesDanTimRoute '/pengaturan/akses-dan-tim': typeof PengaturanAksesDanTimRoute
'/pengaturan/keamanan': typeof PengaturanKeamananRoute '/pengaturan/keamanan': typeof PengaturanKeamananRoute
'/pengaturan/notifikasi': typeof PengaturanNotifikasiRoute '/pengaturan/notifikasi': typeof PengaturanNotifikasiRoute
'/pengaturan/sinkronisasi': typeof PengaturanSinkronisasiRoute
'/pengaturan/umum': typeof PengaturanUmumRoute '/pengaturan/umum': typeof PengaturanUmumRoute
'/profile/edit': typeof ProfileEditRoute '/profile/edit': typeof ProfileEditRoute
'/users/$id': typeof UsersIdRoute '/users/$id': typeof UsersIdRoute
@@ -252,6 +260,7 @@ export interface FileRoutesById {
'/pengaturan/akses-dan-tim': typeof PengaturanAksesDanTimRoute '/pengaturan/akses-dan-tim': typeof PengaturanAksesDanTimRoute
'/pengaturan/keamanan': typeof PengaturanKeamananRoute '/pengaturan/keamanan': typeof PengaturanKeamananRoute
'/pengaturan/notifikasi': typeof PengaturanNotifikasiRoute '/pengaturan/notifikasi': typeof PengaturanNotifikasiRoute
'/pengaturan/sinkronisasi': typeof PengaturanSinkronisasiRoute
'/pengaturan/umum': typeof PengaturanUmumRoute '/pengaturan/umum': typeof PengaturanUmumRoute
'/profile/edit': typeof ProfileEditRoute '/profile/edit': typeof ProfileEditRoute
'/users/$id': typeof UsersIdRoute '/users/$id': typeof UsersIdRoute
@@ -283,6 +292,7 @@ export interface FileRouteTypes {
| '/pengaturan/akses-dan-tim' | '/pengaturan/akses-dan-tim'
| '/pengaturan/keamanan' | '/pengaturan/keamanan'
| '/pengaturan/notifikasi' | '/pengaturan/notifikasi'
| '/pengaturan/sinkronisasi'
| '/pengaturan/umum' | '/pengaturan/umum'
| '/profile/edit' | '/profile/edit'
| '/users/$id' | '/users/$id'
@@ -310,6 +320,7 @@ export interface FileRouteTypes {
| '/pengaturan/akses-dan-tim' | '/pengaturan/akses-dan-tim'
| '/pengaturan/keamanan' | '/pengaturan/keamanan'
| '/pengaturan/notifikasi' | '/pengaturan/notifikasi'
| '/pengaturan/sinkronisasi'
| '/pengaturan/umum' | '/pengaturan/umum'
| '/profile/edit' | '/profile/edit'
| '/users/$id' | '/users/$id'
@@ -339,6 +350,7 @@ export interface FileRouteTypes {
| '/pengaturan/akses-dan-tim' | '/pengaturan/akses-dan-tim'
| '/pengaturan/keamanan' | '/pengaturan/keamanan'
| '/pengaturan/notifikasi' | '/pengaturan/notifikasi'
| '/pengaturan/sinkronisasi'
| '/pengaturan/umum' | '/pengaturan/umum'
| '/profile/edit' | '/profile/edit'
| '/users/$id' | '/users/$id'
@@ -516,6 +528,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PengaturanUmumRouteImport preLoaderRoute: typeof PengaturanUmumRouteImport
parentRoute: typeof PengaturanRouteRoute parentRoute: typeof PengaturanRouteRoute
} }
'/pengaturan/sinkronisasi': {
id: '/pengaturan/sinkronisasi'
path: '/sinkronisasi'
fullPath: '/pengaturan/sinkronisasi'
preLoaderRoute: typeof PengaturanSinkronisasiRouteImport
parentRoute: typeof PengaturanRouteRoute
}
'/pengaturan/notifikasi': { '/pengaturan/notifikasi': {
id: '/pengaturan/notifikasi' id: '/pengaturan/notifikasi'
path: '/notifikasi' path: '/notifikasi'
@@ -583,6 +602,7 @@ interface PengaturanRouteRouteChildren {
PengaturanAksesDanTimRoute: typeof PengaturanAksesDanTimRoute PengaturanAksesDanTimRoute: typeof PengaturanAksesDanTimRoute
PengaturanKeamananRoute: typeof PengaturanKeamananRoute PengaturanKeamananRoute: typeof PengaturanKeamananRoute
PengaturanNotifikasiRoute: typeof PengaturanNotifikasiRoute PengaturanNotifikasiRoute: typeof PengaturanNotifikasiRoute
PengaturanSinkronisasiRoute: typeof PengaturanSinkronisasiRoute
PengaturanUmumRoute: typeof PengaturanUmumRoute PengaturanUmumRoute: typeof PengaturanUmumRoute
} }
@@ -590,6 +610,7 @@ const PengaturanRouteRouteChildren: PengaturanRouteRouteChildren = {
PengaturanAksesDanTimRoute: PengaturanAksesDanTimRoute, PengaturanAksesDanTimRoute: PengaturanAksesDanTimRoute,
PengaturanKeamananRoute: PengaturanKeamananRoute, PengaturanKeamananRoute: PengaturanKeamananRoute,
PengaturanNotifikasiRoute: PengaturanNotifikasiRoute, PengaturanNotifikasiRoute: PengaturanNotifikasiRoute,
PengaturanSinkronisasiRoute: PengaturanSinkronisasiRoute,
PengaturanUmumRoute: PengaturanUmumRoute, PengaturanUmumRoute: PengaturanUmumRoute,
} }

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import SinkronisasiSettings from "@/components/pengaturan/sinkronisasi";
export const Route = createFileRoute("/pengaturan/sinkronisasi")({
component: SinkronisasiSettings,
});

View File

@@ -38,25 +38,24 @@ function ProfileLayout() {
paddingRight: "1rem", paddingRight: "1rem",
}} }}
> >
<Group h="100%" justify="space-between">
<Group h="100%" justify="space-between"> <Group>
<Group> <Button
<Button variant="subtle"
variant="subtle" color="gray"
color="gray" leftSection={<IconChevronLeft size={16} />}
leftSection={<IconChevronLeft size={16} />} onClick={() => navigate({ to: "/" })}
onClick={() => navigate({ to: "/" })} >
> Kembali ke Dashboard
Kembali ke Dashboard </Button>
</Button>
</Group>
<Text fw={700} size="lg" c="orange.6">
PENGATURAN AKUN
</Text>
<Box w={150} />
</Group> </Group>
<Text fw={700} size="lg" c="orange.6">
PENGATURAN AKUN
</Text>
<Box w={150} />
</Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Main> <AppShell.Main>

View File

@@ -0,0 +1,19 @@
import createClient from "openapi-fetch";
import type { paths } from "../../generated/noc-external";
import { getEnv } from "./env";
/**
* NOC External Client
* Digunakan khusus untuk menarik data dari server NOC darmasaba.muku.id
*/
const externalBaseUrl = getEnv(
"NOC_API_URL",
"https://darmasaba.muku.id/api/noc",
);
// Hilangkan suffix /docs/json jika ada di URL
const cleanBaseUrl = externalBaseUrl.replace("/docs/json", "");
export const nocExternalClient = createClient<paths>({
baseUrl: cleanBaseUrl,
});