diff --git a/generated/api.ts b/generated/api.ts index 7327ee1..8ebe7f0 100644 --- a/generated/api.ts +++ b/generated/api.ts @@ -894,21 +894,30 @@ export interface operations { }; content: { "application/json": { + success: boolean; + message: string; data: { - category: string; - count: number; + label: string; + value: number; + color: string; }[]; }; "multipart/form-data": { + success: boolean; + message: string; data: { - category: string; - count: number; + label: string; + value: number; + color: string; }[]; }; "text/plain": { + success: boolean; + message: string; data: { - category: string; - count: number; + label: string; + value: number; + color: string; }[]; }; }; diff --git a/generated/schema.json b/generated/schema.json index 47a21be..96e9204 100644 --- a/generated/schema.json +++ b/generated/schema.json @@ -802,26 +802,38 @@ "schema": { "type": "object", "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, "data": { "type": "array", "items": { "type": "object", "required": [ - "category", - "count" + "label", + "value", + "color" ], "properties": { - "category": { + "label": { "type": "string" }, - "count": { + "value": { "type": "number" + }, + "color": { + "type": "string" } } } } }, "required": [ + "success", + "message", "data" ] } @@ -830,26 +842,38 @@ "schema": { "type": "object", "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, "data": { "type": "array", "items": { "type": "object", "required": [ - "category", - "count" + "label", + "value", + "color" ], "properties": { - "category": { + "label": { "type": "string" }, - "count": { + "value": { "type": "number" + }, + "color": { + "type": "string" } } } } }, "required": [ + "success", + "message", "data" ] } @@ -858,26 +882,38 @@ "schema": { "type": "object", "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, "data": { "type": "array", "items": { "type": "object", "required": [ - "category", - "count" + "label", + "value", + "color" ], "properties": { - "category": { + "label": { "type": "string" }, - "count": { + "value": { "type": "number" + }, + "color": { + "type": "string" } } } } }, "required": [ + "success", + "message", "data" ] } diff --git a/prisma/migrations/20260331065636_add_document_stat_table/migration.sql b/prisma/migrations/20260331065636_add_document_stat_table/migration.sql new file mode 100644 index 0000000..4e0bbe5 --- /dev/null +++ b/prisma/migrations/20260331065636_add_document_stat_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "document_stat" ( + "id" TEXT NOT NULL, + "villageId" TEXT NOT NULL DEFAULT 'desa1', + "label" 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 "document_stat_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "document_stat_villageId_label_key" ON "document_stat"("villageId", "label"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fda4c1a..565cb43 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -107,6 +107,19 @@ model Document { @@map("document") } +model DocumentStat { + id String @id @default(cuid()) + villageId String @default("desa1") + label String + value Int @default(0) + color String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([villageId, label]) + @@map("document_stat") +} + model Discussion { id String @id @default(cuid()) externalId String? @unique // ID asli dari server NOC diff --git a/prisma/seed.ts b/prisma/seed.ts index cee4c87..98bbaed 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -13,6 +13,7 @@ import { seedDiscussions, seedDivisionMetrics, seedDocuments, + seedDocumentStats, } from "./seeders/seed-discussions"; import { getDivisionIds, @@ -102,6 +103,7 @@ export async function runSeed() { // 5. Seed Documents & Discussions console.log("📁 [5/7] Documents & Discussions"); await seedDocuments(divisionIds, adminId); + await seedDocumentStats(); await seedDiscussions(divisionIds, adminId); console.log(); @@ -188,6 +190,7 @@ export async function runSpecificSeeder(name: string) { const divs = await seedDivisions(); const divIds = divs.map((d) => d.id); await seedDocuments(divIds, docAdminId); + await seedDocumentStats(); await seedDiscussions(divIds, docAdminId); break; } diff --git a/prisma/seeders/seed-discussions.ts b/prisma/seeders/seed-discussions.ts index bb9fab7..e75632e 100644 --- a/prisma/seeders/seed-discussions.ts +++ b/prisma/seeders/seed-discussions.ts @@ -70,6 +70,44 @@ export async function seedDocuments(divisionIds: string[], userId: string) { console.log("✅ Documents seeded successfully"); } +/** + * Seed Document Stats + * Creates aggregate document counts matching user request + */ +export async function seedDocumentStats() { + console.log("Seeding Document Stats..."); + + const stats = [ + { + villageId: "desa1", + label: "Gambar", + value: 389, + color: "#fac858", + }, + { + villageId: "desa1", + label: "Dokumen", + value: 147, + color: "#92cc76", + }, + ]; + + for (const stat of stats) { + await prisma.documentStat.upsert({ + where: { + villageId_label: { + villageId: stat.villageId, + label: stat.label, + }, + }, + update: stat, + create: stat, + }); + } + + console.log("✅ Document Stats seeded successfully"); +} + /** * Seed Discussions * Creates sample discussions for divisions and activities @@ -115,12 +153,15 @@ export async function seedDiscussions(divisionIds: string[], userId: string) { // Create parent discussions first const parentDiscussions = []; for (let i = 0; i < discussions.length; i += 2) { + const current = discussions[i]; + if (!current) continue; + const discussion = await prisma.discussion.create({ data: { - message: discussions[i].message, - senderId: discussions[i].senderId, - divisionId: discussions[i].divisionId, - isResolved: discussions[i].isResolved, + message: current.message, + senderId: current.senderId, + divisionId: current.divisionId, + isResolved: current.isResolved, }, }); parentDiscussions.push(discussion); @@ -128,16 +169,20 @@ export async function seedDiscussions(divisionIds: string[], userId: string) { // Create replies for (let i = 1; i < discussions.length; i += 2) { + const current = discussions[i]; + if (!current) continue; + const parentIndex = Math.floor((i - 1) / 2); - if (parentIndex < parentDiscussions.length) { + const parent = parentDiscussions[parentIndex]; + if (parent) { await prisma.discussion.update({ - where: { id: parentDiscussions[parentIndex].id }, + where: { id: parent.id }, data: { replies: { create: { - message: discussions[i].message, - senderId: discussions[i].senderId, - isResolved: discussions[i].isResolved, + message: current.message, + senderId: current.senderId, + isResolved: current.isResolved, }, }, }, diff --git a/scripts/sync-noc.ts b/scripts/sync-noc.ts index 1d440f2..800f273 100644 --- a/scripts/sync-noc.ts +++ b/scripts/sync-noc.ts @@ -226,7 +226,51 @@ async function syncLatestDiscussion() { } /** - * 5. Update lastSyncedAt timestamp + * 5. Sync Document Stats (New) + */ +async function syncDocumentStats() { + logger.info("Syncing Document Stats..."); + const { data, error } = await nocExternalClient.GET("/api/noc/diagram-jumlah-document", { + params: { query: { idDesa: ID_DESA } }, + }); + + if (error || !data) { + logger.error({ error }, "Failed to fetch document stats from NOC"); + return; + } + + // biome-ignore lint/suspicious/noExplicitAny: External API response + const resData = (data as any).data; + if (!Array.isArray(resData)) { + logger.warn({ data }, "Document stats data from NOC is not an array"); + return; + } + + for (const stat of resData) { + await prisma.documentStat.upsert({ + where: { + villageId_label: { + villageId: ID_DESA, + label: stat.label, + }, + }, + update: { + value: stat.value, + color: stat.color, + }, + create: { + villageId: ID_DESA, + label: stat.label, + value: stat.value, + color: stat.color, + }, + }); + } + logger.info(`Synced ${resData.length} document stats`); +} + +/** + * 6. Update lastSyncedAt timestamp */ async function syncLastTimestamp() { logger.info("Updating sync timestamp..."); @@ -247,6 +291,7 @@ async function main() { await syncLatestProjects(); await syncUpcomingEvents(); await syncLatestDiscussion(); + await syncDocumentStats(); await syncLastTimestamp(); logger.info("NOC Data Synchronization Completed Successfully"); diff --git a/src/api/noc.ts b/src/api/noc.ts index 9ecf4e5..9955df1 100644 --- a/src/api/noc.ts +++ b/src/api/noc.ts @@ -1,6 +1,7 @@ import { Elysia, t } from "elysia"; import { prisma } from "../utils/db"; import { $ } from "bun"; +import { nocExternalClient } from "../utils/noc-external-client"; export const noc = new Elysia({ prefix: "/noc" }) .post( @@ -207,18 +208,63 @@ export const noc = new Elysia({ prefix: "/noc" }) "/diagram-jumlah-document", async ({ query }) => { const { idDesa } = query; + + try { + // Coba tarik data dari NOC External API (sesuai permintaan user) + const { data: extData, error } = await nocExternalClient.GET( + "/api/noc/diagram-jumlah-document", + { + params: { query: { idDesa } }, + }, + ); + + if (!error && extData && (extData as any).success) { + return extData as any; + } + } catch (err) { + console.error("Failed to fetch document stats from NOC External", err); + } + + // Fallback ke local database (tabel DocumentStat yang baru) + const stats = await prisma.documentStat.findMany({ + where: { villageId: idDesa }, + }); + + if (stats.length > 0) { + return { + success: true, + message: "Berhasil mendapatkan jumlah document dari database", + data: stats.map((s) => ({ + label: s.label, + value: s.value, + color: s.color, + })), + }; + } + + // Fallback terakhir: groupBy Document (model lama) const data = await prisma.document.groupBy({ where: { villageId: idDesa }, - by: ["category"], + by: ["type"], _count: { _all: true, }, }); + const colorMap: Record = { + Gambar: "#fac858", + Dokumen: "#92cc76", + PDF: "#3B82F6", + Excel: "#10B981", + }; + return { + success: true, + message: "Berhasil mendapatkan jumlah document", data: data.map((d) => ({ - category: d.category, - count: d._count._all, + label: d.type, + value: d._count._all, + color: colorMap[d.type] || "#6B7280", })), }; }, @@ -228,10 +274,13 @@ export const noc = new Elysia({ prefix: "/noc" }) }), response: { 200: t.Object({ + success: t.Boolean(), + message: t.String(), data: t.Array( t.Object({ - category: t.String(), - count: t.Number(), + label: t.String(), + value: t.Number(), + color: t.String(), }), ), }), diff --git a/src/components/kinerja-divisi/document-chart.tsx b/src/components/kinerja-divisi/document-chart.tsx index 33efd73..3ddb73a 100644 --- a/src/components/kinerja-divisi/document-chart.tsx +++ b/src/components/kinerja-divisi/document-chart.tsx @@ -19,8 +19,8 @@ import { import { apiClient } from "@/utils/api-client"; interface DocumentData { - name: string; - jumlah: number; + label: string; + value: number; color: string; } @@ -34,7 +34,13 @@ export function DocumentChart() { useEffect(() => { async function fetchDocumentStats() { try { - const res = await apiClient.GET("/api/division/documents/stats"); + const res = await apiClient.GET("/api/noc/diagram-jumlah-document", { + params: { + query: { + idDesa: "desa1", + }, + }, + }); if (res.data?.data) { setData(res.data.data); } @@ -78,7 +84,7 @@ export function DocumentChart() { stroke={dark ? "#334155" : "#e5e7eb"} /> - + {data.map((entry) => ( - + ))}