Compare commits

..

10 Commits

8 changed files with 660 additions and 180 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Village" ADD COLUMN "isDummy" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -51,6 +51,7 @@ model Village {
name String
desc String @db.Text
isActive Boolean @default(true)
isDummy Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Group Group[]

View File

@@ -0,0 +1,59 @@
import { prisma } from "@/module/_global";
import { ILogin } from "@/types";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
try {
const { phone }: ILogin = await req.json();
const user = await prisma.user.findUnique({
where: { phone, isActive: true },
select: { id: true, phone: true, isWithoutOTP: true },
});
if (!user) {
return Response.json({
success: false,
message: "Nomor telepon tidak terdaftar",
});
}
// Generate OTP
const code = Math.floor(1000 + Math.random() * 9000);
const message = `Desa+\nMasukkan kode ini ${code} pada web app Desa+ anda. Jangan berikan pada siapapun.`;
// Send WhatsApp
try {
const resWa = await fetch(`${process.env.URL_OTP}/api/wa/send-text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.WA_SERVER_TOKEN}`,
},
body: JSON.stringify({
number: user.phone,
text: message,
}),
});
if (!resWa.ok) {
console.error("WhatsApp API Error:", resWa.status);
}
} catch (error) {
console.error("WhatsApp Fetch Error:", error);
}
return Response.json({
success: true,
message: "Sukses",
phone: user.phone,
isWithoutOTP: user.isWithoutOTP,
id: user.id,
otp: code, // Return OTP for client-side verification (as per existing logic)
});
} catch (error) {
console.error(error);
return Response.json({ message: "Internal Server Error (error: 500)", success: false });
}
}

View File

@@ -0,0 +1,233 @@
import { prisma } from "@/module/_global";
import cors from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import Elysia from "elysia";
import _ from "lodash";
import moment from "moment";
import "moment/locale/id";
// Gabungkan semua ke dalam satu instance server yang dipasang di /api/monitoring
const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
.use(cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
}))
.use(swagger({
path: "/docs", // Karena prefix instance adalah /api/monitoring, maka ini akan diakses di /api/monitoring/docs
documentation: {
info: {
title: "Des Plus - Monitoring API",
version: "1.0.0",
description: "API Khusus untuk kebutuhan Dashboard Monitoring",
}
}
}))
.get(
"/grid-overview",
async ({ query, set }) => {
try {
const version = await prisma.setting.findMany({
select: {
id: true,
name: true,
value: true
}
});
const result_version = Object.fromEntries(version.map(item => [item.id, item.value]));
const activity_today = await prisma.userLog.count({
where: {
createdAt: {
gte: moment().subtract(1, 'days').toDate(),
lte: moment().toDate(),
}
}
})
const activity_yesterday = await prisma.userLog.count({
where: {
createdAt: {
gte: moment().subtract(2, 'days').toDate(),
lte: moment().subtract(1, 'days').toDate(),
}
}
})
const activity_increase = (activity_today - activity_yesterday);
const percentage_increase = (activity_increase / activity_yesterday) * 100
const total_village = await prisma.village.findMany({
where: {
isDummy: false
}
})
const total_village_active = total_village.filter((item) => item.isActive).length
const total_village_inactive = total_village.filter((item) => !item.isActive).length
return {
success: true,
message: "Berhasil mendapatkan data",
data: {
version: result_version,
activity: {
today: activity_today,
increase: _.isNaN(percentage_increase) ? 0 : percentage_increase.toFixed(2),
},
village: {
active: total_village_active,
inactive: total_village_inactive,
},
},
};
} catch (error) {
console.error("[overview] grid-overview error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
detail: {
summary: "Grid Overview",
description: "Menu Overview - Mendapatkan daftar versi aplikasi.",
tags: ["overview"],
},
}
)
.get(
"/daily-activity",
async ({ query, set }) => {
try {
// const data = await prisma.userLog.findMany({
// where: {
// User: {
// Village: {
// isDummy: false
// }
// },
// createdAt: {
// gte: moment().subtract(7, 'days').toDate(),
// lte: moment().toDate(),
// }
// },
// select: {
// createdAt: true,
// }
// })
const data = await prisma.$queryRaw`
SELECT
DATE(ul."createdAt") AS tanggal,
COUNT(*) AS total
FROM "UserLog" ul
JOIN "User" u ON ul."idUser" = u."id"
JOIN "Village" v ON u."idVillage" = v."id"
WHERE v."isDummy" = false
AND ul."createdAt" >= NOW() - INTERVAL '7 days'
GROUP BY tanggal
ORDER BY tanggal;` as any[];
const result = [];
// ubah data ke map biar gampang lookup
const map = data.reduce((acc: any, item: any) => {
const key = moment(item.tanggal).format('YYYY-MM-DD');
acc[key] = Number(item.total);
return acc;
}, {});
// generate 7 hari terakhir
for (let i = 6; i >= 0; i--) {
const date = moment().subtract(i, 'days');
const key = date.format('YYYY-MM-DD');
result.push({
date: date.format('DD MMM'),
logs: map[key] || 0
});
}
return {
success: true,
message: "Berhasil mendapatkan data",
data: result,
};
} catch (error) {
console.error("[overview] daily-activity error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
detail: {
summary: "Daily Activity",
description: "Menu Overview - Mendapatkan data grafik aktivitas harian semua desa.",
tags: ["overview"],
},
}
)
.get(
"/comparison-activity",
async ({ query, set }) => {
try {
const data = await prisma.$queryRaw`
SELECT
v."name",
COUNT(ul."id") AS total_logs
FROM "UserLog" ul
JOIN "User" u ON ul."idUser" = u."id"
JOIN "Village" v ON u."idVillage" = v."id"
WHERE v."isDummy" = false
AND ul."createdAt" >= NOW() - INTERVAL '7 days'
GROUP BY v."id", v."name"
ORDER BY total_logs DESC;
` as any[];
const result = data.map(item => ({
village: item.name,
activity: Number(item.total_logs)
}));
return {
success: true,
message: "Berhasil mendapatkan data",
data: result,
};
} catch (error) {
console.error("[overview] comparison-activity error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
detail: {
summary: "Comparison Activity",
description: "Menu Overview - Mendapatkan data grafik perbandingan aktivitas desa selama 7 hari terakhir.",
tags: ["overview"],
},
}
)
;
export const GET = MonitoringServer.handle;
export const POST = MonitoringServer.handle;

View File

@@ -1,7 +1,8 @@
import { prisma } from "@/module/_global";
import cors from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import Elysia, { t } from "elysia";
import { prisma } from "@/module/_global";
import _ from "lodash";
import moment from "moment";
import "moment/locale/id";
@@ -11,7 +12,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
}))
.use(swagger({
.use(swagger({
path: "/docs", // Karena prefix instance adalah /api/noc, maka ini akan diakses di /api/noc/docs
documentation: {
info: {
@@ -83,13 +84,13 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
// Hitung total kegiatan per divisi & urutkan descending, ambil top sesuai limit
const ranked = divisions
.map((d) => ({
.map((d: any) => ({
id: d.id,
division: d.name,
group: d.Group.name,
totalKegiatan: d._count.DivisionProject
}))
.sort((a, b) => b.totalKegiatan - a.totalKegiatan)
.sort((a: any, b: any) => b.totalKegiatan - a.totalKegiatan)
.slice(0, maxResults);
return {
@@ -118,7 +119,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
}),
detail: {
summary: "Divisi Teraktif",
description: "Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.",
description: "Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.",
tags: ["NOC"],
},
}
@@ -186,7 +187,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
take: maxResults,
});
const mapped = projects.map((p) => ({
const mapped = projects.map((p: any) => ({
id: p.id,
title: p.title,
status: p.status,
@@ -225,147 +226,17 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
}),
detail: {
summary: "Latest Projects General",
description: "Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.",
description: "Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.",
tags: ["NOC"],
},
}
)
// ── GET /api/noc/village-summary ───────────────────────────────────────────
.get(
"/village-summary",
async ({ query, set }) => {
const { idDesa } = query;
if (!idDesa) {
set.status = 400;
return { success: false, message: "Parameter idDesa wajib diisi", data: null };
}
try {
const counts = await prisma.village.findUnique({
where: { id: idDesa },
select: {
name: true,
_count: {
select: {
User: true,
Group: true,
Division: true,
Project: true,
Announcement: true,
Discussion: true,
}
}
}
});
if (!counts) {
set.status = 404;
return { success: false, message: "Desa tidak ditemukan", data: null };
}
return {
success: true,
message: "Berhasil mendapatkan ringkasan desa",
data: {
idDesa,
namaDesa: counts.name,
summary: {
totalWarga: counts._count.User,
totalGrup: counts._count.Group,
totalDivisi: counts._count.Division,
totalProyek: counts._count.Project,
totalPengumuman: counts._count.Announcement,
totalDiskusi: counts._count.Discussion,
}
}
};
} catch (error) {
console.error("[NOC] village-summary error:", error);
set.status = 500;
return { success: false, message: "Terjadi kesalahan pada server", data: null };
}
},
{
query: t.Object({ idDesa: t.String() }),
detail: { summary: "Village Summary Statistics", tags: ["NOC"] }
}
)
// ── GET /api/noc/recent-activity ───────────────────────────────────────────
.get(
"/recent-activity",
async ({ query, set }) => {
const { idDesa, limit } = query;
if (!idDesa) {
set.status = 400;
return { success: false, message: "Parameter idDesa wajib diisi", data: null };
}
const maxResults = Math.min(Number(limit ?? 10), 50);
try {
const logs = await prisma.userLog.findMany({
where: {
User: {
idVillage: idDesa
}
},
select: {
id: true,
action: true,
desc: true,
createdAt: true,
User: {
select: {
name: true,
img: true,
Group: { select: { name: true } }
}
}
},
orderBy: { createdAt: "desc" },
take: maxResults
});
const mapped = logs.map(l => ({
id: l.id,
userName: l.User.name,
userGroup: l.User.Group.name,
userImg: l.User.img,
action: l.action,
description: l.desc,
time: moment(l.createdAt).fromNow(),
date: moment(l.createdAt).format("YYYY-MM-DD HH:mm:ss")
}));
return {
success: true,
message: "Berhasil mendapatkan aktivitas terbaru",
data: { idDesa, total: mapped.length, activities: mapped }
};
} catch (error) {
console.error("[NOC] recent-activity error:", error);
set.status = 500;
return { success: false, message: "Terjadi kesalahan pada server", data: null };
}
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String())
}),
detail: { summary: "Recent User Activity Logs", tags: ["NOC"] }
}
)
// ── GET /api/noc/upcoming-events ───────────────────────────────────────────
.get(
"/upcoming-events",
async ({ query, set }) => {
const { idDesa, limit } = query;
const { idDesa, limit, filter } = query;
if (!idDesa) {
set.status = 400;
@@ -443,7 +314,8 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
take: maxResults,
});
const mapped = events.map((e) => ({
const todayMoment = moment().startOf("day");
const mapper = (e: any) => ({
id: e.id,
idCalendar: e.idCalendar,
title: e.DivisionCalendar.title,
@@ -462,17 +334,29 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
id: e.Division.id,
name: e.Division.name,
},
}));
});
const todayEvents = events.filter((e: any) => moment(e.dateStart).isSame(todayMoment, 'day')).map(mapper);
const upcomingEvents = events.filter((e: any) => moment(e.dateStart).isAfter(todayMoment, 'day')).map(mapper);
let data: any = {
idDesa: village.id,
namaDesa: village.name,
};
if (filter === "today") {
data.events = todayEvents;
} else if (filter === "upcoming") {
data.events = upcomingEvents;
} else {
data.today = todayEvents;
data.upcoming = upcomingEvents;
}
return {
success: true,
message: "Berhasil mendapatkan upcoming events",
data: {
idDesa: village.id,
namaDesa: village.name,
total: mapped.length,
events: mapped,
},
message: "Berhasil mendapatkan events",
data: data,
};
} catch (error) {
console.error("[NOC] upcoming-events error:", error);
@@ -490,14 +374,314 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
limit: t.Optional(
t.String({ description: "Jumlah maksimal event (default: 10, maks: 50)" })
),
filter: t.Optional(
t.String({ description: "Filter event: 'today' atau 'upcoming'" })
),
}),
detail: {
summary: "Upcoming Events",
description: "Mendapatkan daftar event yang akan datang untuk semua divisi pada desa tertentu.",
summary: "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.",
tags: ["NOC"],
},
}
)
// ── GET /api/noc/diagram-jumlah-document ───────────────────────────────────────────────
.get(
"/diagram-jumlah-document",
async ({ query, set }) => {
const { idDesa } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const documents = await prisma.divisionDocumentFolderFile.findMany({
where: {
isActive: true,
category: 'FILE',
Division: {
isActive: true,
idVillage: idDesa,
Group: {
isActive: true,
}
}
}
})
const groupData = _.map(_.groupBy(documents, "extension"), (v: any) => ({
file: v[0].extension,
jumlah: v.length,
}))
const image = ['jpg', 'jpeg', 'png', 'heic']
let hasilImage = {
label: 'Gambar',
value: 0,
color: '#fac858'
}
let hasilFile = {
label: 'Dokumen',
value: 0,
color: '#92cc76'
}
groupData.map((v: any) => {
if (image.some((i: any) => i == v.file)) {
hasilImage = {
label: 'Gambar',
value: hasilImage.value + v.jumlah,
color: '#fac858'
}
} else {
hasilFile = {
label: 'Dokumen',
value: hasilFile.value + v.jumlah,
color: '#92cc76'
}
}
})
const allData = [hasilImage, hasilFile]
return {
success: true,
message: "Berhasil mendapatkan jumlah document",
data: allData
};
} catch (error) {
console.error("[NOC] jumlah-document error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
}),
detail: {
summary: "Diagram Jumlah Document",
description: "Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.",
tags: ["NOC"],
},
}
)
// -- GET /api/noc/diagram-progres-kegiatan
.get(
"/diagram-progres-kegiatan",
async ({ query, set }) => {
const { idDesa } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const data = await prisma.project.groupBy({
where: {
isActive: true,
idVillage: idDesa,
Group: {
isActive: true,
}
},
by: ["status"],
_count: true
})
const dataStatus = [{ name: 'Segera dikerjakan', status: 0, color: '#177AD5' }, { name: 'Dikerjakan', status: 1, color: '#fac858' }, { name: 'Selesai dikerjakan', status: 2, color: '#92cc76' }, { name: 'Dibatalkan', status: 3, color: '#ED6665' }]
const hasil: any[] = []
let input
for (let index = 0; index < dataStatus.length; index++) {
const cek = data.some((i: any) => i.status == dataStatus[index].status)
if (cek) {
const find = ((Number(data.find((i: any) => i.status == dataStatus[index].status)?._count) * 100) / data.reduce((n: any, { _count }: any) => n + _count, 0)).toFixed(2)
const fix = find != "100.00" ? find.substr(-2, 2) == "00" ? find.substr(0, 2) : find : "100"
input = {
text: fix + '%',
value: fix,
color: dataStatus[index].color
}
} else {
input = {
text: '0%',
value: 0,
color: dataStatus[index].color
}
}
hasil.push(input)
}
return {
success: true,
message: "Berhasil mendapatkan progres kegiatan",
data: hasil
};
} catch (error) {
console.error("[NOC] progres-kegiatan error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
}),
detail: {
summary: "Diagram Progres Kegiatan",
description: "Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.",
tags: ["NOC"],
},
}
)
// -- GET /api/noc/latest-discussion
.get(
"/latest-discussion",
async ({ query, set }) => {
const { idDesa, limit } = query;
const maxResults = Math.min(Number(limit ?? 5), 50);
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const data = await prisma.discussion.findMany({
take: maxResults,
where: {
idVillage: idDesa,
isActive: true,
status: 1,
},
select: {
id: true,
title: true,
desc: true,
createdAt: true,
User: {
select: {
name: true
}
},
Group: {
select: {
name: true
}
}
},
orderBy: {
createdAt: "desc"
}
})
const allData = data.map((v: any) => ({
..._.omit(v, ["createdAt", "User", "Group"]),
date: moment(v.createdAt).format("ll"),
user: v.User.name,
group: v.Group.name
}))
return {
success: true,
message: "Berhasil mendapatkan latest discussion",
data: allData
};
} catch (error) {
console.error("[NOC] latest-discussion error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
limit: t.Optional(t.String({ description: "Limit data" })),
}),
detail: {
summary: "Latest Discussion",
description: "Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.",
tags: ["NOC"],
},
}
);
export const GET = NocServer.handle;
export const POST = NocServer.handle;

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
export async function GET(request: Request) {
try {
return NextResponse.json({ success: true, version: "2.1.6", tahap: "beta", update: "-revisi api mobile pengumuman, diskusi umum dan diskusi divisi; -ditambah kan file " }, { status: 200 });
return NextResponse.json({ success: true, version: "2.1.9", tahap: "beta", update: "-api untuk dashboard monitoring" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, version: "Gagal mendapatkan version, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });

View File

@@ -5,7 +5,6 @@ import { useFocusTrap } from "@mantine/hooks";
import { useState } from "react";
import toast from "react-hot-toast";
import ViewVerification from "../../varification/view/view_verification";
function ViewLogin() {
const focusTrapRef = useFocusTrap()
const textInfo = "Kami akan mengirimkan kode verifikasi melalui WhatsApp untuk mengonfirmasi nomor Anda.";
@@ -34,23 +33,24 @@ function ViewLogin() {
})
const cekLogin = await cek.json()
if (cekLogin.success) {
const code = Math.floor(1000 + Math.random() * 9000)
try {
const res = await fetch(`https://wa.wibudev.com/code?nom=${cekLogin.phone}&text=*DARMASABA*%0A%0A
JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`).then(
async (res) => {
if (res.status == 200) {
setValPhone(cekLogin.phone)
setOTP(code)
setUser(cekLogin.id)
setVerif(true)
toast.success('Kode verifikasi telah dikirim')
} else {
console.error(res.status)
toast.error('Internal Server Error')
}
}
)
const res = await fetch('/api/auth/otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: isPhone })
})
const data = await res.json()
if (data.success) {
setValPhone(data.phone)
setOTP(data.otp)
setUser(data.id)
setVerif(true)
toast.success('Kode verifikasi telah dikirim')
} else {
toast.error(data.message || 'Gagal mengirim kode verifikasi')
}
} catch (error) {
console.error(error)
toast.error('Internal Server Error')

View File

@@ -15,19 +15,20 @@ export default function ViewVerification({ phone, otp, user }: IVerification) {
async function onResend() {
try {
const code = Math.floor(1000 + Math.random() * 9000)
const res = await fetch(`https://wa.wibudev.com/code?nom=${phone}&text=*DARMASABA*%0A%0A
JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`)
.then(
async (res) => {
if (res.status == 200) {
toast.success('Kode verifikasi telah dikirim')
setOTP(code)
} else {
toast.error('Internal Server Error')
}
}
);
const res = await fetch('/api/auth/otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone })
})
const data = await res.json()
if (data.success) {
toast.success('Kode verifikasi telah dikirim')
setOTP(data.otp)
} else {
toast.error(data.message || 'Gagal mengirim ulang kode')
}
} catch (error) {
console.error(error)
toast.error('Internal Server Error')