Compare commits

..

38 Commits

Author SHA1 Message Date
552957282b bump: version 0.1.18 + migration 2026-05-25 15:36:30 +08:00
22555079f3 feat: graph-log-villages support dateFrom/dateTo + recent-village-logs endpoint 2026-05-25 15:08:30 +08:00
6cf6486172 feat: tambah endpoint GET /api-keys/:id untuk ambil full key 2026-05-25 12:00:53 +08:00
35e51028db Merge pull request 'amalia/22-mei-26' (#52) from amalia/22-mei-26 into join
Reviewed-on: #52
2026-05-22 17:41:21 +08:00
37ea4e37e7 bump: version 0.1.17 + migration 2026-05-22 14:46:33 +08:00
e270db3bfa feat: add range param to daily-activity and comparison-activity endpoints
Both endpoints now accept ?range=7|30|90 (default 7).
comparison-activity result now follows SQL ORDER BY instead of being
remapped through villages array.
2026-05-22 14:16:36 +08:00
32dac32532 feat: add village and date range filter on /log-all-villages endpoint 2026-05-22 11:37:42 +08:00
d369a71eb6 feat: add filter and orderBy support on /user monitoring endpoint 2026-05-22 11:17:42 +08:00
7334831d61 Merge pull request 'bump: version 0.1.16 + migration' (#51) from amalia/21-mei-26 into join
Reviewed-on: #51
2026-05-21 17:25:52 +08:00
c0a4d584af bump: version 0.1.16 + migration 2026-05-21 11:07:23 +08:00
9ac105e7bc Merge pull request 'amalia/20-mei-26' (#50) from amalia/20-mei-26 into join
Reviewed-on: #50
2026-05-20 17:22:20 +08:00
10457e96e8 feat: tambah autentikasi x-api-key pada NOC API dan ekstrak isValidApiKey ke shared lib 2026-05-20 12:23:38 +08:00
9ad934c99f bump: version 0.1.15 + migration 2026-05-19 16:05:35 +08:00
5bfcde32ed Merge pull request 'amalia/18-mei-26' (#49) from amalia/18-mei-26 into join
Reviewed-on: #49
2026-05-18 17:26:42 +08:00
8240d608ad feat: tambah field isApprover pada endpoint get & edit user 2026-05-18 16:42:33 +08:00
fd7d08d38a bump: version 0.1.14 + migration 2026-05-18 15:15:07 +08:00
b95fd9543c feat: filter approver berdasarkan group pada project dan division task
- project/task approval: filter isApprover berdasarkan desa + group project
- project/task approval: supadmin tetap hanya filter desa
- division/task approval: expose idGroup dari Division pada response cat=data
- division/task approval: filter isApprover berdasarkan desa + group division
- division/task approval PUT: ganti getApproverStatus dengan cek langsung
  berdasarkan village, group, dan keanggotaan division admin
2026-05-18 14:52:38 +08:00
7622c58ce4 Merge pull request 'amalia/15-mei-26' (#48) from amalia/15-mei-26 into join
Reviewed-on: #48
2026-05-15 14:20:52 +08:00
d1b90b63e9 bump: version 0.1.13 + migration 2026-05-15 11:16:51 +08:00
387a86f17e bump: version 0.1.12 + migration 2026-05-15 11:00:40 +08:00
b749b333f6 Merge pull request 'upd: api jenna perangkat desa' (#47) from amalia/13-mei-26 into join
Reviewed-on: #47
2026-05-13 17:25:21 +08:00
ac6db48a5a upd: api jenna perangkat desa
Deskripsi:
- api yg akan diakses oleh jenna perangkat desa
- struktur api keys
- migrasi database

No Issues
2026-05-13 17:22:50 +08:00
d8e17340aa Merge pull request 'amalia/12-mei-26' (#46) from amalia/12-mei-26 into join
Reviewed-on: #46
2026-05-12 17:26:10 +08:00
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
1f408e31c2 fix: ubah format tanggal tugas dari DD-MM-YYYY menjadi DD MMM YYYY
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:24:46 +08:00
be0cd94d8d feat: tambah API lampiran file pada tugas kegiatan dan tugas divisi 2026-05-06 12:32:34 +08:00
2b71c729ad feat: tambah model ProjectTaskFile dan DivisionProjectTaskFile
Menambahkan relasi file ke task pada project dan division project.
2026-05-06 10:54:12 +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
28a536ae17 bump: version 0.1.10 + migration 2026-05-04 15:40:29 +08:00
48f73b627d chore: setup MCP deploy-stg + dokumentasi deployment 2026-05-04 15:40:05 +08:00
6b4dd91e0b bump: version 0.1.9 + migration 2026-05-04 14:49:57 +08:00
f2793a7c70 bump: version 0.1.8 + migration 2026-05-04 14:43:01 +08:00
59 changed files with 3889 additions and 2698 deletions

View File

@@ -3,3 +3,37 @@
Docker images are built via `.github/workflows/publish.yml` and pushed to GHCR (`ghcr.io`). Portainer redeploys via `.github/workflows/re-pull.yml`. Supports `dev`, `stg`, and `prod` stacks.
The Dockerfile uses a two-stage build: Bun builder → Bun runner (non-root user, port 3000).
## Git Remote Structure
| Remote | URL | Purpose |
|--------|-----|---------|
| `origin` | wibugit.wibudev.com/wibu/sistem-desa-mandiri | Repo kerja tim |
| `build` | github.com/bipprojectbali/desa-plus | Repo deployment (trigger CI/CD) |
**Branch mapping:**
- `origin/staging` — branch integrasi tim (bukan deployment target)
- `build/stg` — branch deployment stg (trigger publish image + Portainer repull)
- `build/prod` — branch deployment prod
- `build/dev` — branch deployment dev
## Deploy to STG Flow
Cukup jalankan MCP `deploy-stg` — handles otomatis: cek migrasi → bump version → commit → push ke `build/stg` → trigger publish workflow (`ref: stg`) → tunggu selesai → trigger repull Portainer → verify version via `BASE_URL${VERSION_PATH}`.
> `origin` tidak punya branch `stg` (hanya `staging`). "stg" selalu merujuk ke `build/stg`.
## MCP `deploy-stg`
Lokasi: `.mcp/deploy-stg/server.ts`. Berkomunikasi langsung dengan GitHub REST API (tidak butuh `gh` CLI), hanya perlu `git` & `prisma` lokal.
**Env vars** (di `.mcp.json` atau `.env`):
- `GH_TOKEN` — PAT dengan scope `repo` + `workflow` untuk trigger Actions
- `GH_URL` — repo build target, format `owner/repo` atau full URL
- `BASE_URL` — base URL stg untuk verifikasi versi
- `VERSION_PATH` — endpoint cek versi (default `/api/version-app`)
- `STACK_NAME` — nama stack Portainer
**Tools:** `deploy`, `publish`, `repull`, `run_status`, `check_version`.
**Penting:** workflow `publish.yml` & `re-pull.yml` di-trigger dengan `ref: stg` agar `actions/checkout@v4` checkout dari branch `stg`, bukan default branch (`main`).

View File

@@ -10,3 +10,15 @@ Copy `.env.example` to `.env`. Required variables:
| `WS_APIKEY` | WebSocket/file storage API key |
| `WIBU_REALTIME_KEY` | Real-time communication |
| `FCM_KEY` | Firebase Cloud Messaging |
## Deployment (MCP `deploy-stg`)
Diisi di `.env` lokal (jangan commit `GH_TOKEN`). `.mcp.json` me-reference via `${GH_TOKEN}`.
| Variable | Purpose |
|---|---|
| `GH_TOKEN` | GitHub PAT dengan scope `repo` + `workflow` |
| `GH_URL` | Repo build target (`owner/repo` atau full URL) |
| `BASE_URL` | Base URL deployment stg (untuk verifikasi versi) |
| `VERSION_PATH` | Endpoint cek versi (default `/api/version-app`) |
| `STACK_NAME` | Nama stack di Portainer |

View File

@@ -48,6 +48,12 @@ WS_APIKEY="your-websocket-api-key"
# API key untuk akses endpoint /api/monitoring (header: x-api-key)
MONITORING_API_KEY="your-monitoring-api-key"
# ===========================================
# AI API
# ===========================================
# API key untuk akses endpoint /api/ai/* (header: x-api-key)
AI_API_KEY="your-ai-api-key"
# ===========================================
# APPLICATION SETTINGS
# ===========================================

View File

@@ -5,9 +5,13 @@
"command": "bun",
"args": ["run", ".mcp/deploy-stg/server.ts"],
"env": {
"GH_TOKEN": "${GH_TOKEN}",
"GH_URL": "bipprojectbali/desa-plus",
"BASE_URL": "https://desa-plus-stg.wibudev.com",
"VERSION_PATH": "/api/version-app",
"STACK_NAME": "desa-plus"
}
}
}
}

View File

@@ -4,24 +4,127 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execFileSync, execSync } from "child_process";
import { execFileSync } from "child_process";
import { readFileSync, writeFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
const REPO = "bipprojectbali/desa-plus";
const STACK_ENV = "stg";
const BASE_URL = process.env.BASE_URL ?? "";
const VERSION_PATH = process.env.VERSION_PATH ?? "/api/version-app";
const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? "";
const GH_TOKEN = process.env.GH_TOKEN ?? "";
const GH = (args: string[]) =>
execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
const GH_URL_RAW = process.env.GH_URL ?? "";
// support both "owner/repo" and "https://github.com/owner/repo" formats
const REPO = GH_URL_RAW.startsWith("http")
? GH_URL_RAW.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "")
: GH_URL_RAW;
const GIT = (args: string[]) =>
execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
// --- GitHub API client (no gh CLI) ---
const GH_API = "https://api.github.com";
type WorkflowRun = {
id: number;
name: string;
status: string;
conclusion: string | null;
html_url: string;
created_at: string;
run_started_at: string;
};
async function ghFetch<T = unknown>(
pathname: string,
init: RequestInit = {}
): Promise<T> {
if (!GH_TOKEN) throw new Error("GH_TOKEN tidak di-set.");
if (!REPO) throw new Error("GH_URL tidak di-set.");
const res = await fetch(`${GH_API}${pathname}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${GH_TOKEN}`,
"X-GitHub-Api-Version": "2022-11-28",
...(init.body ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`);
}
// 204 No Content (e.g. workflow dispatch)
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
async function triggerWorkflow(
workflow: string,
ref: string,
inputs: Record<string, string>
): Promise<void> {
await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, {
method: "POST",
body: JSON.stringify({ ref, inputs }),
});
}
async function getLatestRun(workflow: string): Promise<WorkflowRun> {
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(
`/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=1`
);
if (!data.workflow_runs?.length) {
throw new Error(`Tidak ada run untuk workflow ${workflow}.`);
}
return data.workflow_runs[0];
}
async function listRuns(
workflow: string | "all",
limit: number
): Promise<WorkflowRun[]> {
const url =
workflow === "all"
? `/repos/${REPO}/actions/runs?per_page=${limit}`
: `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=${limit}`;
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(url);
return data.workflow_runs ?? [];
}
async function waitForRun(
runId: number,
timeoutMs = 30 * 60 * 1000
): Promise<WorkflowRun> {
const interval = 10_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await ghFetch<WorkflowRun>(
`/repos/${REPO}/actions/runs/${runId}`
);
if (run.status === "completed") {
if (run.conclusion !== "success") {
throw new Error(
`Run ${runId} selesai dengan conclusion: ${run.conclusion}`
);
}
return run;
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`Timeout menunggu run ${runId}.`);
}
// --- version helpers ---
function bumpVersion(version: string, type: "patch" | "minor" | "major"): string {
@@ -48,7 +151,7 @@ function applyVersionBump(newVersion: string): void {
async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise<string> {
if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg.";
const url = `${BASE_URL}/api/version-app`;
const url = `${BASE_URL}${VERSION_PATH}`;
const interval = 15_000;
const maxAttempts = Math.ceil(timeoutMs / interval);
let last = "";
@@ -199,54 +302,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]);
GIT(["push", "build", `${currentBranch}:stg`, "--force"]);
// 4. Trigger publish
GH([
"workflow", "run", "publish.yml",
"--repo", REPO,
"--field", `stack_env=${STACK_ENV}`,
"--field", `tag=${newVersion}`,
]);
// 4. Trigger publish workflow
await triggerWorkflow("publish.yml", STACK_ENV, {
stack_env: STACK_ENV,
tag: newVersion,
});
await new Promise((r) => setTimeout(r, 4000));
const publishRunId = GH([
"run", "list", "--repo", REPO,
"--workflow", "publish.yml",
"--limit", "1",
"--json", "databaseId",
"--jq", ".[0].databaseId",
]);
const publishUrl = GH([
"run", "list", "--repo", REPO,
"--workflow", "publish.yml",
"--limit", "1",
"--json", "url",
"--jq", ".[0].url",
]);
const publishRun = await getLatestRun("publish.yml");
// 5. Wait for publish to finish
execSync(`gh run watch ${publishRunId} --repo ${REPO} --exit-status`, {
encoding: "utf-8",
cwd: PROJECT_ROOT,
timeout: 30 * 60 * 1000,
stdio: "pipe",
});
await waitForRun(publishRun.id);
// 6. Trigger repull
GH([
"workflow", "run", "re-pull.yml",
"--repo", REPO,
"--field", `stack_name=${stack_name}`,
"--field", `stack_env=${STACK_ENV}`,
]);
await triggerWorkflow("re-pull.yml", STACK_ENV, {
stack_name,
stack_env: STACK_ENV,
});
await new Promise((r) => setTimeout(r, 4000));
const repullUrl = GH([
"run", "list", "--repo", REPO,
"--workflow", "re-pull.yml",
"--limit", "1",
"--json", "url",
"--jq", ".[0].url",
]);
const repullRun = await getLatestRun("re-pull.yml");
// 7. Wait for repull, then verify version
await new Promise((r) => setTimeout(r, 30_000));
@@ -260,8 +335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
type: "text",
text: [
`Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`,
`Publish run : ${publishUrl}`,
`Repull run : ${repullUrl}`,
`Publish run : ${publishRun.html_url}`,
`Repull run : ${repullRun.html_url}`,
``,
`Versi lokal : ${localVer}`,
versionCheck,
@@ -275,24 +350,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (name === "publish") {
const tag = readPkgVersion();
GH([
"workflow", "run", "publish.yml",
"--repo", REPO,
"--field", `stack_env=${STACK_ENV}`,
"--field", `tag=${tag}`,
]);
await triggerWorkflow("publish.yml", STACK_ENV, {
stack_env: STACK_ENV,
tag,
});
await new Promise((r) => setTimeout(r, 3000));
const runUrl = GH([
"run", "list", "--repo", REPO,
"--workflow", "publish.yml",
"--limit", "1",
"--json", "url",
"--jq", ".[0].url",
]);
const run = await getLatestRun("publish.yml");
return {
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }],
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${run.html_url}` }],
};
}
@@ -302,24 +369,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const stack_name = _sn || DEFAULT_STACK_NAME;
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
GH([
"workflow", "run", "re-pull.yml",
"--repo", REPO,
"--field", `stack_name=${stack_name}`,
"--field", `stack_env=${STACK_ENV}`,
]);
await triggerWorkflow("re-pull.yml", STACK_ENV, {
stack_name,
stack_env: STACK_ENV,
});
await new Promise((r) => setTimeout(r, 3000));
const runUrl = GH([
"run", "list", "--repo", REPO,
"--workflow", "re-pull.yml",
"--limit", "1",
"--json", "url",
"--jq", ".[0].url",
]);
const run = await getLatestRun("re-pull.yml");
return {
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }],
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${run.html_url}` }],
};
}
@@ -329,17 +388,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
workflow?: string;
limit?: number;
};
const workflowArgs = workflow === "all" ? [] : ["--workflow", workflow];
const output = GH([
"run", "list",
"--repo", REPO,
...workflowArgs,
"--limit", String(limit),
"--json", "workflowName,status,conclusion,startedAt,url,databaseId",
"--jq",
'.[] | "[\(.status)/\(.conclusion // "-")] \(.workflowName) — \(.startedAt)\n \(.url)"',
]);
const runs = await listRuns(workflow, limit);
const output = runs
.map(
(r) =>
`[${r.status}/${r.conclusion ?? "-"}] ${r.name}${r.run_started_at}\n ${r.html_url}`
)
.join("\n");
return {
content: [{ type: "text", text: output || "Tidak ada run ditemukan." }],
@@ -353,7 +409,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (BASE_URL) {
try {
const res = await fetch(`${BASE_URL}/api/version-app`);
const res = await fetch(`${BASE_URL}${VERSION_PATH}`);
const data = (await res.json()) as { version?: string };
stgVersion = data.version ?? "?";
} catch (e) {
@@ -371,7 +427,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
type: "text",
text: [
`Lokal (package.json) : ${localVersion}`,
`Stg (/api/version-app): ${stgVersion}`,
`Stg (${VERSION_PATH}): ${stgVersion}`,
`Status : ${match}`,
].join("\n"),
},

BIN
erd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

214
generate_erd.py Normal file
View File

@@ -0,0 +1,214 @@
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import matplotlib.patheffects as pe
# ── colour palette ──────────────────────────────────────────────────────────
C = {
"admin": "#4A90D9",
"user": "#2ECC71",
"village": "#E74C3C",
"announce": "#F39C12",
"project": "#9B59B6",
"division": "#1ABC9C",
"discuss": "#E67E22",
"other": "#95A5A6",
"new": "#E74C3C",
}
GROUPS = {
"Admin": (["AdminRole","Admin"], C["admin"]),
"User": (["UserRole","User","TokenDeviceUser","UserLog"],C["user"]),
"Village": (["Village","ColorTheme","BannerImage"], C["village"]),
"Announcement": (["Announcement","AnnouncementMember",
"AnnouncementFile"], C["announce"]),
"Project": (["Project","ProjectMember","ProjectFile",
"ProjectLink","ProjectTask","ProjectTaskFile",
"ProjectTaskDetail"], C["project"]),
"Division": (["Division","DivisionMember","DivisionProject",
"DivisionProjectMember","DivisionProjectFile",
"DivisionProjectLink",
"DivisionProjectTask","DivisionProjectTaskFile",
"DivisionProjectTaskDetail",
"DivisionDisscussion","DivisionDisscussionComment",
"DivisionDiscussionFile",
"DivisionDocumentFolderFile","DivisionDocumentShare",
"DivisionCalendar","DivisionCalendarReminder",
"DivisionCalendarMember",
"ContainerFileDivision"], C["division"]),
"Discussion": (["Discussion","DiscussionMember",
"DiscussionComment","DiscussionFile"], C["discuss"]),
"Other": (["Group","Position","Notifications",
"Subscribe","Setting","ContainerImage"], C["other"]),
}
NEW_MODELS = {"ProjectTaskFile", "DivisionProjectTaskFile"}
# ── relations (src, dst, label) ─────────────────────────────────────────────
RELATIONS = [
# Admin
("AdminRole","Admin","1-N"),
# User
("UserRole","User","1-N"),
("Village","User","1-N"),
("Group","User","1-N"),
("Position","User","0..1-N"),
# Village
("Village","Group","1-N"),
("Village","Announcement","1-N"),
("Village","Project","1-N"),
("Village","Division","1-N"),
("Village","Discussion","1-N"),
("Village","ColorTheme","1-N"),
("Village","BannerImage","1-N"),
# Group
("Group","Position","1-N"),
("Group","Project","1-N"),
("Group","Division","1-N"),
("Group","AnnouncementMember","1-N"),
("Group","Discussion","1-N"),
# Announcement
("Announcement","AnnouncementMember","1-N"),
("Announcement","AnnouncementFile","1-N"),
("Division","AnnouncementMember","1-N"),
# Project
("Project","ProjectMember","1-N"),
("Project","ProjectFile","1-N"),
("Project","ProjectLink","1-N"),
("Project","ProjectTask","1-N"),
("ProjectTask","ProjectTaskDetail","1-N"),
("ProjectTask","ProjectTaskFile","1-N"),
("ProjectFile","ProjectTaskFile","1-N"),
# Division
("Division","DivisionMember","1-N"),
("Division","DivisionProject","1-N"),
("DivisionProject","DivisionProjectMember","1-N"),
("DivisionProject","DivisionProjectFile","1-N"),
("DivisionProject","DivisionProjectLink","1-N"),
("DivisionProject","DivisionProjectTask","1-N"),
("DivisionProjectTask","DivisionProjectTaskDetail","1-N"),
("DivisionProjectTask","DivisionProjectTaskFile","1-N"),
("DivisionProjectFile","DivisionProjectTaskFile","1-N"),
("ContainerFileDivision","DivisionProjectFile","1-N"),
("Division","DivisionDisscussion","1-N"),
("DivisionDisscussion","DivisionDisscussionComment","1-N"),
("DivisionDisscussion","DivisionDiscussionFile","1-N"),
("Division","DivisionDocumentFolderFile","1-N"),
("DivisionDocumentFolderFile","DivisionDocumentShare","1-N"),
("Division","DivisionCalendar","1-N"),
("DivisionCalendar","DivisionCalendarReminder","1-N"),
("DivisionCalendar","DivisionCalendarMember","1-N"),
# Discussion
("Discussion","DiscussionMember","1-N"),
("Discussion","DiscussionComment","1-N"),
("Discussion","DiscussionFile","1-N"),
# Other
("User","Notifications","1-N"),
("User","Subscribe","1-1"),
("User","TokenDeviceUser","1-N"),
("User","UserLog","1-N"),
]
# ── layout: group boxes ──────────────────────────────────────────────────────
# (x, y, w, h) in data coordinates (canvas = 0..100 x 0..100)
LAYOUT = {
"Admin": ( 1, 88, 18, 10),
"User": ( 1, 68, 22, 18),
"Village": (26, 88, 22, 10),
"Other": (51, 88, 22, 10),
"Announcement": (76, 80, 22, 18),
"Project": ( 1, 2, 38, 48),
"Division": (41, 2, 38, 64),
"Discussion": (81, 2, 17, 30),
}
def group_center(gname):
x,y,w,h = LAYOUT[gname]
return x+w/2, y+h/2
def model_pos(model):
for gname,(models,_) in GROUPS.items():
if model in models:
x,y,w,h = LAYOUT[gname]
idx = models.index(model)
n = len(models)
cols = max(1, min(3, n))
rows = (n + cols - 1) // cols
col = idx % cols
row = idx // cols
mx = x + 1.5 + col * (w-2) / cols
my = y + h - 2.5 - row * (h-1.5) / rows
return mx, my
return 50, 50
# ── draw ─────────────────────────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(28, 22))
ax.set_xlim(0, 100)
ax.set_ylim(0, 100)
ax.axis("off")
fig.patch.set_facecolor("#F0F4F8")
ax.set_facecolor("#F0F4F8")
ax.set_title("ERD Sistem Desa Mandiri", fontsize=20, fontweight="bold",
color="#2C3E50", pad=14)
# group boxes
for gname, (models, color) in GROUPS.items():
x,y,w,h = LAYOUT[gname]
rect = FancyBboxPatch((x,y), w, h,
boxstyle="round,pad=0.3",
linewidth=2, edgecolor=color,
facecolor=color+"22")
ax.add_patch(rect)
ax.text(x+w/2, y+h-0.6, gname, ha="center", va="top",
fontsize=9, fontweight="bold", color=color)
# model nodes
for gname, (models, color) in GROUPS.items():
for m in models:
mx, my = model_pos(m)
is_new = m in NEW_MODELS
fc = "#FFECEC" if is_new else "white"
ec = C["new"] if is_new else color
lw = 2.5 if is_new else 1.5
node = FancyBboxPatch((mx-3.2, my-0.85), 6.4, 1.7,
boxstyle="round,pad=0.2",
linewidth=lw, edgecolor=ec, facecolor=fc)
ax.add_patch(node)
fw = "bold" if is_new else "normal"
ax.text(mx, my, m, ha="center", va="center",
fontsize=6.2, color="#2C3E50", fontweight=fw)
# relations
drawn = set()
for src, dst, lbl in RELATIONS:
key = tuple(sorted([src,dst]))
sx, sy = model_pos(src)
dx, dy = model_pos(dst)
color = "#BDC3C7"
is_new_rel = src in NEW_MODELS or dst in NEW_MODELS
if is_new_rel:
color = C["new"]
ax.annotate("", xy=(dx,dy), xytext=(sx,sy),
arrowprops=dict(arrowstyle="-|>", color=color,
lw=1.5 if is_new_rel else 0.8,
connectionstyle="arc3,rad=0.05"))
if key not in drawn:
mx2, my2 = (sx+dx)/2, (sy+dy)/2
ax.text(mx2, my2+0.4, lbl, ha="center", va="bottom",
fontsize=4.5, color=color, alpha=0.85)
drawn.add(key)
# legend
leg_items = [
mpatches.Patch(facecolor="#FFECEC", edgecolor=C["new"], linewidth=2,
label="Model Baru"),
mpatches.Patch(facecolor="white", edgecolor="#BDC3C7", label="Model Lama"),
]
ax.legend(handles=leg_items, loc="lower right", fontsize=9,
framealpha=0.9, edgecolor="#BDC3C7")
out = "/Users/wibu04/Documents/Projects/sistem-desa-mandiri/erd.png"
plt.savefig(out, dpi=150, bbox_inches="tight", facecolor=fig.get_facecolor())
plt.close()
print("Saved:", out)

View File

@@ -1,12 +1,13 @@
{
"name": "sistem-desa-mandiri",
"version": "0.1.7",
"version": "0.1.18",
"private": true,
"scripts": {
"dev": "next dev --experimental-https",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"claude": "set -a && source .env && set +a && claude"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "ProjectTaskFile" (
"id" TEXT NOT NULL,
"idTask" TEXT NOT NULL,
"idFile" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTaskFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectTaskFile" (
"id" TEXT NOT NULL,
"idTask" TEXT NOT NULL,
"idFile" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectTaskFile_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ProjectTaskFile" ADD CONSTRAINT "ProjectTaskFile_idTask_fkey" FOREIGN KEY ("idTask") REFERENCES "ProjectTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTaskFile" ADD CONSTRAINT "ProjectTaskFile_idFile_fkey" FOREIGN KEY ("idFile") REFERENCES "ProjectFile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTaskFile" ADD CONSTRAINT "DivisionProjectTaskFile_idTask_fkey" FOREIGN KEY ("idTask") REFERENCES "DivisionProjectTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTaskFile" ADD CONSTRAINT "DivisionProjectTaskFile_idFile_fkey" FOREIGN KEY ("idFile") REFERENCES "DivisionProjectFile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

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

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"key" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
@@ -232,15 +237,16 @@ model ProjectMember {
}
model ProjectFile {
id String @id @default(cuid())
Project Project @relation(fields: [idProject], references: [id])
idProject String
name String
extension String
idStorage String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
Project Project @relation(fields: [idProject], references: [id])
idProject String
name String
extension String
idStorage String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ProjectTaskFile ProjectTaskFile[]
}
model ProjectLink {
@@ -259,14 +265,27 @@ 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[]
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 {
id String @id @default(cuid())
ProjectTask ProjectTask @relation(fields: [idTask], references: [id])
idTask String
ProjectFile ProjectFile @relation(fields: [idFile], references: [id])
idFile String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProjectTaskDetail {
@@ -360,14 +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[]
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 {
@@ -397,18 +418,30 @@ model DivisionProjectMember {
}
model DivisionProjectFile {
id String @id @default(cuid())
Division Division @relation(fields: [idDivision], references: [id])
idDivision String
DivisionProject DivisionProject @relation(fields: [idProject], references: [id])
idProject String
ContainerFileDivision ContainerFileDivision @relation(fields: [idFile], references: [id])
idFile String
isActive Boolean @default(true)
User User @relation(fields: [createdBy], references: [id])
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
Division Division @relation(fields: [idDivision], references: [id])
idDivision String
DivisionProject DivisionProject @relation(fields: [idProject], references: [id])
idProject String
ContainerFileDivision ContainerFileDivision @relation(fields: [idFile], references: [id])
idFile String
isActive Boolean @default(true)
User User @relation(fields: [createdBy], references: [id])
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
DivisionProjectTaskFile DivisionProjectTaskFile[]
}
model DivisionProjectTaskFile {
id String @id @default(cuid())
DivisionProjectTask DivisionProjectTask @relation(fields: [idTask], references: [id])
idTask String
DivisionProjectFile DivisionProjectFile @relation(fields: [idFile], references: [id])
idFile String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DivisionDisscussion {
@@ -668,3 +701,40 @@ 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
}
model ApiKey {
id String @id @default(cuid())
name String
key String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,95 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ONE PENGUMUMAN, UNTUK TAMPIL DETAIL PENGUMUMAN
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const data = await prisma.announcement.count({
where: {
id: id,
},
});
if (data == 0) {
return NextResponse.json(
{
success: false,
message: "Gagal mendapatkan pengumuman, data tidak ditemukan",
},
{ status: 404 }
);
}
const announcement = await prisma.announcement.findUnique({
where: {
id: id,
},
select: {
id: true,
title: true,
desc: true,
},
});
if (!announcement) {
return NextResponse.json(
{
success: false,
message: "Gagal mendapatkan pengumuman, data tidak ditemukan",
},
{ status: 404 }
);
}
let dataFix = { ...announcement, member: {} };
const announcementMember = await prisma.announcementMember.findMany({
where: {
idAnnouncement: id,
},
select: {
idGroup: true,
idDivision: true,
Group: {
select: {
name: true,
},
},
Division: {
select: {
name: true,
},
},
},
});
const formatMember = announcementMember.map((v: any) => ({
..._.omit(v, ["Group", "Division"]),
idGroup: v.idGroup,
idDivision: v.idDivision,
group: v.Group.name,
division: v.Division.name
}))
dataFix.member = formatMember
return NextResponse.json(
{
success: true,
message: "Berhasil mendapatkan pengumuman",
data: dataFix,
},
{ status: 200 }
);
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan pengumuman, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,52 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import "moment/locale/id";
import { NextResponse } from "next/server";
export const dynamic = 'force-dynamic'
// GET ALL PENGUMUMAN
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const judul = searchParams.get('search');
const page = searchParams.get('page');
const get = searchParams.get('get');
const villageId = searchParams.get('desa');
const active = searchParams.get('active');
let getFix = 0;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
idVillage: String(villageId),
isActive: (active == "false" || active == undefined) ? false : true,
title: {
contains: (judul == undefined || judul == null) ? "" : judul,
mode: "insensitive"
}
}
const data = await prisma.announcement.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json({ success: true, message: "Berhasil mendapatkan pengumuman", data, }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan pengumuman, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,21 +0,0 @@
import { prisma } from "@/module/_global";
import { NextResponse } from "next/server";
// GET ONE BANNER
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const data = await prisma.bannerImage.findUnique({
where: {
id: String(id)
}
})
return NextResponse.json({ success: true, message: "Berhasil mendapatkan banner", data }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan banner, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,48 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ALL BANNER
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const judul = searchParams.get('search');
const page = searchParams.get('page');
const get = searchParams.get('get');
const villageId = searchParams.get('desa');
const active = searchParams.get('active');
let getFix = 0;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
idVillage: String(villageId),
isActive: (active == "false" || active == undefined) ? false : true,
title: {
contains: (judul == undefined || judul == null) ? "" : judul,
mode: "insensitive"
}
}
const data = await prisma.bannerImage.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json({ success: true, message: "Berhasil mendapatkan banner", data }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan data banner, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,99 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import moment from "moment";
import { NextResponse } from "next/server";
// GET ONE CALENDER BY ID KALENDER REMINDER
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params
const cek = await prisma.divisionCalendarReminder.count({
where: {
id: id
}
})
if (cek == 0) {
return NextResponse.json(
{
success: false,
message: "Gagal mendapatkan acara, data tidak ditemukan",
},
{ status: 404 }
);
}
const data: any = await prisma.divisionCalendarReminder.findUnique({
where: {
id: id
},
select: {
id: true,
timeStart: true,
dateStart: true,
timeEnd: true,
createdAt: true,
DivisionCalendar: {
select: {
id: true,
title: true,
desc: true,
linkMeet: true,
repeatEventTyper: true,
repeatValue: true,
}
}
}
});
const { DivisionCalendar, ...dataCalender } = data
const timeStart = moment.utc(dataCalender?.timeStart).format("HH:mm")
const timeEnd = moment.utc(dataCalender?.timeEnd).format("HH:mm")
const idCalendar = data?.DivisionCalendar.id
const title = data?.DivisionCalendar?.title
const desc = data?.DivisionCalendar?.desc
const linkMeet = data?.DivisionCalendar?.linkMeet
const repeatEventTyper = data?.DivisionCalendar?.repeatEventTyper
const repeatValue = data?.DivisionCalendar?.repeatValue
const result = { ...dataCalender, timeStart, timeEnd, title, desc, linkMeet, repeatEventTyper, repeatValue }
const member = await prisma.divisionCalendarMember.findMany({
where: {
idCalendar
},
select: {
id: true,
idUser: true,
User: {
select: {
id: true,
name: true,
email: true,
img: true
}
}
}
})
const fixMember = member.map((v: any) => ({
..._.omit(v, ["User"]),
name: v.User.name,
email: v.User.email,
img: v.User.img
}))
const dataFix = {
...result,
member: fixMember,
}
return NextResponse.json({ success: true, message: "Berhasil mendapatkan kalender", data: dataFix }, { status: 200 });
} catch (error) {
return NextResponse.json({ success: false, message: "Gagal mendapatkan kalender, data tidak ditemukan (error: 500)", }, { status: 500 });
}
}

View File

@@ -1,149 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import moment from "moment";
import "moment/locale/id";
import { NextResponse } from "next/server";
//GET ALL CALENDER
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idDivision = searchParams.get("division");
const isDate = searchParams.get("date")
const villageId = searchParams.get("desa")
const active = searchParams.get("active")
const search = searchParams.get("search")
const page = searchParams.get("page")
const get = searchParams.get("get")
let getFix = 0;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {}
if (idDivision != "" && idDivision != null && idDivision != undefined) {
if (isDate != null && isDate != undefined && isDate != "") {
kondisi = {
idDivision: String(idDivision),
dateStart: new Date(String(isDate)),
DivisionCalendar: {
title: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
},
isActive: (active == "false" || active == undefined) ? false : true,
Division: {
idVillage: String(villageId)
}
}
}
} else {
kondisi = {
idDivision: String(idDivision),
DivisionCalendar: {
title: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
},
isActive: (active == "false" || active == undefined) ? false : true,
Division: {
idVillage: String(villageId)
}
}
}
}
} else {
if (isDate != null && isDate != undefined && isDate != "") {
kondisi = {
dateStart: new Date(String(isDate)),
DivisionCalendar: {
title: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
},
isActive: (active == "false" || active == undefined) ? false : true,
Division: {
idVillage: String(villageId)
}
}
}
} else {
kondisi = {
DivisionCalendar: {
title: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
},
isActive: (active == "false" || active == undefined) ? false : true,
Division: {
idVillage: String(villageId)
}
}
}
}
}
const data = await prisma.divisionCalendarReminder.findMany({
where: kondisi,
skip: dataSkip,
take: getFix,
select: {
id: true,
dateStart: true,
timeStart: true,
timeEnd: true,
createdAt: true,
DivisionCalendar: {
select: {
isActive: true,
title: true,
desc: true,
User: {
select: {
name: true
}
}
}
}
},
orderBy: [
{
dateStart: 'asc'
},
{
timeStart: 'asc'
},
{
timeEnd: 'asc'
}
]
});
const allOmit = data.map((v: any) => ({
..._.omit(v, ["DivisionCalendar", "User"]),
title: v.DivisionCalendar.title,
desc: v.DivisionCalendar.desc,
createdBy: v.DivisionCalendar.User.name,
isActive: v.DivisionCalendar.isActive,
timeStart: moment.utc(v.timeStart).format('HH:mm'),
timeEnd: moment.utc(v.timeEnd).format('HH:mm')
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan kalender", data: allOmit }, { status: 200 });
} catch (error) {
console.error(error)
return NextResponse.json({ success: false, message: "Gagal mendapatkan kalender, data tidak ditemukan (error: 500)" }, { status: 404 });
}
}

View File

@@ -1,117 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ONE DETAIL DISKUSI UMUM
export async function GET(request: Request, context: { params: { id: string } }) {
try {
let dataFix
const { id } = context.params
const { searchParams } = new URL(request.url);
const kategori = searchParams.get("cat");
const idVillage = searchParams.get("desa");
const cek = await prisma.discussion.count({
where: {
id,
idVillage: String(idVillage)
}
})
if (cek == 0) {
return NextResponse.json({ success: false, message: "Gagal mendapatkan diskusi, data tidak ditemukan" }, { status: 404 });
}
if (kategori == "comment") {
const data = await prisma.discussionComment.findMany({
where: {
idDiscussion: id,
isActive: true
},
select: {
id: true,
comment: true,
createdAt: true,
idUser: true,
User: {
select: {
name: true,
img: true
}
}
}
})
dataFix = data.map((v: any) => ({
..._.omit(v, ["User",]),
username: v.User.name,
img: v.User.img
}))
} else if (kategori == "member") {
const data = await prisma.discussionMember.findMany({
where: {
idDiscussion: id,
isActive: true
},
select: {
idUser: true,
User: {
select: {
name: true,
img: true
}
}
}
})
dataFix = data.map((v: any) => ({
..._.omit(v, ["User",]),
name: v.User.name,
img: v.User.img
}))
} else {
const data = await prisma.discussion.findUnique({
where: {
id,
idVillage: String(idVillage)
},
select: {
isActive: true,
id: true,
title: true,
idGroup: true,
desc: true,
status: true,
createdAt: true,
Group: {
select: {
name: true,
}
}
}
})
dataFix = {
id: data?.id,
isActive: data?.isActive,
idGroup: data?.idGroup,
group: data?.Group.name,
title: data?.title,
desc: data?.desc,
status: data?.status == 1 ? "Open" : "Close",
createdAt: data?.createdAt
}
}
return NextResponse.json({ success: true, message: "Berhasil mendapatkan diskusi", data: dataFix }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan diskusi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,92 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import "moment/locale/id";
import { NextResponse } from "next/server";
// GET ALL DISCUSSION GENERAL
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idGroup = searchParams.get("group");
const idVillage = searchParams.get("desa");
const search = searchParams.get('search');
const page = searchParams.get('page');
const status = searchParams.get('status');
const active = searchParams.get('active');
const get = searchParams.get('get')
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: active == "false" ? false : true,
status: status == "close" ? 2 : 1,
idVillage: String(idVillage),
title: {
contains: (search == undefined || search == "null") ? "" : search,
mode: "insensitive"
},
}
if (idGroup != "null" && idGroup != undefined && idGroup != "") {
kondisi = {
...kondisi,
idGroup: String(idGroup)
}
}
const data = await prisma.discussion.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
orderBy: [
{
status: 'desc'
},
{
createdAt: 'desc'
}
],
select: {
id: true,
title: true,
desc: true,
status: true,
createdAt: true,
DiscussionComment: {
select: {
id: true,
}
},
Group: {
select: {
name: true,
}
}
}
});
const fixData = data.map((v: any) => ({
..._.omit(v, ["DiscussionComment", "status", "Group"]),
totalKomentar: v.DiscussionComment.length,
status: v.status == 1 ? "Open" : "Close",
group: v.Group.name,
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan diskusi", data: fixData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan diskusi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,92 +0,0 @@
import { prisma } from "@/module/_global";
import { NextResponse } from "next/server";
// GET ONE DISCUSSION BY ID
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params
const cek = await prisma.divisionDisscussion.count({
where: { id }
})
if (cek == 0) {
return NextResponse.json(
{ success: false, message: "Gagal mendapatkan diskusi, data tidak ditemukan" },
{ status: 404 }
);
}
const data = await prisma.divisionDisscussion.findUnique({
where: { id },
select: {
isActive: true,
id: true,
desc: true,
status: true,
createdAt: true,
idDivision: true,
Division: {
select: {
name: true,
}
},
User: { select: { name: true, img: true } },
DivisionDisscussionComment: {
select: {
id: true,
comment: true,
createdAt: true,
User: { select: { name: true, img: true } }
}
},
}
});
if (!data) {
return NextResponse.json(
{ success: false, message: "Diskusi tidak ditemukan" },
{ status: 404 }
);
}
// ambil nama creator
const createdBy = data.User.name;
const status = data.status == 1 ? "Open" : "Close"
const division = data.Division.name
// mapping komentar → hilangkan nested User
const komentar = data.DivisionDisscussionComment.map((comment: any) => ({
id: comment.id,
comment: comment.comment,
createdAt: comment.createdAt,
username: comment.User.name,
userimg: comment.User.img,
}));
// bentuk hasil akhir sesuai request
const result = {
id: data.id,
idDivision: data.idDivision,
division,
isActive: data.isActive,
desc: data.desc,
status,
createdAt: data.createdAt,
createdBy,
komentar,
};
return NextResponse.json(
{ success: true, message: "Berhasil mendapatkan diskusi", data: result },
{ status: 200 }
);
} catch (error) {
console.error(error);
return NextResponse.json(
{ success: false, message: "Gagal mendapatkan diskusi, coba lagi nanti (error: 500)", reason: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,95 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import "moment/locale/id";
import { NextResponse } from "next/server";
// GET ALL DISCUSSION DIVISION ACTIVE = TRUE
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idDivision = searchParams.get("division");
const search = searchParams.get('search');
const page = searchParams.get('page');
const status = searchParams.get('status');
const isActive = searchParams.get('active');
const villageId = searchParams.get('desa');
const get = searchParams.get('get');
let getFix = 0;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: isActive == "false" ? false : true,
status: status == "close" ? 2 : 1,
Division: {
idVillage: String(villageId)
},
desc: {
contains: (search == undefined || search == "null") ? "" : search,
mode: "insensitive"
},
}
if (idDivision != "null" && idDivision != null && idDivision != undefined) {
kondisi = {
isActive: isActive == "false" ? false : true,
status: status == "close" ? 2 : 1,
idDivision: idDivision,
Division: {
idVillage: String(villageId)
},
desc: {
contains: (search == undefined || search == "null") ? "" : search,
mode: "insensitive"
},
}
}
const data = await prisma.divisionDisscussion.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
orderBy: {
createdAt: 'desc'
},
select: {
id: true,
desc: true,
status: true,
createdAt: true,
idDivision: true,
Division: {
select: {
name: true,
}
},
DivisionDisscussionComment: {
select: {
id: true,
}
}
}
});
const fixData = data.map((v: any) => ({
..._.omit(v, ["DivisionDisscussionComment", "status", "Division"]),
totalKomentar: v.DivisionDisscussionComment.length,
status: v.status == 1 ? "Open" : "Close",
division: v.Division.name
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan diskusi", data: fixData, }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan diskusi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,63 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ONE DATA DIVISI :: UNTUK TAMPIL DATA DI HALAMAN EDIT DAN INFO
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { searchParams } = new URL(request.url);
const idVillage = searchParams.get("desa");
const data = await prisma.division.findUnique({
where: {
id: String(id),
idVillage: String(idVillage)
}
});
if (!data) {
return NextResponse.json({ success: false, message: "Gagal mendapatkan divisi, data tidak ditemukan", }, { status: 404 });
}
const member = await prisma.divisionMember.findMany({
where: {
idDivision: String(id),
isActive: true,
},
select: {
id: true,
isAdmin: true,
idUser: true,
User: {
select: {
name: true,
img: true
}
}
},
orderBy: {
isAdmin: 'desc',
}
})
const fixMember = member.map((v: any) => ({
..._.omit(v, ["User"]),
name: v.User.name,
img: v.User.img
}))
const dataFix = {
...data,
member: fixMember
}
return NextResponse.json({ success: true, message: "Berhasil mendapatkan divisi", data: dataFix, }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan divisi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,269 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const idVillage = searchParams.get("desa")
const idGroup = searchParams.get("group")
const division = searchParams.get("division")
const date = searchParams.get("date-start")
const dateAkhir = searchParams.get("date-end")
const kat = searchParams.get("cat")
// CHART PROGRESS
if (kat == "dokumen") {
// CHART DOKUMEN
let kondisi: any = {
isActive: true,
category: 'FILE',
Division: {
idVillage: String(idVillage)
},
createdAt: {
gte: new Date(String(date)),
lte: new Date(String(dateAkhir))
},
}
if (idGroup != undefined && idGroup != null && idGroup != "") {
kondisi = {
isActive: true,
category: 'FILE',
Division: {
idGroup: String(idGroup)
},
createdAt: {
gte: new Date(String(date)),
lte: new Date(String(dateAkhir))
},
}
}
if (division != undefined && division != null && division != "") {
kondisi = {
...kondisi,
idDivision: String(division)
}
}
const dataDokumen = await prisma.divisionDocumentFolderFile.findMany({
where: kondisi,
})
const groupData = _.map(_.groupBy(dataDokumen, "extension"), (v: any) => ({
file: v[0].extension,
jumlah: v.length,
}))
const image = ['jpg', 'jpeg', 'png', 'heic']
let hasilImage = {
name: 'Gambar',
value: 0
}
let hasilFile = {
name: 'Dokumen',
value: 0
}
groupData.map((v: any) => {
if (image.some((i: any) => i == v.file)) {
hasilImage = {
name: 'Gambar',
value: hasilImage.value + v.jumlah
}
} else {
hasilFile = {
name: 'Dokumen',
value: hasilFile.value + v.jumlah
}
}
})
const hasilDokumen = { gambar: hasilImage.value, dokumen: hasilFile.value }
return NextResponse.json({ success: true, message: "Berhasil mendapatkan data", data: hasilDokumen }, { status: 200 });
} else if (kat == "event") {
// CHART EVENT
let kondisiSelesai: any = {
isActive: true,
Division: {
idVillage: String(idVillage)
},
DivisionCalendarReminder: {
some: {
dateStart: {
gte: new Date(String(date)),
lte: new Date()
}
}
}
}
let kondisiComingSoon: any = {
isActive: true,
Division: {
idVillage: String(idVillage)
},
DivisionCalendarReminder: {
some: {
dateStart: {
gt: new Date(),
lte: new Date(String(dateAkhir))
}
}
}
}
if (idGroup != undefined && idGroup != null && idGroup != "") {
kondisiSelesai = {
isActive: true,
Division: {
idGroup: String(idGroup)
},
DivisionCalendarReminder: {
some: {
dateStart: {
gte: new Date(String(date)),
lte: new Date()
}
}
}
}
kondisiComingSoon = {
isActive: true,
Division: {
idGroup: String(idGroup)
},
DivisionCalendarReminder: {
some: {
dateStart: {
gt: new Date(),
lte: new Date(String(dateAkhir))
}
}
}
}
}
if (division != undefined && division != null && division != "") {
kondisiSelesai = {
...kondisiSelesai,
idDivision: String(division)
}
kondisiComingSoon = {
...kondisiComingSoon,
idDivision: String(division)
}
}
const eventSelesai = await prisma.divisionCalendar.count({
where: kondisiSelesai
})
const eventComingSoon = await prisma.divisionCalendar.count({
where: kondisiComingSoon
})
const hasilEvent = {
selesai: eventSelesai,
akan_datang: eventComingSoon
}
return NextResponse.json({ success: true, message: "Berhasil mendapatkan data", data: hasilEvent }, { status: 200 });
} else {
let kondisiProgress: any = {
isActive: true,
Division: {
idVillage: String(idVillage)
},
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
},
dateEnd: {
lte: new Date(String(dateAkhir))
}
}
}
}
if (idGroup != undefined && idGroup != null && idGroup != "") {
kondisiProgress = {
isActive: true,
Division: {
idGroup: String(idGroup)
},
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
},
dateEnd: {
lte: new Date(String(dateAkhir))
}
}
}
}
}
if (division != undefined && division != null && division != "") {
kondisiProgress = {
...kondisiProgress,
idDivision: String(division)
}
}
const data = await prisma.divisionProject.groupBy({
where: kondisiProgress,
by: ["status"],
_count: true
})
const dataStatus = [{ name: 'Segera', status: 0 }, { name: 'Dikerjakan', status: 1 }, { name: 'Selesai', status: 2 }, { name: 'Dibatalkan', status: 3 }]
const hasilProgres: any[] = []
let input
for (let index = 0; index < dataStatus.length; index++) {
const cek = data.some((i: any) => i.status == dataStatus[index].status)
if (cek) {
const find = ((Number(data.find((i: any) => i.status == dataStatus[index].status)?._count) * 100) / data.reduce((n, { _count }) => n + _count, 0)).toFixed(2)
const fix = find != "100.00" ? find.substr(-2, 2) == "00" ? find.substr(0, 2) : find : "100"
input = {
name: dataStatus[index].name,
value: fix
}
} else {
input = {
name: dataStatus[index].name,
value: 0
}
}
hasilProgres.push(input)
}
const dataFixProgress = hasilProgres.reduce((acc: any, curr: any) => {
acc[curr.name] = curr.value
return acc
}, {})
return NextResponse.json({ success: true, message: "Berhasil mendapatkan data", data: dataFixProgress }, { status: 200 });
}
}
catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan data, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,84 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ALL DATA DIVISI == LIST DATA DIVISI
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idVillage = searchParams.get("desa");
const idGroup = searchParams.get("group");
const name = searchParams.get('search');
const page = searchParams.get('page');
const active = searchParams.get("active");
const get = searchParams.get('get')
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: active == 'false' ? false : true,
idVillage: String(idVillage),
name: {
contains: (name == undefined || name == "null") ? "" : name,
mode: "insensitive"
}
}
if (idGroup != "null" && idGroup != undefined && idGroup != "") {
kondisi = {
...kondisi,
idGroup: String(idGroup)
}
}
const data = await prisma.division.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
select: {
id: true,
name: true,
desc: true,
idGroup: true,
Group: {
select: {
name: true
}
},
DivisionMember: {
where: {
isActive: true
},
select: {
idUser: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
const allData = data.map((v: any) => ({
..._.omit(v, ["DivisionMember", "Group"]),
group: v.Group.name,
jumlahMember: v.DivisionMember.length,
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan divisi", data: allData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan divisi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,145 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import moment from "moment";
import { NextResponse } from "next/server";
// GET ALL DOCUMENT
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idDivision = searchParams.get("division");
const villageId = searchParams.get("desa");
const path = searchParams.get("path");
const active = searchParams.get("active");
const search = searchParams.get("search");
const page = searchParams.get("page");
const get = searchParams.get("get");
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
Division: {
idVillage: String(villageId)
},
isActive: active == 'false' ? false : true,
path: (path == "undefined" || path == "null" || path == "" || path == null || path == undefined) ? "home" : path,
name: {
contains: (search == undefined || search == "null") ? "" : search,
mode: "insensitive"
}
}
if (idDivision != "null" && idDivision != undefined && idDivision != "") {
kondisi = {
...kondisi,
idDivision: String(idDivision)
}
}
let formatDataShare: any[] = [];
if (path == "home" || path == "null" || path == "undefined" || path == null || path == undefined || path == "") {
const dataShare = await prisma.divisionDocumentShare.findMany({
where: {
isActive: true,
idDivision: String(idDivision),
DivisionDocumentFolderFile: {
isActive: true
}
},
select: {
DivisionDocumentFolderFile: {
select: {
idStorage: true,
id: true,
category: true,
name: true,
extension: true,
path: true,
User: {
select: {
name: true
}
},
createdAt: true,
updatedAt: true
}
}
},
orderBy: {
DivisionDocumentFolderFile: {
createdAt: 'desc'
}
}
})
formatDataShare = dataShare.map((v: any) => ({
..._.omit(v, ["DivisionDocumentFolderFile"]),
idStorage: v.DivisionDocumentFolderFile.idStorage,
id: v.DivisionDocumentFolderFile.id,
category: v.DivisionDocumentFolderFile.category,
name: v.DivisionDocumentFolderFile.name,
extension: v.DivisionDocumentFolderFile.extension,
path: v.DivisionDocumentFolderFile.path,
createdBy: v.DivisionDocumentFolderFile.User.name,
createdAt: v.DivisionDocumentFolderFile.createdAt,
updatedAt: v.DivisionDocumentFolderFile.updatedAt,
share: true
}))
}
const data = await prisma.divisionDocumentFolderFile.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
select: {
id: true,
category: true,
name: true,
extension: true,
idStorage: true,
path: true,
User: {
select: {
name: true
}
},
createdAt: true,
updatedAt: true
},
orderBy: {
createdAt: 'desc'
}
})
const allData = data.map((v: any) => ({
..._.omit(v, ["User", "createdAt", "updatedAt"]),
createdBy: v.User.name,
createdAt: v.createdAt,
updatedAt: v.updatedAt,
share: false
}))
if (formatDataShare.length > 0) {
allData.push(...formatDataShare)
}
const formatData = _.orderBy(allData, ['category', 'createdAt'], ['desc', 'desc']);
return NextResponse.json({ success: true, message: "Berhasil mendapatkan item", data: formatData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan item, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,45 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const villageId = searchParams.get("desa");
const isActive = searchParams.get("active");
const search = searchParams.get('search');
const page = searchParams.get('page')
const get = searchParams.get('get')
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
const data = await prisma.group.findMany({
skip: dataSkip,
take: getFix,
where: {
isActive: isActive == 'false' ? false : true,
idVillage: String(villageId),
name: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
}
},
orderBy: {
name: 'asc'
}
});
return NextResponse.json({ success: true, message: "Berhasil mendapatkan grup", data, }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan grup, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,79 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ALL POSITION
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idVillage = searchParams.get("desa");
const idGroup = searchParams.get("group");
const active = searchParams.get('active');
const search = searchParams.get('search')
const page = searchParams.get('page')
const get = searchParams.get('get')
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: active == 'false' ? false : true,
Group: {
idVillage: String(idVillage)
},
name: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
}
}
if (idGroup != "null" && idGroup != undefined && idGroup != "") {
kondisi = {
...kondisi,
idGroup: String(idGroup)
}
}
const positions = await prisma.position.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
select: {
id: true,
name: true,
idGroup: true,
isActive: true,
createdAt: true,
updatedAt: true,
Group: {
select: {
name: true
}
}
},
orderBy: {
name: 'asc'
}
});
const allData = positions.map((v: any) => ({
..._.omit(v, ["Group"]),
group: v.Group.name
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan jabatan", data: allData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan jabatan, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,172 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import moment from "moment";
import { NextResponse } from "next/server";
// GET DETAIL PROJECT / GET ONE PROJECT
export async function GET(request: Request, context: { params: { id: string } }) {
try {
let allData
const { id } = context.params;
const { searchParams } = new URL(request.url);
const kategori = searchParams.get("cat");
const data = await prisma.project.findUnique({
where: {
id: String(id),
},
select: {
id: true,
idVillage: true,
idGroup: true,
title: true,
status: true,
desc: true,
reason: true,
report: true,
isActive: true,
Group: {
select: {
name: true
}
}
}
});
if (!data) {
return NextResponse.json({ success: false, message: "Gagal mendapatkan kegiatan, data tidak ditemukan", }, { status: 404 });
}
if (kategori == "data") {
const dataProgress = await prisma.projectTask.findMany({
where: {
isActive: true,
idProject: String(id)
},
orderBy: {
updatedAt: 'desc'
}
})
const semua = dataProgress.length
const selesai = _.filter(dataProgress, { status: 1 }).length
const progress = Math.ceil((selesai / semua) * 100)
allData = {
id: data.id,
idVillage: data.idVillage,
idGroup: data.idGroup,
group: data.Group.name,
title: data.title,
status: data.status == 3 ? "batal" : data.status == 2 ? "selesai" : data.status == 1 ? "dikerjakan" : "segera",
desc: data.desc,
reason: data.reason,
report: data.report,
isActive: data.isActive,
progress: (_.isNaN(progress)) ? 0 : progress,
}
} else if (kategori == "task") {
const dataProgress = await prisma.projectTask.findMany({
where: {
isActive: true,
idProject: String(id)
},
select: {
id: true,
title: true,
desc: true,
status: true,
dateStart: true,
dateEnd: true,
createdAt: true
},
orderBy: {
createdAt: 'asc'
}
})
const formatData = dataProgress.map((v: any) => ({
..._.omit(v, ["dateStart", "dateEnd", "createdAt", "status"]),
status: v.status == 1 ? "selesai" : "belum selesai",
dateStart: moment(v.dateStart).format("DD-MM-YYYY"),
dateEnd: moment(v.dateEnd).format("DD-MM-YYYY"),
createdAt: moment(v.createdAt).format("DD-MM-YYYY HH:mm"),
}))
const dataFix = _.orderBy(formatData, 'createdAt', 'asc')
allData = dataFix
} else if (kategori == "file") {
const dataFile = await prisma.projectFile.findMany({
where: {
isActive: true,
idProject: String(id)
},
orderBy: {
createdAt: 'asc'
},
select: {
id: true,
name: true,
extension: true,
idStorage: true
}
})
allData = dataFile
} else if (kategori == "member") {
const dataMember = await prisma.projectMember.findMany({
where: {
isActive: true,
idProject: String(id)
},
select: {
id: true,
idUser: true,
User: {
select: {
name: true,
email: true,
img: true,
Position: {
select: {
name: true
}
}
}
},
}
})
const fix = dataMember.map((v: any) => ({
..._.omit(v, ["User"]),
name: v.User.name,
email: v.User.email,
img: v.User.img,
position: v.User.Position.name
}))
allData = fix
} else if (kategori == "link") {
const dataLink = await prisma.projectLink.findMany({
where: {
isActive: true,
idProject: String(id)
},
orderBy: {
createdAt: 'asc'
}
})
allData = dataLink
}
return NextResponse.json({ success: true, message: "Berhasil mendapatkan kegiatan", data: allData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan kegiatan, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,107 +0,0 @@
import { prisma } from "@/module/_global";
import _, { ceil } from "lodash";
import { NextResponse } from "next/server";
// GET ALL DATA PROJECT
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const idVillage = searchParams.get('desa');
const name = searchParams.get('search');
const status = searchParams.get('status');
const idGroup = searchParams.get("group");
const page = searchParams.get('page');
const get = searchParams.get('get');
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: true,
idVillage: String(idVillage),
title: {
contains: (name == undefined || name == "null") ? "" : name,
mode: "insensitive"
},
}
if (status != "null" && status != undefined && status != "") {
kondisi = {
...kondisi,
status: status == "segera" ? 0 : status == "dikerjakan" ? 1 : status == "selesai" ? 2 : status == "batal" ? 3 : 0
}
}
if (idGroup != "null" && idGroup != undefined && idGroup != "") {
kondisi = {
...kondisi,
idGroup: String(idGroup)
}
}
const data = await prisma.project.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
select: {
id: true,
idGroup: true,
title: true,
desc: true,
status: true,
ProjectMember: {
where: {
isActive: true
},
select: {
idUser: true
}
},
ProjectTask: {
where: {
isActive: true
},
select: {
title: true,
status: true
}
},
Group: {
select: {
name: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
const omitData = data.map((v: any) => ({
..._.omit(v, ["ProjectMember", "ProjectTask", "status", "Group"]),
group: v.Group.name,
status: v.status == 1 ? "dikerjakan" : v.status == 2 ? "selesai" : v.status == 3 ? "batal" : "segera",
progress: ceil((v.ProjectTask.filter((i: any) => i.status == 1).length * 100) / v.ProjectTask.length),
member: v.ProjectMember.length
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan kegiatan", data: omitData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan kegiatan, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,180 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import moment from "moment";
import "moment/locale/id";
import { NextResponse } from "next/server";
// GET DETAIL TASK DIVISI / GET ONE
export async function GET(request: Request, context: { params: { id: string } }) {
try {
let allData
const { id } = context.params;
const { searchParams } = new URL(request.url);
const kategori = searchParams.get("cat");
const data = await prisma.divisionProject.findUnique({
where: {
id: String(id),
},
select: {
id: true,
idDivision: true,
title: true,
status: true,
desc: true,
reason: true,
report: true,
isActive: true,
Division: {
select: {
name: true
}
}
}
});
if (!data) {
return NextResponse.json({ success: false, message: "Gagal mendapatkan kegiatan, data tidak ditemukan", }, { status: 404 });
}
if (kategori == "data") {
const dataProgress = await prisma.divisionProjectTask.findMany({
where: {
isActive: true,
idProject: String(id)
},
orderBy: {
updatedAt: 'desc'
}
})
const semua = dataProgress.length
const selesai = _.filter(dataProgress, { status: 1 }).length
const progress = Math.ceil((selesai / semua) * 100)
allData = {
id: data.id,
idDivision: data.idDivision,
division: data.Division.name,
title: data.title,
status: data.status == 3 ? "batal" : data.status == 2 ? "selesai" : data.status == 1 ? "dikerjakan" : "segera",
desc: data.desc,
reason: data.reason,
report: data.report,
isActive: data.isActive,
progress: progress,
}
} else if (kategori == "task") {
const dataProgress = await prisma.divisionProjectTask.findMany({
where: {
isActive: true,
idProject: String(id)
},
select: {
id: true,
title: true,
status: true,
dateStart: true,
dateEnd: true,
},
orderBy: {
createdAt: 'asc'
}
})
const fix = dataProgress.map((v: any) => ({
..._.omit(v, ["dateStart", "dateEnd", "status"]),
status: v.status == 1 ? "selesai" : "belum selesai",
dateStart: moment(v.dateStart).format("DD-MM-YYYY"),
dateEnd: moment(v.dateEnd).format("DD-MM-YYYY"),
}))
allData = fix
} else if (kategori == "file") {
const dataFile = await prisma.divisionProjectFile.findMany({
where: {
isActive: true,
idProject: String(id)
},
select: {
id: true,
ContainerFileDivision: {
select: {
id: true,
name: true,
extension: true,
idStorage: true
}
}
}
})
const fix = dataFile.map((v: any) => ({
..._.omit(v, ["ContainerFileDivision"]),
nameInStorage: v.ContainerFileDivision.id,
name: v.ContainerFileDivision.name,
extension: v.ContainerFileDivision.extension,
idStorage: v.ContainerFileDivision.idStorage,
}))
allData = fix
} else if (kategori == "member") {
const dataMember = await prisma.divisionProjectMember.findMany({
where: {
isActive: true,
idProject: String(id)
},
select: {
id: true,
idUser: true,
User: {
select: {
name: true,
email: true,
img: true,
Position: {
select: {
name: true
}
}
}
}
}
})
const fix = dataMember.map((v: any) => ({
..._.omit(v, ["User"]),
name: v.User.name,
email: v.User.email,
img: v.User.img,
position: v.User.Position.name
}))
allData = fix
} else if (kategori == "link") {
const dataLink = await prisma.divisionProjectLink.findMany({
where: {
isActive: true,
idProject: String(id)
},
orderBy: {
createdAt: 'asc'
}
})
allData = dataLink
}
return NextResponse.json({ success: true, message: "Berhasil mendapatkan tugas divisi", data: allData }, { status: 200 });
}
catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan tugas divisi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,104 +0,0 @@
import { prisma } from "@/module/_global";
import _, { ceil } from "lodash";
import { NextResponse } from "next/server";
// GET ALL DATA TUGAS DIVISI
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const villageId = searchParams.get('desa');
const division = searchParams.get('division');
const search = searchParams.get('search');
const status = searchParams.get('status');
const page = searchParams.get('page');
const get = searchParams.get('get');
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: true,
Division: {
idVillage: String(villageId)
},
title: {
contains: (search == undefined || search == "null") ? "" : search,
mode: "insensitive"
}
}
if (status != "null" && status != undefined && status != "" && status != null) {
kondisi = {
...kondisi,
status: status == "segera" ? 0 : status == "dikerjakan" ? 1 : status == "selesai" ? 2 : status == "batal" ? 3 : 0
}
}
if (division != "null" && division != undefined && division != "" && division != null) {
kondisi = {
...kondisi,
idDivision: String(division)
}
}
const data = await prisma.divisionProject.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
select: {
id: true,
idDivision: true,
title: true,
desc: true,
status: true,
DivisionProjectTask: {
where: {
isActive: true
},
select: {
title: true,
status: true
}
},
DivisionProjectMember: {
where: {
isActive: true
},
select: {
idUser: true
}
},
Division: {
select: {
name: true
}
}
},
orderBy: {
createdAt: "desc"
}
});
const formatData = data.map((v: any) => ({
..._.omit(v, ["DivisionProjectTask", "DivisionProjectMember", "status", "Division"]),
division: v.Division.name,
status: v.status == 1 ? "dikerjakan" : v.status == 2 ? "selesai" : v.status == 3 ? "batal" : "segera",
progress: ceil((v.DivisionProjectTask.filter((i: any) => i.status == 1).length * 100) / v.DivisionProjectTask.length),
member: v.DivisionProjectMember.length,
}))
return NextResponse.json({ success: true, message: "Berhasil mendapatkan divisi", data: formatData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan divisi, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,75 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ONE MEMBER / USER
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const users = await prisma.user.findUnique({
where: {
id: id,
},
select: {
id: true,
nik: true,
name: true,
phone: true,
email: true,
gender: true,
img: true,
idGroup: true,
isActive: true,
idPosition: true,
createdAt: true,
updatedAt: true,
UserRole: {
select: {
name: true,
id: true
}
},
Position: {
select: {
name: true,
id: true
},
},
Group: {
select: {
name: true,
id: true
},
},
},
});
const { ...userData } = users;
const group = users?.Group.name
const position = users?.Position?.name
const idUserRole = users?.UserRole.id
const phone = '+62' + users?.phone
const role = users?.UserRole.name
const gender = users?.gender == "F" ? "Perempuan" : "Laki-Laki"
const result = { ...userData, gender, group, position, idUserRole, phone, role };
const omitData = _.omit(result, ["Group", "Position", "UserRole"]);
return NextResponse.json(
{
success: true,
message: "Berhasil mendapatkan anggota",
data: omitData,
},
{ status: 200 }
);
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan anggota, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,88 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
// GET ALL MEMBER / USER
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search')
const idVillage = searchParams.get("desa");
const idGroup = searchParams.get("group");
const active = searchParams.get("active");
const page = searchParams.get('page');
const get = searchParams.get('get');
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
let kondisi: any = {
isActive: active == 'false' ? false : true,
idVillage: String(idVillage),
name: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive",
},
NOT: {
idUserRole: 'developer'
}
}
if (idGroup != "null" && idGroup != undefined && idGroup != "" && idGroup != null) {
kondisi = {
...kondisi,
idGroup: String(idGroup)
}
}
const users = await prisma.user.findMany({
skip: dataSkip,
take: getFix,
where: kondisi,
select: {
id: true,
idUserRole: true,
isActive: true,
nik: true,
name: true,
phone: true,
Position: {
select: {
name: true,
},
},
Group: {
select: {
name: true,
},
},
},
orderBy: {
name: 'asc'
}
});
const allData = users.map((v: any) => ({
..._.omit(v, ["phone", "gender", "Group", "Position"]),
gender: v.gender == "F" ? "Perempuan" : "Laki-Laki",
phone: "+" + v.phone,
group: v.Group.name,
position: v?.Position?.name
}))
return NextResponse.json({ success: true, message: "Berhasil member", data: allData }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan anggota, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -1,50 +0,0 @@
import { prisma } from "@/module/_global";
import _ from "lodash";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const isActive = searchParams.get("active");
const search = searchParams.get('search');
const page = searchParams.get('page')
const get = searchParams.get('get')
let getFix = 10;
if (get == null || get == undefined || get == "" || _.isNaN(Number(get))) {
getFix = 10;
} else {
getFix = Number(get);
}
const dataSkip = page == null || page == undefined ? 0 : Number(page) * getFix - getFix;
const data = await prisma.village.findMany({
skip: dataSkip,
take: getFix,
where: {
isActive: isActive == 'false' ? false : true,
name: {
contains: (search == undefined || search == null) ? "" : search,
mode: "insensitive"
}
},
select: {
id: true,
name: true,
isActive: true,
createdAt: true,
updatedAt: true
},
orderBy: {
name: 'asc'
}
});
return NextResponse.json({ success: true, message: "Berhasil mendapatkan desa", data, }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan desa, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 });
}
}

View File

@@ -68,7 +68,18 @@ export async function GET(request: Request, context: { params: { id: string } })
status: true,
dateStart: true,
dateEnd: true,
createdAt: true
createdAt: true,
ProjectTaskFile: {
where: { isActive: true },
select: {
ProjectFile: {
select: {
name: true,
extension: true
}
}
}
}
},
orderBy: {
dateStart: 'asc'
@@ -76,12 +87,15 @@ export async function GET(request: Request, context: { params: { id: string } })
})
const formatData = dataProgress.map((v: any) => ({
..._.omit(v, ["dateStart", "dateEnd", "createdAt"]),
dateStart: moment(v.dateStart).format("DD-MM-YYYY"),
dateEnd: moment(v.dateEnd).format("DD-MM-YYYY"),
..._.omit(v, ["dateStart", "dateEnd", "createdAt", "ProjectTaskFile"]),
dateStart: moment(v.dateStart).format("DD MMM YYYY"),
dateEnd: moment(v.dateEnd).format("DD MMM YYYY"),
createdAt: moment(v.createdAt).format("DD-MM-YYYY HH:mm"),
files: v.ProjectTaskFile.map((tf: any) => ({
name: tf.ProjectFile.name,
extension: tf.ProjectFile.extension
}))
}))
// const dataFix = _.orderBy(formatData, 'createdAt', 'asc')
allData = formatData
} else if (kategori == "file") {

View File

@@ -0,0 +1,367 @@
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, idGroup: string): Promise<NotifTarget[]> {
const approvers = await prisma.user.findMany({
where: {
isActive: true,
idVillage,
OR: [
{ isApprover: true, idGroup },
{ 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, title: true,
Project: { select: { id: true, idGroup: 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.Project.id);
// Notifikasi ke semua approver di desa dan group yang sama
const approverTargets = await getApproversInVillage(String(userMobile.idVillage), task.Project.idGroup);
await sendNotification({
targets: approverTargets,
idUserFrom: userMobile.id,
idContent: task.Project.id,
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, title: true, Project: { select: { id: 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.Project.id);
// Notifikasi ke submitter
const submitterTarget = await getUserNotifTarget(pendingApproval.idUser);
if (submitterTarget) {
await sendNotification({
targets: [submitterTarget],
idUserFrom: userMobile.id,
idContent: task.Project.id,
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.Project.id);
// Notifikasi ke submitter
const submitterTarget = await getUserNotifTarget(pendingApproval.idUser);
if (submitterTarget) {
await sendNotification({
targets: [submitterTarget],
idUserFrom: userMobile.id,
idContent: task.Project.id,
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,198 @@
import { DIR, funUploadFile, prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import { createLogUserMobile } from "@/module/user";
import { NextResponse } from "next/server";
// GET: daftar file yang terlampir pada ProjectTask
// [id] = ProjectTask.id
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { searchParams } = new URL(request.url);
const userMobile = searchParams.get("user");
const user = await funGetUserById({ id: String(userMobile) });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const data = await prisma.projectTaskFile.findMany({
where: {
idTask: id,
isActive: true,
},
select: {
id: true,
ProjectFile: {
select: {
id: true,
name: true,
extension: true,
idStorage: true,
},
},
},
orderBy: { createdAt: "asc" },
});
const result = data.map((v) => ({
id: v.id, // ProjectTaskFile.id — dipakai untuk DELETE
idFile: v.ProjectFile.id, // ProjectFile.id — dipakai untuk filter duplikat di picker
name: v.ProjectFile.name,
extension: v.ProjectFile.extension,
idStorage: v.ProjectFile.idStorage,
}));
return NextResponse.json({ success: true, message: "Berhasil mendapatkan file tugas", data: result }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan file tugas (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// POST: upload file baru ke ProjectTask
// Membuat ProjectFile baru lalu membuat ProjectTaskFile (junction)
// [id] = ProjectTask.id
export async function POST(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const body = await request.formData();
const data = JSON.parse(body.get("data") as string);
const user = await funGetUserById({ id: data.user });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.projectTask.findUnique({
where: { id },
select: { id: true, idProject: true },
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const hasCekFile = body.has("file0");
if (!hasCekFile) {
return NextResponse.json({ success: false, message: "Tidak ada file yang dikirim" }, { status: 200 });
}
body.delete("data");
for (const [key] of body.entries()) {
if (!key.startsWith("file")) continue;
const file = body.get(key) as File;
const fExt = file.name.split(".").pop();
const fName = file.name.replace("." + fExt, "");
const upload = await funUploadFile({ file, dirId: DIR.project });
if (!upload.success) continue;
const projectFile = await prisma.projectFile.create({
data: {
idProject: task.idProject,
name: fName,
extension: String(fExt),
idStorage: upload.data.id,
},
select: { id: true },
});
await prisma.projectTaskFile.create({
data: {
idTask: id,
idFile: projectFile.id,
},
});
}
await createLogUserMobile({ act: "CREATE", desc: "User menambah file pada tugas kegiatan", table: "projectTask", data: id, user: user.id });
return NextResponse.json({ success: true, message: "Berhasil menambahkan file" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal menambahkan file (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// PATCH: link ProjectFile yang sudah ada ke ProjectTask
// Body: { user, idFile } — idFile = ProjectFile.id
// [id] = ProjectTask.id
export async function PATCH(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user: userId, idFile } = await request.json();
const user = await funGetUserById({ id: String(userId) });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.projectTask.findUnique({
where: { id },
select: { id: true },
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const file = await prisma.projectFile.findUnique({
where: { id: idFile },
select: { id: true },
});
if (!file) {
return NextResponse.json({ success: false, message: "File tidak ditemukan" }, { status: 200 });
}
// cek apakah sudah pernah di-link
const existing = await prisma.projectTaskFile.findFirst({
where: { idTask: id, idFile, isActive: true },
});
if (existing) {
return NextResponse.json({ success: false, message: "File sudah terlampir pada tugas ini" }, { status: 200 });
}
await prisma.projectTaskFile.create({
data: { idTask: id, idFile },
});
await createLogUserMobile({ act: "CREATE", desc: "User melampirkan file kegiatan ke tugas", table: "projectTask", data: id, user: user.id });
return NextResponse.json({ success: true, message: "Berhasil melampirkan file" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal melampirkan file (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// DELETE: hapus lampiran file dari ProjectTask (hapus junction record saja)
// [id] = ProjectTaskFile.id
export async function DELETE(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user: userId } = await request.json();
const user = await funGetUserById({ id: String(userId) });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const junction = await prisma.projectTaskFile.findUnique({
where: { id },
select: { id: true, idTask: true },
});
if (!junction) {
return NextResponse.json({ success: false, message: "Data tidak ditemukan" }, { status: 200 });
}
await prisma.projectTaskFile.delete({ where: { id } });
await createLogUserMobile({ act: "DELETE", desc: "User menghapus lampiran file dari tugas kegiatan", table: "projectTask", data: junction.idTask, user: user.id });
return NextResponse.json({ success: true, message: "Berhasil menghapus lampiran file" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal menghapus lampiran file (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}

View File

@@ -25,6 +25,9 @@ export async function GET(request: Request, context: { params: { id: string } })
where: {
id: String(id),
isActive: true
},
include: {
Division: { select: { idGroup: true } }
}
});
@@ -33,7 +36,7 @@ export async function GET(request: Request, context: { params: { id: string } })
}
if (kategori == "data") {
allData = data
allData = { ...data, idGroup: data.Division.idGroup }
} else if (kategori == "progress") {
const dataProgress = await prisma.divisionProjectTask.findMany({
where: {
@@ -74,6 +77,21 @@ export async function GET(request: Request, context: { params: { id: string } })
status: true,
dateStart: true,
dateEnd: true,
DivisionProjectTaskFile: {
where: { isActive: true },
select: {
DivisionProjectFile: {
select: {
ContainerFileDivision: {
select: {
name: true,
extension: true,
},
},
},
},
},
},
},
orderBy: {
dateStart: 'asc'
@@ -81,9 +99,13 @@ export async function GET(request: Request, context: { params: { id: string } })
})
const fix = dataProgress.map((v: any) => ({
..._.omit(v, ["dateStart", "dateEnd"]),
dateStart: moment(v.dateStart).format("DD-MM-YYYY"),
dateEnd: moment(v.dateEnd).format("DD-MM-YYYY"),
..._.omit(v, ["dateStart", "dateEnd", "DivisionProjectTaskFile"]),
dateStart: moment(v.dateStart).format("DD MMM YYYY"),
dateEnd: moment(v.dateEnd).format("DD MMM YYYY"),
files: v.DivisionProjectTaskFile.map((tf: any) => ({
name: tf.DivisionProjectFile.ContainerFileDivision.name,
extension: tf.DivisionProjectFile.ContainerFileDivision.extension,
})),
}))
allData = fix

View File

@@ -0,0 +1,426 @@
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 division = await prisma.division.findUnique({
where: { id: idDivision },
select: { idGroup: true }
});
const idGroup = division?.idGroup;
const [globalApprovers, divisionAdmins] = await Promise.all([
prisma.user.findMany({
where: {
isActive: true,
idVillage,
OR: [
{ isApprover: true, idGroup },
{ 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 taskForAuth = await prisma.divisionProjectTask.findUnique({
where: { id, isActive: true },
select: { idDivision: true }
});
if (!taskForAuth) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const [division, userFull, isDivAdmin] = await Promise.all([
prisma.division.findUnique({
where: { id: taskForAuth.idDivision },
select: { idGroup: true, idVillage: true }
}),
prisma.user.findUnique({
where: { id: userMobile.id },
select: { isApprover: true, idGroup: true, idVillage: true, UserRole: { select: { id: true } } }
}),
prisma.divisionMember.count({
where: { idDivision: taskForAuth.idDivision, idUser: userMobile.id, isAdmin: true, isActive: true }
})
]);
const isSupadmin = APPROVER_ROLES.includes(userFull?.UserRole?.id ?? '');
const isGroupApprover = !!(userFull?.isApprover &&
userFull.idVillage === division?.idVillage &&
userFull.idGroup === division?.idGroup);
if (!isSupadmin && !isGroupApprover && isDivAdmin === 0) {
return NextResponse.json({ success: false, message: "Anda tidak memiliki izin untuk menyetujui atau menolak tugas" }, { 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

@@ -0,0 +1,210 @@
import { DIR, funUploadFile, prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import { createLogUserMobile } from "@/module/user";
import { NextResponse } from "next/server";
// GET: daftar file yang terlampir pada DivisionProjectTask
// [id] = DivisionProjectTask.id
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { searchParams } = new URL(request.url);
const userMobile = searchParams.get("user");
const user = await funGetUserById({ id: String(userMobile) });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const data = await prisma.divisionProjectTaskFile.findMany({
where: {
idTask: id,
isActive: true,
},
select: {
id: true,
DivisionProjectFile: {
select: {
id: true,
ContainerFileDivision: {
select: {
name: true,
extension: true,
idStorage: true,
},
},
},
},
},
orderBy: { createdAt: "asc" },
});
const result = data.map((v) => ({
id: v.id,
idFile: v.DivisionProjectFile.id,
name: v.DivisionProjectFile.ContainerFileDivision.name,
extension: v.DivisionProjectFile.ContainerFileDivision.extension,
idStorage: v.DivisionProjectFile.ContainerFileDivision.idStorage,
}));
return NextResponse.json({ success: true, message: "Berhasil mendapatkan file tugas", data: result }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal mendapatkan file tugas (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// POST: upload file baru ke DivisionProjectTask
// [id] = DivisionProjectTask.id
export async function POST(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const body = await request.formData();
const data = JSON.parse(body.get("data") as string);
const user = await funGetUserById({ id: data.user });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.divisionProjectTask.findUnique({
where: { id },
select: { id: true, idProject: true, idDivision: true },
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const hasCekFile = body.has("file0");
if (!hasCekFile) {
return NextResponse.json({ success: false, message: "Tidak ada file yang dikirim" }, { status: 200 });
}
body.delete("data");
for (const [key] of body.entries()) {
if (!key.startsWith("file")) continue;
const file = body.get(key) as File;
const fExt = file.name.split(".").pop();
const fName = file.name.replace("." + fExt, "");
const upload = await funUploadFile({ file, dirId: DIR.task });
if (!upload.success) continue;
const container = await prisma.containerFileDivision.create({
data: {
idDivision: task.idDivision,
name: fName,
extension: String(fExt),
idStorage: upload.data.id,
},
select: { id: true },
});
const divFile = await prisma.divisionProjectFile.create({
data: {
idProject: task.idProject,
idDivision: task.idDivision,
idFile: container.id,
createdBy: user.id,
},
select: { id: true },
});
await prisma.divisionProjectTaskFile.create({
data: {
idTask: id,
idFile: divFile.id,
},
});
}
await createLogUserMobile({ act: "CREATE", desc: "User menambah file pada tugas divisi", table: "divisionProjectTask", data: id, user: user.id });
return NextResponse.json({ success: true, message: "Berhasil menambahkan file" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal menambahkan file (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// PATCH: link DivisionProjectFile yang sudah ada ke DivisionProjectTask
// Body: { user, idFile } — idFile = DivisionProjectFile.id
// [id] = DivisionProjectTask.id
export async function PATCH(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user: userId, idFile } = await request.json();
const user = await funGetUserById({ id: String(userId) });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const task = await prisma.divisionProjectTask.findUnique({
where: { id },
select: { id: true },
});
if (!task) {
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
}
const file = await prisma.divisionProjectFile.findUnique({
where: { id: idFile },
select: { id: true },
});
if (!file) {
return NextResponse.json({ success: false, message: "File tidak ditemukan" }, { status: 200 });
}
const existing = await prisma.divisionProjectTaskFile.findFirst({
where: { idTask: id, idFile, isActive: true },
});
if (existing) {
return NextResponse.json({ success: false, message: "File sudah terlampir pada tugas ini" }, { status: 200 });
}
await prisma.divisionProjectTaskFile.create({
data: { idTask: id, idFile },
});
await createLogUserMobile({ act: "CREATE", desc: "User melampirkan file divisi ke tugas", table: "divisionProjectTask", data: id, user: user.id });
return NextResponse.json({ success: true, message: "Berhasil melampirkan file" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal melampirkan file (error: 500)", reason: (error as Error).message }, { status: 500 });
}
}
// DELETE: hapus lampiran file dari DivisionProjectTask (hapus junction record saja)
// [id] = DivisionProjectTaskFile.id
export async function DELETE(request: Request, context: { params: { id: string } }) {
try {
const { id } = context.params;
const { user: userId } = await request.json();
const user = await funGetUserById({ id: String(userId) });
if (!user.id || user.id === "null") {
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
}
const junction = await prisma.divisionProjectTaskFile.findUnique({
where: { id },
select: { id: true, idTask: true },
});
if (!junction) {
return NextResponse.json({ success: false, message: "Data tidak ditemukan" }, { status: 200 });
}
await prisma.divisionProjectTaskFile.delete({ where: { id } });
await createLogUserMobile({ act: "DELETE", desc: "User menghapus lampiran file dari tugas divisi", table: "divisionProjectTask", data: junction.idTask, user: user.id });
return NextResponse.json({ success: true, message: "Berhasil menghapus lampiran file" }, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json({ success: false, message: "Gagal menghapus lampiran file (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

@@ -12,7 +12,7 @@ import "moment/locale/id";
const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
.use(cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"]
}))
.use(swagger({
@@ -117,50 +117,32 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
)
.get("/daily-activity", async ({ query, set }) => {
try {
// const data = await prisma.userLog.findMany({
// where: {
// User: {
// Village: {
// isDummy: false
// }
// },
// createdAt: {
// gte: moment().subtract(7, 'days').toDate(),
// lte: moment().toDate(),
// }
// },
// select: {
// createdAt: true,
// }
// })
const VALID_RANGES = [7, 30, 90];
const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7;
const data = await prisma.$queryRaw`
SELECT
SELECT
DATE(ul."createdAt") AS tanggal,
COUNT(*) AS total
FROM "UserLog" ul
JOIN "User" u ON ul."idUser" = u."id"
JOIN "Village" v ON u."idVillage" = v."id"
WHERE v."isDummy" = false
AND ul."createdAt" >= NOW() - INTERVAL '7 days'
AND ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day')
GROUP BY tanggal
ORDER BY tanggal;` as any[];
const result = [];
// ubah data ke map biar gampang lookup
const map = data.reduce((acc: any, item: any) => {
const key = moment(item.tanggal).format('YYYY-MM-DD');
acc[key] = Number(item.total);
return acc;
}, {});
// generate 7 hari terakhir
for (let i = 6; i >= 0; i--) {
for (let i = range - 1; i >= 0; i--) {
const date = moment().subtract(i, 'days');
const key = date.format('YYYY-MM-DD');
result.push({
date: date.format('DD MMM'),
logs: map[key] || 0
@@ -183,45 +165,39 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
},
{
query: t.Object({
range: t.Optional(t.String({ description: "Rentang hari: 7, 30, atau 90 (default: 7)" })),
}),
detail: {
summary: "Daily Activity",
description: "Menu Overview - Mendapatkan data grafik aktivitas harian semua desa.",
description: "Menu Overview - Mendapatkan data grafik aktivitas harian semua desa. Gunakan ?range=30 atau ?range=90 untuk rentang lebih panjang.",
tags: ["overview"],
},
}
)
.get("/comparison-activity", async ({ query, set }) => {
try {
const villages = await prisma.village.findMany({
where: { isDummy: false },
select: { name: true },
});
const VALID_RANGES = [7, 30, 90];
const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7;
const data = await prisma.$queryRaw`
SELECT
SELECT
v."name",
COUNT(ul."id") AS total_logs
FROM "UserLog" ul
JOIN "User" u ON ul."idUser" = u."id"
JOIN "Village" v ON u."idVillage" = v."id"
WHERE v."isDummy" = false
AND ul."createdAt" >= NOW() - INTERVAL '7 days'
AND ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day')
GROUP BY v."id", v."name"
ORDER BY total_logs DESC;
` as any[];
const logMap: Record<string, number> = {};
data.forEach((item) => {
logMap[item.name] = Number(item.total_logs);
});
const result = villages.map((v) => ({
village: v.name,
activity: logMap[v.name] || 0,
const result = data.map((item: any) => ({
village: item.name,
activity: Number(item.total_logs),
}));
return {
success: true,
message: "Berhasil mendapatkan data",
@@ -238,9 +214,12 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
},
{
query: t.Object({
range: t.Optional(t.String({ description: "Rentang hari: 7, 30, atau 90 (default: 7)" })),
}),
detail: {
summary: "Comparison Activity",
description: "Menu Overview - Mendapatkan data grafik perbandingan aktivitas desa selama 7 hari terakhir.",
description: "Menu Overview - Mendapatkan data grafik perbandingan aktivitas desa. Gunakan ?range=30 atau ?range=90 untuk rentang lebih panjang.",
tags: ["overview"],
},
}
@@ -311,13 +290,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 +314,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 +322,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 +480,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
id: true,
name: true,
isActive: true,
isDummy: true,
createdAt: true,
updatedAt: true,
desc: true,
@@ -529,6 +509,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,
@@ -652,7 +633,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
)
.get("/graph-log-villages", async ({ query, set }) => {
const { id, time } = query;
const { id, time, dateFrom, dateTo } = query;
try {
const village = await prisma.village.findUnique({
@@ -670,14 +651,20 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
const now = new Date();
let startDate: Date;
let endDate: Date = now;
const useCustomRange = !!(dateFrom && dateTo);
if (time === "daily") {
if (useCustomRange) {
startDate = new Date(dateFrom);
endDate = new Date(dateTo);
endDate.setHours(23, 59, 59, 999);
} else if (time === "daily") {
startDate = new Date();
startDate.setDate(now.getDate() - 13); // 14 hari
} else if (time === "monthly") {
startDate = new Date(now.getFullYear(), 0, 1); // awal tahun
} else if (time === "yearly") {
startDate = new Date(now.getFullYear() - 4, 0, 1); // 5 tahun terakhir (opsional)
startDate = new Date(now.getFullYear() - 4, 0, 1); // 5 tahun terakhir
} else {
startDate = new Date(0);
}
@@ -686,6 +673,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
where: {
createdAt: {
gte: startDate,
...(useCustomRange ? { lte: endDate } : {}),
},
User: {
idVillage: id,
@@ -701,21 +689,27 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
// =========================
const map: Record<string, number> = {};
// Tentukan format label berdasarkan range
const effectiveTime = useCustomRange ? (
// > 60 hari pakai monthly, selain itu daily
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) > 60 ? 'monthly' : 'daily'
) : time;
dataLog.forEach((log) => {
const date = new Date(log.createdAt);
let label = "";
if (time === "daily") {
if (effectiveTime === "daily") {
label = date.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
});
} else if (time === "monthly") {
} else if (effectiveTime === "monthly") {
label = date.toLocaleDateString("id-ID", {
month: "short",
year: "numeric",
});
} else if (time === "yearly") {
} else if (effectiveTime === "yearly") {
label = date.getFullYear().toString();
}
@@ -727,9 +721,13 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
// =========================
let result: any[] = [];
if (time === "daily") {
for (let i = 13; i >= 0; i--) {
const d = new Date();
if (effectiveTime === "daily") {
const days = useCustomRange
? Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
: 14;
for (let i = days - 1; i >= 0; i--) {
const d = new Date(endDate);
d.setDate(d.getDate() - i);
const label = d.toLocaleDateString("id-ID", {
@@ -742,41 +740,38 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
aktivitas: map[label] || 0,
});
}
} else if (time === "monthly") {
const year = now.getFullYear();
for (let m = 0; m <= 11; m++) {
const d = new Date(year, m, 1);
} else if (effectiveTime === "monthly") {
const s = new Date(startDate);
const e = new Date(endDate);
const months: string[] = [];
const label = d.toLocaleDateString("id-ID", {
const cursor = new Date(s.getFullYear(), s.getMonth(), 1);
while (cursor <= e) {
months.push(cursor.toLocaleDateString("id-ID", {
month: "short",
});
result.push({
label,
aktivitas: map[label] || 0,
});
year: "numeric",
}));
cursor.setMonth(cursor.getMonth() + 1);
}
} else if (time === "yearly") {
result = months.map((label) => ({
label,
aktivitas: map[label] || 0,
}));
} else if (effectiveTime === "yearly") {
const years = Object.keys(map).map(Number);
if (years.length === 0) {
const currentYear = new Date().getFullYear();
result = [
{ label: currentYear.toString(), aktivitas: 0 }
];
result = [{ label: currentYear.toString(), aktivitas: 0 }];
} else {
const minYear = Math.min(...years);
const maxYear = Math.max(...years);
result = [];
for (let y = minYear; y <= maxYear; y++) {
const label = y.toString();
result.push({
label,
aktivitas: map[label] || 0,
label: y.toString(),
aktivitas: map[y.toString()] || 0,
});
}
}
@@ -800,21 +795,81 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
{
query: t.Object({
id: t.String({ description: "ID desa" }),
time: t.Enum(
{
daily: "daily",
monthly: "monthly",
yearly: "yearly",
},
{
description: "Rentang waktu (daily = 14 hari, monthly = 1 tahun, yearly = per tahun)",
}
),
time: t.Optional(t.Enum(
{ daily: "daily", monthly: "monthly", yearly: "yearly" },
{ description: "Rentang waktu (daily = 14 hari, monthly = 1 tahun, yearly = per tahun). Default: daily" },
)),
dateFrom: t.Optional(t.String({ description: "Filter dari tanggal (YYYY-MM-DD). Mengabaikan time jika diisi bersama dateTo." })),
dateTo: t.Optional(t.String({ description: "Filter sampai tanggal (YYYY-MM-DD). Mengabaikan time jika diisi bersama dateFrom." })),
}),
detail: {
summary: "Graph Log Villages",
description:
"Mendapatkan data grafik log aktivitas desa berdasarkan rentang waktu (harian, bulanan, tahunan)",
"Mendapatkan data grafik log aktivitas desa berdasarkan rentang waktu (harian, bulanan, tahunan) atau custom date range.",
tags: ["detail-villages"],
},
}
)
.get("/recent-village-logs", async ({ query, set }) => {
const { id } = query;
try {
const village = await prisma.village.findUnique({
where: { id },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const logs = await prisma.userLog.findMany({
where: {
User: { idVillage: id },
},
select: {
createdAt: true,
action: true,
desc: true,
User: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
take: 10,
});
const result = logs.map((log) => ({
timestamp: log.createdAt,
userName: log.User.name,
action: log.action,
desc: log.desc,
}));
return {
success: true,
message: "Berhasil mendapatkan data",
data: result,
};
} catch (error) {
console.error("[recent-village-logs] error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
id: t.String({ description: "ID desa" }),
}),
detail: {
summary: "Recent Village Logs",
description: "Mendapatkan 10 log aktivitas terbaru di desa tertentu.",
tags: ["detail-villages"],
},
}
@@ -957,7 +1012,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 +1032,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
data: {
name,
desc,
isDummy,
},
});
@@ -998,6 +1054,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",
@@ -1057,37 +1114,31 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
)
.get("/log-all-villages", async ({ query, set }) => {
const { page = 1, search } = query;
const { page = 1, search, action, idVillage, dateFrom, dateTo } = query;
const pageNum = Number(page) || 1;
const take = 15;
const skip = (pageNum - 1) * take;
const whereClause = {
...(action && { action: action.toUpperCase() }),
...(idVillage && { User: { idVillage } }),
...(dateFrom || dateTo) && {
createdAt: {
...(dateFrom && { gte: new Date(dateFrom) }),
...(dateTo && { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) }),
},
},
...(search && {
OR: [
{ User: { name: { contains: search, mode: "insensitive" as const } } },
{ User: { Village: { name: { contains: search, mode: "insensitive" as const } } } },
],
}),
};
try {
const dataLog = await prisma.userLog.findMany({
where: {
...(search && {
OR: [
{
User: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
User: {
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
},
],
}),
},
where: whereClause,
select: {
id: true,
createdAt: true,
@@ -1111,32 +1162,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
take,
});
const total = await prisma.userLog.count({
where: {
...(search && {
OR: [
{
User: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
User: {
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
},
],
}),
},
});
const total = await prisma.userLog.count({ where: whereClause });
const result = dataLog.map((item) => ({
id: item.id,
@@ -1171,65 +1197,49 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
{
query: t.Object({
page: t.Optional(t.String({ description: "Halaman" })),
search: t.Optional(t.String({ description: "Pencarian" })),
search: t.Optional(t.String({ description: "Pencarian nama user atau desa" })),
action: t.Optional(t.String({ description: "Filter jenis aksi: LOGIN | LOGOUT | CREATE | UPDATE | DELETE" })),
idVillage: t.Optional(t.String({ description: "Filter berdasarkan ID desa" })),
dateFrom: t.Optional(t.String({ description: "Tanggal mulai (ISO 8601, e.g. 2026-05-01)" })),
dateTo: t.Optional(t.String({ description: "Tanggal akhir (ISO 8601, e.g. 2026-05-31)" })),
}),
detail: {
summary: "Log Villages",
description:
"Mendapatkan data log aktivitas desa berdasarkan halaman dan pencarian",
description: "Mendapatkan data log aktivitas desa dengan filter aksi, desa, rentang tanggal, pencarian, dan paginasi",
tags: ["log-activity"],
},
}
)
.get("/user", async ({ query, set }) => {
const { page = 1, search } = query;
const { page = 1, search, isActive, idUserRole, idVillage, orderBy = 'createdAt', orderDir = 'desc' } = query;
const pageNum = Number(page) || 1;
const take = 15;
const skip = (pageNum - 1) * take;
const SORTABLE_FIELDS = ['name', 'email', 'isActive', 'idUserRole', 'createdAt'] as const;
type SortableField = typeof SORTABLE_FIELDS[number];
const safeOrderBy: SortableField = SORTABLE_FIELDS.includes(orderBy as SortableField) ? (orderBy as SortableField) : 'createdAt';
const safeOrderDir = orderDir === 'asc' ? 'asc' : 'desc';
const whereClause = {
...(isActive !== undefined && { isActive: isActive === 'true' }),
...(idUserRole && { idUserRole }),
...(idVillage && { idVillage }),
...(search && {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ phone: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
{ nik: { contains: search, mode: "insensitive" as const } },
{ Village: { name: { contains: search, mode: "insensitive" as const } } },
{ idUserRole: search },
],
}),
};
try {
const data = await prisma.user.findMany({
where: {
...(search && {
OR: [
{
name: {
contains: search,
mode: "insensitive",
},
},
{
phone: {
contains: search,
mode: "insensitive",
},
},
{
email: {
contains: search,
mode: "insensitive",
},
},
{
nik: {
contains: search,
mode: "insensitive",
},
},
{
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
idUserRole: search,
},
],
}),
},
where: whereClause,
select: {
id: true,
name: true,
@@ -1238,6 +1248,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
email: true,
isWithoutOTP: true,
isActive: true,
isApprover: true,
idUserRole: true,
idVillage: true,
idGroup: true,
@@ -1265,55 +1276,13 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
},
},
orderBy: {
createdAt: "desc",
[safeOrderBy]: safeOrderDir,
},
skip,
take,
});
const total = await prisma.user.count({
where: {
...(search && {
OR: [
{
name: {
contains: search,
mode: "insensitive",
},
},
{
phone: {
contains: search,
mode: "insensitive",
},
},
{
email: {
contains: search,
mode: "insensitive",
},
},
{
nik: {
contains: search,
mode: "insensitive",
},
},
{
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
idUserRole: search,
},
],
}),
},
});
const total = await prisma.user.count({ where: whereClause });
const result = data.map((item) => ({
id: item.id,
@@ -1324,6 +1293,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
gender: item.gender,
isWithoutOTP: item.isWithoutOTP,
isActive: item.isActive,
isApprover: item.isApprover,
role: item.UserRole?.name,
village: item.Village?.name,
group: item.Group?.name,
@@ -1357,12 +1327,16 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
{
query: t.Object({
page: t.Optional(t.String({ description: "Halaman" })),
search: t.Optional(t.String({ description: "Pencarian" })),
search: t.Optional(t.String({ description: "Pencarian nama/NIK/email/telepon" })),
isActive: t.Optional(t.String({ description: "Filter status: 'true' atau 'false'" })),
idUserRole: t.Optional(t.String({ description: "Filter berdasarkan ID role" })),
idVillage: t.Optional(t.String({ description: "Filter berdasarkan ID desa" })),
orderBy: t.Optional(t.String({ description: "Kolom urutan: name | email | isActive | idUserRole | createdAt (default: createdAt)" })),
orderDir: t.Optional(t.String({ description: "Arah urutan: asc | desc (default: desc)" })),
}),
detail: {
summary: "User",
description:
"Mendapatkan data user berdasarkan halaman dan pencarian",
description: "Mendapatkan data user dengan filter status, role, desa, pencarian, dan pengurutan",
tags: ["user"],
},
}
@@ -1436,7 +1410,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
)
.post("/edit-user", async ({ body, set }) => {
const { id, name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP } = body;
const { id, name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP, isApprover } = body;
try {
const cekId = await prisma.user.findFirst({
@@ -1488,6 +1462,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
idPosition,
isActive,
isWithoutOTP,
isApprover,
},
});
@@ -1518,6 +1493,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
idPosition: t.Optional(t.Union([t.String(), t.Null()], { description: "ID Posisi" })),
isActive: t.Boolean({ description: "Aktif" }),
isWithoutOTP: t.Boolean({ description: "Tanpa OTP" }),
isApprover: t.Boolean({ description: "Approver" }),
}),
detail: {
summary: "Edit User",
@@ -1527,11 +1503,93 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
},
}
)
;
;
// ─── API KEY MANAGEMENT ──────────────────────────────────────────────────
.get("/api-keys", async ({ set }) => {
try {
const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: "desc" } });
return {
success: true,
message: "Berhasil mendapatkan API keys",
data: keys.map((k) => ({ ...k, key: k.key.slice(0, 8) + "••••••••" + k.key.slice(-4) })),
};
} catch (error) {
set.status = 500;
return { success: false, message: "Gagal mendapatkan API keys" };
}
}, {
detail: { summary: "List API Keys", tags: ["api-key"] },
})
.get("/api-keys/:id", async ({ params, set }) => {
try {
const key = await prisma.apiKey.findUnique({ where: { id: params.id } });
if (!key) { set.status = 404; return { success: false, message: "API key tidak ditemukan" }; }
return { success: true, data: key };
} catch (error) {
set.status = 500;
return { success: false, message: "Gagal mendapatkan API key" };
}
}, {
params: t.Object({ id: t.String() }),
detail: { summary: "Get API Key (full)", tags: ["api-key"] },
})
.post("/api-keys", async ({ body, set }) => {
try {
const rawKey = "ak_" + crypto.randomUUID().replace(/-/g, "");
const key = await prisma.apiKey.create({ data: { name: body.name, key: rawKey } });
return {
success: true,
message: "API key berhasil dibuat",
data: { ...key, key: rawKey },
};
} catch (error) {
set.status = 500;
return { success: false, message: "Gagal membuat API key" };
}
}, {
body: t.Object({ name: t.String({ description: "Nama key" }) }),
detail: { summary: "Buat API Key", tags: ["api-key"] },
})
.patch("/api-keys/:id", async ({ params, body, set }) => {
try {
const key = await prisma.apiKey.update({
where: { id: params.id },
data: { isActive: body.isActive },
});
return {
success: true,
message: "API key berhasil diupdate",
data: { ...key, key: key.key.slice(0, 8) + "••••••••" + key.key.slice(-4) },
};
} catch (error) {
set.status = 500;
return { success: false, message: "Gagal mengupdate API key" };
}
}, {
params: t.Object({ id: t.String() }),
body: t.Object({ isActive: t.Boolean({ description: "Status aktif" }) }),
detail: { summary: "Toggle API Key", tags: ["api-key"] },
})
.delete("/api-keys/:id", async ({ params, set }) => {
try {
await prisma.apiKey.delete({ where: { id: params.id } });
return { success: true, message: "API key berhasil dihapus" };
} catch (error) {
set.status = 500;
return { success: false, message: "Gagal menghapus API key" };
}
}, {
params: t.Object({ id: t.String() }),
detail: { summary: "Hapus API Key", tags: ["api-key"] },
});
export const GET = MonitoringServer.handle;
export const POST = MonitoringServer.handle;
export const PATCH = MonitoringServer.handle;
export const DELETE = MonitoringServer.handle;
export const OPTIONS = MonitoringServer.handle;

View File

@@ -1,3 +1,4 @@
import { isValidApiKey } from "@/lib/apiKey";
import { prisma } from "@/module/_global";
import cors from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
@@ -11,20 +12,40 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
.use(cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "x-api-key"],
}))
.use(swagger({
path: "/docs", // Karena prefix instance adalah /api/noc, maka ini akan diakses di /api/noc/docs
path: "/docs",
documentation: {
info: {
title: "Sistem Desa Mandiri - NOC API",
version: "1.0.0",
description: "API Khusus untuk kebutuhan NOC (Network Operation Center) dan Monitoring Desa",
},
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "x-api-key",
},
},
},
security: [{ ApiKeyAuth: [] }],
tags: [
{ name: "NOC", description: "Endpoint khusus monitoring" }
]
}
}))
.onBeforeHandle(async ({ request, set, path }) => {
if (path.startsWith("/api/noc/docs")) return;
const incoming = request.headers.get("x-api-key");
if (!incoming || !(await isValidApiKey(incoming))) {
set.status = 401;
return { success: false, message: "Unauthorized" };
}
})
// ── GET /api/noc/active-divisions ──────────────────────────────────────────
.get(

15
src/lib/apiKey.ts Normal file
View File

@@ -0,0 +1,15 @@
import { prisma } from "@/module/_global";
const CACHE_TTL_MS = 60_000;
let apiKeyCache: Set<string> = new Set();
let cacheExpiresAt = 0;
export async function isValidApiKey(incoming: string): Promise<boolean> {
const now = Date.now();
if (now > cacheExpiresAt) {
const rows = await prisma.apiKey.findMany({ where: { isActive: true }, select: { key: true } });
apiKeyCache = new Set(rows.map((r) => r.key));
cacheExpiresAt = now + CACHE_TTL_MS;
}
return apiKeyCache.has(incoming);
}