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
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
364
src/app/api/mobile/project/task/[id]/approval/route.ts
Normal file
364
src/app/api/mobile/project/task/[id]/approval/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
408
src/app/api/mobile/task/tugas/[id]/approval/route.ts
Normal file
408
src/app/api/mobile/task/tugas/[id]/approval/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user