feat(noc): integrate DocumentStat model and sync with external NOC API

This commit is contained in:
2026-03-31 15:04:51 +08:00
parent 11ef320d55
commit 6ace5b5d1c
9 changed files with 260 additions and 39 deletions

View File

@@ -894,21 +894,30 @@ export interface operations {
}; };
content: { content: {
"application/json": { "application/json": {
success: boolean;
message: string;
data: { data: {
category: string; label: string;
count: number; value: number;
color: string;
}[]; }[];
}; };
"multipart/form-data": { "multipart/form-data": {
success: boolean;
message: string;
data: { data: {
category: string; label: string;
count: number; value: number;
color: string;
}[]; }[];
}; };
"text/plain": { "text/plain": {
success: boolean;
message: string;
data: { data: {
category: string; label: string;
count: number; value: number;
color: string;
}[]; }[];
}; };
}; };

View File

@@ -802,26 +802,38 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"data": { "data": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"required": [ "required": [
"category", "label",
"count" "value",
"color"
], ],
"properties": { "properties": {
"category": { "label": {
"type": "string" "type": "string"
}, },
"count": { "value": {
"type": "number" "type": "number"
},
"color": {
"type": "string"
} }
} }
} }
} }
}, },
"required": [ "required": [
"success",
"message",
"data" "data"
] ]
} }
@@ -830,26 +842,38 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"data": { "data": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"required": [ "required": [
"category", "label",
"count" "value",
"color"
], ],
"properties": { "properties": {
"category": { "label": {
"type": "string" "type": "string"
}, },
"count": { "value": {
"type": "number" "type": "number"
},
"color": {
"type": "string"
} }
} }
} }
} }
}, },
"required": [ "required": [
"success",
"message",
"data" "data"
] ]
} }
@@ -858,26 +882,38 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"data": { "data": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"required": [ "required": [
"category", "label",
"count" "value",
"color"
], ],
"properties": { "properties": {
"category": { "label": {
"type": "string" "type": "string"
}, },
"count": { "value": {
"type": "number" "type": "number"
},
"color": {
"type": "string"
} }
} }
} }
} }
}, },
"required": [ "required": [
"success",
"message",
"data" "data"
] ]
} }

View File

@@ -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");

View File

@@ -107,6 +107,19 @@ model Document {
@@map("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 { model Discussion {
id String @id @default(cuid()) id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC externalId String? @unique // ID asli dari server NOC

View File

@@ -13,6 +13,7 @@ import {
seedDiscussions, seedDiscussions,
seedDivisionMetrics, seedDivisionMetrics,
seedDocuments, seedDocuments,
seedDocumentStats,
} from "./seeders/seed-discussions"; } from "./seeders/seed-discussions";
import { import {
getDivisionIds, getDivisionIds,
@@ -102,6 +103,7 @@ export async function runSeed() {
// 5. Seed Documents & Discussions // 5. Seed Documents & Discussions
console.log("📁 [5/7] Documents & Discussions"); console.log("📁 [5/7] Documents & Discussions");
await seedDocuments(divisionIds, adminId); await seedDocuments(divisionIds, adminId);
await seedDocumentStats();
await seedDiscussions(divisionIds, adminId); await seedDiscussions(divisionIds, adminId);
console.log(); console.log();
@@ -188,6 +190,7 @@ export async function runSpecificSeeder(name: string) {
const divs = await seedDivisions(); const divs = await seedDivisions();
const divIds = divs.map((d) => d.id); const divIds = divs.map((d) => d.id);
await seedDocuments(divIds, docAdminId); await seedDocuments(divIds, docAdminId);
await seedDocumentStats();
await seedDiscussions(divIds, docAdminId); await seedDiscussions(divIds, docAdminId);
break; break;
} }

View File

@@ -70,6 +70,44 @@ export async function seedDocuments(divisionIds: string[], userId: string) {
console.log("✅ Documents seeded successfully"); 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 * Seed Discussions
* Creates sample discussions for divisions and activities * Creates sample discussions for divisions and activities
@@ -115,12 +153,15 @@ export async function seedDiscussions(divisionIds: string[], userId: string) {
// Create parent discussions first // Create parent discussions first
const parentDiscussions = []; const parentDiscussions = [];
for (let i = 0; i < discussions.length; i += 2) { for (let i = 0; i < discussions.length; i += 2) {
const current = discussions[i];
if (!current) continue;
const discussion = await prisma.discussion.create({ const discussion = await prisma.discussion.create({
data: { data: {
message: discussions[i].message, message: current.message,
senderId: discussions[i].senderId, senderId: current.senderId,
divisionId: discussions[i].divisionId, divisionId: current.divisionId,
isResolved: discussions[i].isResolved, isResolved: current.isResolved,
}, },
}); });
parentDiscussions.push(discussion); parentDiscussions.push(discussion);
@@ -128,16 +169,20 @@ export async function seedDiscussions(divisionIds: string[], userId: string) {
// Create replies // Create replies
for (let i = 1; i < discussions.length; i += 2) { for (let i = 1; i < discussions.length; i += 2) {
const current = discussions[i];
if (!current) continue;
const parentIndex = Math.floor((i - 1) / 2); const parentIndex = Math.floor((i - 1) / 2);
if (parentIndex < parentDiscussions.length) { const parent = parentDiscussions[parentIndex];
if (parent) {
await prisma.discussion.update({ await prisma.discussion.update({
where: { id: parentDiscussions[parentIndex].id }, where: { id: parent.id },
data: { data: {
replies: { replies: {
create: { create: {
message: discussions[i].message, message: current.message,
senderId: discussions[i].senderId, senderId: current.senderId,
isResolved: discussions[i].isResolved, isResolved: current.isResolved,
}, },
}, },
}, },

View File

@@ -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() { async function syncLastTimestamp() {
logger.info("Updating sync timestamp..."); logger.info("Updating sync timestamp...");
@@ -247,6 +291,7 @@ async function main() {
await syncLatestProjects(); await syncLatestProjects();
await syncUpcomingEvents(); await syncUpcomingEvents();
await syncLatestDiscussion(); await syncLatestDiscussion();
await syncDocumentStats();
await syncLastTimestamp(); await syncLastTimestamp();
logger.info("NOC Data Synchronization Completed Successfully"); logger.info("NOC Data Synchronization Completed Successfully");

View File

@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { $ } from "bun"; import { $ } from "bun";
import { nocExternalClient } from "../utils/noc-external-client";
export const noc = new Elysia({ prefix: "/noc" }) export const noc = new Elysia({ prefix: "/noc" })
.post( .post(
@@ -207,18 +208,63 @@ export const noc = new Elysia({ prefix: "/noc" })
"/diagram-jumlah-document", "/diagram-jumlah-document",
async ({ query }) => { async ({ query }) => {
const { idDesa } = 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({ const data = await prisma.document.groupBy({
where: { villageId: idDesa }, where: { villageId: idDesa },
by: ["category"], by: ["type"],
_count: { _count: {
_all: true, _all: true,
}, },
}); });
const colorMap: Record<string, string> = {
Gambar: "#fac858",
Dokumen: "#92cc76",
PDF: "#3B82F6",
Excel: "#10B981",
};
return { return {
success: true,
message: "Berhasil mendapatkan jumlah document",
data: data.map((d) => ({ data: data.map((d) => ({
category: d.category, label: d.type,
count: d._count._all, value: d._count._all,
color: colorMap[d.type] || "#6B7280",
})), })),
}; };
}, },
@@ -228,10 +274,13 @@ export const noc = new Elysia({ prefix: "/noc" })
}), }),
response: { response: {
200: t.Object({ 200: t.Object({
success: t.Boolean(),
message: t.String(),
data: t.Array( data: t.Array(
t.Object({ t.Object({
category: t.String(), label: t.String(),
count: t.Number(), value: t.Number(),
color: t.String(),
}), }),
), ),
}), }),

View File

@@ -19,8 +19,8 @@ import {
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
interface DocumentData { interface DocumentData {
name: string; label: string;
jumlah: number; value: number;
color: string; color: string;
} }
@@ -34,7 +34,13 @@ export function DocumentChart() {
useEffect(() => { useEffect(() => {
async function fetchDocumentStats() { async function fetchDocumentStats() {
try { 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) { if (res.data?.data) {
setData(res.data.data); setData(res.data.data);
} }
@@ -78,7 +84,7 @@ export function DocumentChart() {
stroke={dark ? "#334155" : "#e5e7eb"} stroke={dark ? "#334155" : "#e5e7eb"}
/> />
<XAxis <XAxis
dataKey="name" dataKey="label"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }} tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
@@ -97,9 +103,9 @@ export function DocumentChart() {
}} }}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }} labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/> />
<Bar dataKey="jumlah" radius={[4, 4, 0, 0]}> <Bar dataKey="value" radius={[4, 4, 0, 0]}>
{data.map((entry) => ( {data.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} /> <Cell key={`cell-${entry.label}`} fill={entry.color} />
))} ))}
</Bar> </Bar>
</BarChart> </BarChart>