Compare commits

...

8 Commits

Author SHA1 Message Date
b6e1f59945 bump: version 0.1.11 + migration 2026-05-12 17:18:24 +08:00
0e9fa756cb feat: expose isDummy on get-villages and edit-villages endpoints 2026-05-12 14:11:22 +08:00
e6702ba01e Merge pull request 'feat: tambah endpoint kalender umum village' (#45) from amalia/11-mei-26 into join
Reviewed-on: #45
2026-05-11 17:36:03 +08:00
863b8bec54 feat: tambah endpoint kalender umum village
- GET /mobile/village-calendar: ambil acara divisi dan kegiatan se-village per tanggal
- GET /mobile/village-calendar/indicator: dot indikator per bulan, task di-expand per hari dalam range dateStart-dateEnd
2026-05-11 15:19:29 +08:00
b146106d13 Merge pull request 'feat: tambah fitur approval task pada project dan divisi' (#44) from amalia/07-mei-26 into join
Reviewed-on: #44
2026-05-07 17:39:55 +08:00
732e26ca0d feat: tambah fitur approval task pada project dan divisi
- tambah model ProjectTaskApproval dan DivisionProjectTaskApproval di schema prisma
- tambah field isApprover pada model User
- tambah API approval project task: GET riwayat, POST ajukan, PUT setujui/tolak
- tambah API approval division task: GET riwayat, POST ajukan, PUT setujui/tolak
- notifikasi dikirim ke approver, admin divisi, dan submitter via FCM, web push, dan in-app
- tambah PATCH endpoint untuk toggle isApprover pada mobile user API
- perbaiki pengecekan role approver menggunakan UserRole.id
2026-05-07 16:04:11 +08:00
b7ce72a41b Merge pull request 'amalia/06-mei-26' (#43) from amalia/06-mei-26 into join
Reviewed-on: #43
2026-05-06 17:16:57 +08:00
3ce5e14a6c Merge pull request 'amalia/04-mei-26' (#42) from amalia/04-mei-26 into join
Reviewed-on: #42
2026-05-04 17:06:53 +08:00
10 changed files with 1126 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "sistem-desa-mandiri",
"version": "0.1.10",
"version": "0.1.11",
"private": true,
"scripts": {
"dev": "next dev --experimental-https",

View File

@@ -0,0 +1,48 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isApprover" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ProjectTaskApproval" (
"id" TEXT NOT NULL,
"idTask" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"idApprover" TEXT,
"status" INTEGER NOT NULL DEFAULT 0,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTaskApproval_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectTaskApproval" (
"id" TEXT NOT NULL,
"idTask" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"idApprover" TEXT,
"status" INTEGER NOT NULL DEFAULT 0,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectTaskApproval_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ProjectTaskApproval" ADD CONSTRAINT "ProjectTaskApproval_idTask_fkey" FOREIGN KEY ("idTask") REFERENCES "ProjectTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTaskApproval" ADD CONSTRAINT "ProjectTaskApproval_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTaskApproval" ADD CONSTRAINT "ProjectTaskApproval_idApprover_fkey" FOREIGN KEY ("idApprover") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTaskApproval" ADD CONSTRAINT "DivisionProjectTaskApproval_idTask_fkey" FOREIGN KEY ("idTask") REFERENCES "DivisionProjectTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTaskApproval" ADD CONSTRAINT "DivisionProjectTaskApproval_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTaskApproval" ADD CONSTRAINT "DivisionProjectTaskApproval_idApprover_fkey" FOREIGN KEY ("idApprover") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -109,6 +109,7 @@ model User {
img String?
isFirstLogin Boolean @default(true)
isWithoutOTP Boolean @default(false)
isApprover Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -125,13 +126,17 @@ model User {
DivisionDocumentFolderFile DivisionDocumentFolderFile[]
DivisionCalendar DivisionCalendar[]
DivisionCalendarMember DivisionCalendarMember[]
Notifications Notifications[] @relation("UserToUser")
Notifications2 Notifications[] @relation("UserFromUser")
Subscribe Subscribe?
Discussion Discussion[]
DiscussionMember DiscussionMember[]
DiscussionComment DiscussionComment[]
TokenDeviceUser TokenDeviceUser[]
Notifications Notifications[] @relation("UserToUser")
Notifications2 Notifications[] @relation("UserFromUser")
Subscribe Subscribe?
Discussion Discussion[]
DiscussionMember DiscussionMember[]
DiscussionComment DiscussionComment[]
TokenDeviceUser TokenDeviceUser[]
ProjectTaskApprovalSubmitted ProjectTaskApproval[] @relation("ApprovalSubmitter")
ProjectTaskApprovalHandled ProjectTaskApproval[] @relation("ApprovalApprover")
DivisionProjectTaskApprovalSubmitted DivisionProjectTaskApproval[] @relation("DivApprovalSubmitter")
DivisionProjectTaskApprovalHandled DivisionProjectTaskApproval[] @relation("DivApprovalApprover")
}
model TokenDeviceUser {
@@ -260,15 +265,16 @@ model ProjectTask {
idProject String
title String
desc String?
status Int @default(0) // 0 = todo, 1 = done
notifikasi Boolean @default(false)
dateStart DateTime @db.Date
dateEnd DateTime @db.Date
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ProjectTaskDetail ProjectTaskDetail[]
ProjectTaskFile ProjectTaskFile[]
status Int @default(0) // 0 = todo, 1 = done, 2 = waiting_approval
notifikasi Boolean @default(false)
dateStart DateTime @db.Date
dateEnd DateTime @db.Date
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ProjectTaskDetail ProjectTaskDetail[]
ProjectTaskFile ProjectTaskFile[]
ProjectTaskApproval ProjectTaskApproval[]
}
model ProjectTaskFile {
@@ -373,15 +379,16 @@ model DivisionProjectTask {
idProject String
title String
desc String? @db.Text
status Int @default(0) // 0 = todo, 1 = done
notifikasi Boolean @default(false)
dateStart DateTime @db.Date
dateEnd DateTime @db.Date
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
DivisionProjectTaskDetail DivisionProjectTaskDetail[]
DivisionProjectTaskFile DivisionProjectTaskFile[]
status Int @default(0) // 0 = todo, 1 = done, 2 = waiting_approval
notifikasi Boolean @default(false)
dateStart DateTime @db.Date
dateEnd DateTime @db.Date
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
DivisionProjectTaskDetail DivisionProjectTaskDetail[]
DivisionProjectTaskFile DivisionProjectTaskFile[]
DivisionProjectTaskApproval DivisionProjectTaskApproval[]
}
model DivisionProjectTaskDetail {
@@ -694,3 +701,31 @@ model Setting{
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProjectTaskApproval {
id String @id @default(cuid())
ProjectTask ProjectTask @relation(fields: [idTask], references: [id])
idTask String
Submitter User @relation("ApprovalSubmitter", fields: [idUser], references: [id])
idUser String
Approver User? @relation("ApprovalApprover", fields: [idApprover], references: [id])
idApprover String?
status Int @default(0) // 0 = pending, 1 = approved, 2 = rejected
note String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DivisionProjectTaskApproval {
id String @id @default(cuid())
DivisionProjectTask DivisionProjectTask @relation(fields: [idTask], references: [id])
idTask String
Submitter User @relation("DivApprovalSubmitter", fields: [idUser], references: [id])
idUser String
Approver User? @relation("DivApprovalApprover", fields: [idApprover], references: [id])
idApprover String?
status Int @default(0) // 0 = pending, 1 = approved, 2 = rejected
note String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -0,0 +1,364 @@
import { funSendWebPush, prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import { createLogUserMobile } from "@/module/user";
import _ from "lodash";
import moment from "moment";
import { NextResponse } from "next/server";
import { sendFCMNotificationMany } from "../../../../../../../../xsendMany";
const APPROVER_ROLES = ['supadmin', 'developer'];
async function getApproverStatus(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
isApprover: true,
UserRole: { select: { id: true } }
}
});
if (!user) return false;
return user.isApprover || APPROVER_ROLES.includes(user.UserRole.id);
}
async function recalculateProjectStatus(idProject: string) {
const tasks = await prisma.projectTask.findMany({
where: { isActive: true, idProject },
select: { status: true }
});
const semua = tasks.length;
const selesai = tasks.filter((t) => t.status === 1).length;
const prosess = semua === 0 ? 0 : Math.ceil((selesai / semua) * 100);
let statusProject = 1;
if (prosess === 100) statusProject = 2;
else if (prosess === 0) statusProject = 0;
await prisma.project.update({
where: { id: idProject },
data: { status: statusProject }
});
}
type NotifTarget = {
idUserTo: string;
tokens: string[];
subscription: string | undefined;
}
async function sendNotification({
targets,
idUserFrom,
idContent,
title,
desc,
}: {
targets: NotifTarget[];
idUserFrom: string;
idContent: string;
title: string;
desc: string;
}) {
const filtered = targets.filter((t) => t.idUserTo !== idUserFrom);
const unique = _.uniqBy(filtered, 'idUserTo');
if (unique.length === 0) return;
// In-app notification
await prisma.notifications.createMany({
data: unique.map((t) => ({
idUserTo: t.idUserTo,
idUserFrom,
category: 'project',
idContent,
title,
desc,
}))
});
// FCM push notification
const tokens = [...new Set(unique.flatMap((t) => t.tokens))].filter(Boolean);
if (tokens.length > 0) {
await sendFCMNotificationMany({
token: tokens,
title,
body: desc,
data: { id: idContent, category: 'project', content: idContent }
});
}
// Web push notification
const subs = unique
.filter((t): t is typeof t & { subscription: string } => Boolean(t.subscription))
.map((t) => ({ idUser: t.idUserTo, subscription: t.subscription }));
if (subs.length > 0) {
await funSendWebPush({ sub: subs, message: { title, body: desc } });
}
}
async function getApproversInVillage(idVillage: string): Promise<NotifTarget[]> {
const approvers = await prisma.user.findMany({
where: {
isActive: true,
idVillage,
OR: [
{ isApprover: true },
{ UserRole: { id: 'supadmin' } }
]
},
select: {
id: true,
TokenDeviceUser: { select: { token: true } },
Subscribe: { select: { subscription: true } }
}
});
return approvers.map((u) => ({
idUserTo: u.id,
tokens: u.TokenDeviceUser.map((t) => t.token),
subscription: u.Subscribe?.subscription ?? undefined,
}));
}
async function getUserNotifTarget(userId: string): Promise<NotifTarget | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
TokenDeviceUser: { select: { token: true } },
Subscribe: { select: { subscription: true } }
}
});
if (!user) return null;
return {
idUserTo: user.id,
tokens: user.TokenDeviceUser.map((t) => t.token),
subscription: user.Subscribe?.subscription ?? undefined,
};
}
// GET — Riwayat approval task
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { searchParams } = new URL(request.url);
const user = searchParams.get("user");
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id) {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.projectTask.count({ where: { id, isActive: true } });
if (task === 0) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const data = await prisma.projectTaskApproval.findMany({
where: { idTask: id },
orderBy: { createdAt: "desc" },
select: {
id: true,
status: true,
note: true,
createdAt: true,
Submitter: { select: { name: true } },
Approver: { select: { name: true } },
}
});
const formatted = data.map((v) => ({
id: v.id,
status: v.status,
note: v.note,
createdAt: moment(v.createdAt).format("DD MMM YYYY, HH:mm"),
submitter: { name: v.Submitter.name },
approver: v.Approver ? { name: v.Approver.name } : null,
}));
return NextResponse.json({ success: true, message: "Riwayat approval berhasil ditemukan", data: formatted }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan riwayat approval (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// POST — Ajukan selesai (user mengajukan task untuk persetujuan)
export async function POST(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user } = await request.json();
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id) {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.projectTask.findUnique({
where: { id, isActive: true },
select: { id: true, status: true, idProject: true, title: true }
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
if (task.status !== 0) {
return NextResponse.json({ success: false, message: "Hanya tugas berstatus 'Belum Selesai' yang bisa diajukan" }, { status: 200 });
}
const pendingApproval = await prisma.projectTaskApproval.count({
where: { idTask: id, status: 0 }
});
if (pendingApproval > 0) {
return NextResponse.json({ success: false, message: "Tugas sudah dalam proses menunggu persetujuan" }, { status: 200 });
}
await prisma.$transaction([
prisma.projectTaskApproval.create({
data: { idTask: id, idUser: userMobile.id, status: 0 }
}),
prisma.projectTask.update({
where: { id },
data: { status: 2 }
})
]);
await recalculateProjectStatus(task.idProject);
// Notifikasi ke semua approver
const approverTargets = await getApproversInVillage(String(userMobile.idVillage));
await sendNotification({
targets: approverTargets,
idUserFrom: userMobile.id,
idContent: task.idProject,
title: 'Pengajuan Penyelesaian Tugas',
desc: task.title,
});
await createLogUserMobile({ act: 'CREATE', desc: 'User mengajukan task untuk persetujuan', table: 'projectTaskApproval', data: id, user: userMobile.id });
return NextResponse.json({ success: true, message: "Tugas berhasil diajukan untuk persetujuan" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mengajukan tugas (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// PUT — Setujui atau Tolak (approver action)
export async function PUT(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user, action, note } = await request.json();
if (!['approve', 'reject'].includes(action)) {
return NextResponse.json({ success: false, message: "Action tidak valid, gunakan 'approve' atau 'reject'" }, { status: 200 });
}
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id) {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const canApprove = await getApproverStatus(userMobile.id);
if (!canApprove) {
return NextResponse.json({ success: false, message: "Anda tidak memiliki izin untuk menyetujui atau menolak tugas" }, { status: 200 });
}
const task = await prisma.projectTask.findUnique({
where: { id, isActive: true },
select: { id: true, status: true, idProject: true, title: true }
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
if (task.status !== 2) {
return NextResponse.json({ success: false, message: "Tugas tidak sedang menunggu persetujuan" }, { status: 200 });
}
const pendingApproval = await prisma.projectTaskApproval.findFirst({
where: { idTask: id, status: 0 },
orderBy: { createdAt: "desc" },
select: { id: true, idUser: true }
});
if (!pendingApproval) {
return NextResponse.json({ success: false, message: "Data persetujuan pending tidak ditemukan" }, { status: 200 });
}
if (action === 'approve') {
await prisma.$transaction([
prisma.projectTaskApproval.update({
where: { id: pendingApproval.id },
data: { status: 1, idApprover: userMobile.id }
}),
prisma.projectTask.update({
where: { id },
data: { status: 1 }
})
]);
await recalculateProjectStatus(task.idProject);
// Notifikasi ke submitter
const submitterTarget = await getUserNotifTarget(pendingApproval.idUser);
if (submitterTarget) {
await sendNotification({
targets: [submitterTarget],
idUserFrom: userMobile.id,
idContent: task.idProject,
title: 'Tugas Disetujui',
desc: task.title,
});
}
await createLogUserMobile({ act: 'UPDATE', desc: 'Approver menyetujui task', table: 'projectTaskApproval', data: id, user: userMobile.id });
return NextResponse.json({ success: true, message: "Tugas berhasil disetujui" }, { status: 200 });
}
// reject
if (!note || String(note).trim() === '') {
return NextResponse.json({ success: false, message: "Alasan penolakan wajib diisi" }, { status: 200 });
}
await prisma.$transaction([
prisma.projectTaskApproval.update({
where: { id: pendingApproval.id },
data: { status: 2, idApprover: userMobile.id, note: String(note).trim() }
}),
prisma.projectTask.update({
where: { id },
data: { status: 0 }
})
]);
await recalculateProjectStatus(task.idProject);
// Notifikasi ke submitter
const submitterTarget = await getUserNotifTarget(pendingApproval.idUser);
if (submitterTarget) {
await sendNotification({
targets: [submitterTarget],
idUserFrom: userMobile.id,
idContent: task.idProject,
title: 'Tugas Ditolak',
desc: task.title,
});
}
await createLogUserMobile({ act: 'UPDATE', desc: 'Approver menolak task', table: 'projectTaskApproval', data: id, user: userMobile.id });
return NextResponse.json({ success: true, message: "Tugas berhasil ditolak" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal memproses persetujuan (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,408 @@
import { funSendWebPush, prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import { createLogUserMobile } from "@/module/user";
import _ from "lodash";
import moment from "moment";
import { NextResponse } from "next/server";
import { sendFCMNotificationMany } from "../../../../../../../../xsendMany";
const APPROVER_ROLES = ['supadmin', 'developer'];
async function getApproverStatus(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
isApprover: true,
UserRole: { select: { id: true } }
}
});
if (!user) return false;
return user.isApprover || APPROVER_ROLES.includes(user.UserRole.id);
}
async function recalculateTaskStatus(idProject: string) {
const tasks = await prisma.divisionProjectTask.findMany({
where: { isActive: true, idProject },
select: { status: true }
});
const semua = tasks.length;
const selesai = tasks.filter((t) => t.status === 1).length;
const prosess = semua === 0 ? 0 : Math.ceil((selesai / semua) * 100);
let statusProject = 1;
if (prosess === 100) statusProject = 2;
else if (prosess === 0) statusProject = 0;
await prisma.divisionProject.update({
where: { id: idProject },
data: { status: statusProject }
});
}
type NotifTarget = {
idUserTo: string;
tokens: string[];
subscription: string | undefined;
}
async function sendNotification({
targets,
idUserFrom,
idContent,
category,
title,
desc,
}: {
targets: NotifTarget[];
idUserFrom: string;
idContent: string;
category: string;
title: string;
desc: string;
}) {
const filtered = targets.filter((t) => t.idUserTo !== idUserFrom);
const unique = _.uniqBy(filtered, 'idUserTo');
if (unique.length === 0) return;
await prisma.notifications.createMany({
data: unique.map((t) => ({
idUserTo: t.idUserTo,
idUserFrom,
category,
idContent,
title,
desc,
}))
});
const tokens = [...new Set(unique.flatMap((t) => t.tokens))].filter(Boolean);
if (tokens.length > 0) {
await sendFCMNotificationMany({
token: tokens,
title,
body: desc,
data: { id: idContent, category, content: idContent }
});
}
const subs = unique
.filter((t): t is typeof t & { subscription: string } => Boolean(t.subscription))
.map((t) => ({ idUser: t.idUserTo, subscription: t.subscription }));
if (subs.length > 0) {
await funSendWebPush({ sub: subs, message: { title, body: desc } });
}
}
async function getApproversForDivision(idVillage: string, idDivision: string): Promise<NotifTarget[]> {
const [globalApprovers, divisionAdmins] = await Promise.all([
prisma.user.findMany({
where: {
isActive: true,
idVillage,
OR: [
{ isApprover: true },
{ UserRole: { id: 'supadmin' } }
]
},
select: {
id: true,
TokenDeviceUser: { select: { token: true } },
Subscribe: { select: { subscription: true } }
}
}),
prisma.divisionMember.findMany({
where: { idDivision, isAdmin: true, isActive: true },
select: {
User: {
select: {
id: true,
TokenDeviceUser: { select: { token: true } },
Subscribe: { select: { subscription: true } }
}
}
}
})
]);
const fromGlobal = globalApprovers.map((u) => ({
idUserTo: u.id,
tokens: u.TokenDeviceUser.map((t) => t.token),
subscription: u.Subscribe?.subscription ?? undefined,
}));
const fromAdmin = divisionAdmins.map((m) => ({
idUserTo: m.User.id,
tokens: m.User.TokenDeviceUser.map((t) => t.token),
subscription: m.User.Subscribe?.subscription ?? undefined,
}));
return _.uniqBy([...fromGlobal, ...fromAdmin], 'idUserTo');
}
async function getUserNotifTarget(userId: string): Promise<NotifTarget | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
TokenDeviceUser: { select: { token: true } },
Subscribe: { select: { subscription: true } }
}
});
if (!user) return null;
return {
idUserTo: user.id,
tokens: user.TokenDeviceUser.map((t) => t.token),
subscription: user.Subscribe?.subscription ?? undefined,
};
}
// GET — Riwayat approval task divisi
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { searchParams } = new URL(request.url);
const user = searchParams.get("user");
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id) {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.divisionProjectTask.count({ where: { id, isActive: true } });
if (task === 0) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const data = await prisma.divisionProjectTaskApproval.findMany({
where: { idTask: id },
orderBy: { createdAt: "desc" },
select: {
id: true,
status: true,
note: true,
createdAt: true,
Submitter: { select: { name: true } },
Approver: { select: { name: true } },
}
});
const formatted = data.map((v) => ({
id: v.id,
status: v.status,
note: v.note,
createdAt: moment(v.createdAt).format("DD MMM YYYY, HH:mm"),
submitter: { name: v.Submitter.name },
approver: v.Approver ? { name: v.Approver.name } : null,
}));
return NextResponse.json({ success: true, message: "Riwayat approval berhasil ditemukan", data: formatted }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan riwayat approval (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// POST — Ajukan selesai
export async function POST(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user } = await request.json();
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id) {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.divisionProjectTask.findUnique({
where: { id, isActive: true },
select: { id: true, status: true, idProject: true, idDivision: true, title: true }
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
if (task.status !== 0) {
return NextResponse.json({ success: false, message: "Hanya tugas berstatus 'Belum Selesai' yang bisa diajukan" }, { status: 200 });
}
const pendingApproval = await prisma.divisionProjectTaskApproval.count({
where: { idTask: id, status: 0 }
});
if (pendingApproval > 0) {
return NextResponse.json({ success: false, message: "Tugas sudah dalam proses menunggu persetujuan" }, { status: 200 });
}
await prisma.$transaction([
prisma.divisionProjectTaskApproval.create({
data: { idTask: id, idUser: userMobile.id, status: 0 }
}),
prisma.divisionProjectTask.update({
where: { id },
data: { status: 2 }
})
]);
await recalculateTaskStatus(task.idProject);
const approverTargets = await getApproversForDivision(String(userMobile.idVillage), task.idDivision);
await sendNotification({
targets: approverTargets,
idUserFrom: userMobile.id,
idContent: task.idProject,
category: `division/${task.idDivision}/task`,
title: 'Pengajuan Penyelesaian Tugas',
desc: task.title,
});
await createLogUserMobile({ act: 'CREATE', desc: 'User mengajukan task divisi untuk persetujuan', table: 'divisionProjectTaskApproval', data: id, user: userMobile.id });
return NextResponse.json({ success: true, message: "Tugas berhasil diajukan untuk persetujuan" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mengajukan tugas (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// PUT — Setujui atau Tolak
export async function PUT(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user, action, note } = await request.json();
if (!['approve', 'reject'].includes(action)) {
return NextResponse.json({ success: false, message: "Action tidak valid, gunakan 'approve' atau 'reject'" }, { status: 200 });
}
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id) {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const canApprove = await getApproverStatus(userMobile.id);
if (!canApprove) {
// Check if division admin
const task = await prisma.divisionProjectTask.findUnique({
where: { id, isActive: true },
select: { idDivision: true }
});
if (task) {
const isDivAdmin = await prisma.divisionMember.count({
where: { idDivision: task.idDivision, idUser: userMobile.id, isAdmin: true, isActive: true }
});
if (isDivAdmin === 0) {
return NextResponse.json({ success: false, message: "Anda tidak memiliki izin untuk menyetujui atau menolak tugas" }, { status: 200 });
}
} else {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
}
const task = await prisma.divisionProjectTask.findUnique({
where: { id, isActive: true },
select: { id: true, status: true, idProject: true, idDivision: true, title: true }
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
if (task.status !== 2) {
return NextResponse.json({ success: false, message: "Tugas tidak sedang menunggu persetujuan" }, { status: 200 });
}
const pendingApproval = await prisma.divisionProjectTaskApproval.findFirst({
where: { idTask: id, status: 0 },
orderBy: { createdAt: "desc" },
select: { id: true, idUser: true }
});
if (!pendingApproval) {
return NextResponse.json({ success: false, message: "Data persetujuan pending tidak ditemukan" }, { status: 200 });
}
if (action === 'approve') {
await prisma.$transaction([
prisma.divisionProjectTaskApproval.update({
where: { id: pendingApproval.id },
data: { status: 1, idApprover: userMobile.id }
}),
prisma.divisionProjectTask.update({
where: { id },
data: { status: 1 }
})
]);
await recalculateTaskStatus(task.idProject);
const [submitterTarget, approverTargets] = await Promise.all([
getUserNotifTarget(pendingApproval.idUser),
getApproversForDivision(String(userMobile.idVillage), task.idDivision),
]);
const notifTargets = _.uniqBy([
...(submitterTarget ? [submitterTarget] : []),
...approverTargets,
], 'idUserTo');
await sendNotification({
targets: notifTargets,
idUserFrom: userMobile.id,
idContent: task.idProject,
category: `division/${task.idDivision}/task`,
title: 'Tugas Disetujui',
desc: task.title,
});
await createLogUserMobile({ act: 'UPDATE', desc: 'Approver menyetujui task divisi', table: 'divisionProjectTaskApproval', data: id, user: userMobile.id });
return NextResponse.json({ success: true, message: "Tugas berhasil disetujui" }, { status: 200 });
}
if (!note || String(note).trim() === '') {
return NextResponse.json({ success: false, message: "Alasan penolakan wajib diisi" }, { status: 200 });
}
await prisma.$transaction([
prisma.divisionProjectTaskApproval.update({
where: { id: pendingApproval.id },
data: { status: 2, idApprover: userMobile.id, note: String(note).trim() }
}),
prisma.divisionProjectTask.update({
where: { id },
data: { status: 0 }
})
]);
await recalculateTaskStatus(task.idProject);
const [submitterTarget, approverTargets] = await Promise.all([
getUserNotifTarget(pendingApproval.idUser),
getApproversForDivision(String(userMobile.idVillage), task.idDivision),
]);
const notifTargets = _.uniqBy([
...(submitterTarget ? [submitterTarget] : []),
...approverTargets,
], 'idUserTo');
await sendNotification({
targets: notifTargets,
idUserFrom: userMobile.id,
idContent: task.idProject,
category: `division/${task.idDivision}/task`,
title: 'Tugas Ditolak',
desc: task.title,
});
await createLogUserMobile({ act: 'UPDATE', desc: 'Approver menolak task divisi', table: 'divisionProjectTaskApproval', data: id, user: userMobile.id });
return NextResponse.json({ success: true, message: "Tugas berhasil ditolak" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal memproses persetujuan (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}

View File

@@ -23,6 +23,7 @@ export async function GET(request: Request, context: { params: { id: string } })
img: true,
idGroup: true,
isActive: true,
isApprover: true,
idPosition: true,
UserRole: {
select: {
@@ -139,6 +140,37 @@ export async function DELETE(request: Request, context: { params: { id: string }
}
// TOGGLE APPROVER STATUS
export async function PATCH(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user, isApprover } = await request.json();
if (user == "null" || user == undefined || user == "") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const data = await prisma.user.count({ where: { id } });
if (data == 0) {
return NextResponse.json({ success: false, message: "Anggota tidak ditemukan" }, { status: 200 });
}
await prisma.user.update({
where: { id },
data: { isApprover: Boolean(isApprover) },
});
const log = await createLogUserMobile({ act: 'UPDATE', desc: 'User mengupdate status approver anggota', table: 'user', data: id, user });
return NextResponse.json({ success: true, message: "Berhasil mengupdate status approver anggota" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mengupdate status approver, coba lagi nanti (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// UPDATE MEMBER
export async function PUT(request: Request, context: { params: { id: string } }) {
try {

View File

@@ -0,0 +1,83 @@
import { prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import _ from "lodash";
import moment from "moment";
import { NextResponse } from "next/server";
// GET indicator dot per bulan (tanggal mana saja yang ada event)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const user = searchParams.get("user");
const date = searchParams.get("date");
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id || userMobile.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const idVillage = userMobile.idVillage;
const awalDate = moment(date).format("YYYY-MM") + "-01";
const akhirDate = moment(awalDate).add(1, "M").format("YYYY-MM-DD");
// Tanggal dengan event kalender divisi
const calendarDates = await prisma.divisionCalendarReminder.findMany({
where: {
isActive: true,
dateStart: {
gte: new Date(awalDate),
lt: new Date(akhirDate),
},
Division: {
idVillage,
isActive: true,
},
DivisionCalendar: {
isActive: true,
},
},
select: { dateStart: true },
});
// Tanggal dengan task project se-village (ambil semua task yang overlap dengan bulan ini)
const taskDates = await prisma.projectTask.findMany({
where: {
isActive: true,
dateStart: { lt: new Date(akhirDate) },
dateEnd: { gte: new Date(awalDate) },
Project: {
isActive: true,
idVillage: String(idVillage),
},
},
select: { dateStart: true, dateEnd: true },
});
// Expand setiap task menjadi array tanggal per hari dalam range bulan
const taskExpandedDates: string[] = [];
for (const task of taskDates) {
let cur = moment(task.dateStart);
const end = moment(task.dateEnd);
const monthStart = moment(awalDate);
const monthEnd = moment(akhirDate);
while (cur.isSameOrBefore(end)) {
if (cur.isSameOrAfter(monthStart) && cur.isBefore(monthEnd)) {
taskExpandedDates.push(cur.format("YYYY-MM-DD"));
}
cur.add(1, 'day');
}
}
const calendarResult = _.uniq(calendarDates.map((v) => moment(v.dateStart).format("YYYY-MM-DD"))).sort();
const taskResult = _.uniq(taskExpandedDates).sort();
return NextResponse.json({
success: true,
message: "Berhasil mendapatkan indicator kalender",
data: { calendar: calendarResult, task: taskResult }
}, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan indicator kalender (error: 500)" }, { status: 500 });
}
}

View File

@@ -0,0 +1,122 @@
import { prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import _ from "lodash";
import moment from "moment";
import "moment/locale/id";
import { NextResponse } from "next/server";
// GET events per tanggal (DivisionCalendarReminder + ProjectTask)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const user = searchParams.get("user");
const date = searchParams.get("date");
const userMobile = await funGetUserById({ id: String(user) });
if (!userMobile.id || userMobile.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const targetDate = new Date(String(date));
const idVillage = userMobile.idVillage;
// Ambil semua event kalender divisi se-village pada tanggal tersebut
const calendarData = await prisma.divisionCalendarReminder.findMany({
where: {
isActive: true,
dateStart: targetDate,
Division: {
idVillage,
isActive: true,
},
DivisionCalendar: {
isActive: true,
},
},
select: {
id: true,
idCalendar: true,
dateStart: true,
dateEnd: true,
timeStart: true,
timeEnd: true,
Division: {
select: {
id: true,
name: true,
},
},
DivisionCalendar: {
select: {
title: true,
desc: true,
},
},
},
orderBy: [{ timeStart: "asc" }, { timeEnd: "asc" }],
});
// Ambil semua task project se-village yang mencakup tanggal tersebut (dateStart <= target <= dateEnd)
const taskData = await prisma.projectTask.findMany({
where: {
isActive: true,
dateStart: { lte: targetDate },
dateEnd: { gte: targetDate },
Project: {
isActive: true,
idVillage: String(idVillage),
},
},
select: {
id: true,
title: true,
desc: true,
dateStart: true,
dateEnd: true,
Project: {
select: {
id: true,
title: true,
},
},
},
orderBy: { dateStart: "asc" },
});
const calendarResult = calendarData.map((v) => ({
id: v.id,
type: "calendar",
title: v.DivisionCalendar.title,
desc: v.DivisionCalendar.desc,
dateStart: moment(v.dateStart).format("YYYY-MM-DD"),
dateEnd: v.dateEnd ? moment(v.dateEnd).format("YYYY-MM-DD") : null,
timeStart: moment.utc(v.timeStart).format("HH:mm"),
timeEnd: moment.utc(v.timeEnd).format("HH:mm"),
divisionName: v.Division.name,
projectName: null,
idDivision: v.Division.id,
idRef: v.Division.id,
}));
const taskResult = taskData.map((v) => ({
id: v.id,
type: "task",
title: v.title,
desc: v.desc ?? "",
dateStart: moment(v.dateStart).format("YYYY-MM-DD"),
dateEnd: moment(v.dateEnd).format("YYYY-MM-DD"),
timeStart: null,
timeEnd: null,
divisionName: null,
projectName: v.Project.title,
idRef: v.Project.id,
}));
const data = [...calendarResult, ...taskResult];
return NextResponse.json({ success: true, message: "Berhasil mendapatkan kalender umum", data }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan kalender umum (error: 500)" }, { status: 500 });
}
}

View File

@@ -311,13 +311,13 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
try {
const data = await prisma.village.findMany({
where: {
isDummy: false,
...(search && { name: { contains: search, mode: 'insensitive' } })
},
select: {
id: true,
name: true,
isActive: true,
isDummy: true,
createdAt: true,
User: {
where: {
@@ -335,7 +335,6 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
const count = await prisma.village.count({
where: {
isDummy: false,
...(search && { name: { contains: search, mode: 'insensitive' } })
},
})
@@ -344,6 +343,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
id: village.id,
name: village.name,
isActive: village.isActive,
isDummy: village.isDummy,
createdAt: formatDateTime(village.createdAt),
perbekel: village.User[0]?.name || null,
}));
@@ -501,6 +501,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
id: true,
name: true,
isActive: true,
isDummy: true,
createdAt: true,
updatedAt: true,
desc: true,
@@ -529,6 +530,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
id: data?.id,
name: data?.name,
isActive: data?.isActive,
isDummy: data?.isDummy,
desc: data?.desc,
createdAt: data?.createdAt ? formatDateTime(data.createdAt) : null,
updatedAt: data?.updatedAt ? formatDateTime(data.updatedAt) : null,
@@ -957,7 +959,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
)
.post("/edit-villages", async ({ body, set }) => {
const { id, name, desc } = body;
const { id, name, desc, isDummy } = body;
try {
const village = await prisma.village.findUnique({
@@ -977,6 +979,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
data: {
name,
desc,
isDummy,
},
});
@@ -998,6 +1001,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
id: t.String({ description: "ID desa" }),
name: t.String({ description: "Nama desa" }),
desc: t.String({ description: "Deskripsi desa" }),
isDummy: t.Boolean({ description: "Apakah desa dummy" }),
}),
detail: {
summary: "Edit Villages",