diff --git a/prisma/migrations/20260507034026_add_task_approval/migration.sql b/prisma/migrations/20260507034026_add_task_approval/migration.sql new file mode 100644 index 0000000..af29503 --- /dev/null +++ b/prisma/migrations/20260507034026_add_task_approval/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a8c332..ae87c5e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/app/api/mobile/project/task/[id]/approval/route.ts b/src/app/api/mobile/project/task/[id]/approval/route.ts new file mode 100644 index 0000000..45d41b7 --- /dev/null +++ b/src/app/api/mobile/project/task/[id]/approval/route.ts @@ -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 { + 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 { + 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 { + 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 }); + } +} diff --git a/src/app/api/mobile/task/tugas/[id]/approval/route.ts b/src/app/api/mobile/task/tugas/[id]/approval/route.ts new file mode 100644 index 0000000..0b53c6d --- /dev/null +++ b/src/app/api/mobile/task/tugas/[id]/approval/route.ts @@ -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 { + 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 { + 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 { + 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 }); + } +} diff --git a/src/app/api/mobile/user/[id]/route.ts b/src/app/api/mobile/user/[id]/route.ts index b993d8a..854b8c0 100644 --- a/src/app/api/mobile/user/[id]/route.ts +++ b/src/app/api/mobile/user/[id]/route.ts @@ -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 {