Compare commits

...

30 Commits

Author SHA1 Message Date
144f4d554a upd: add village active check on login and mobile user api
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:43:05 +08:00
860e9e74c4 Merge pull request 'upd: update api monitoring' (#37) from amalia/21-apr-26 into join
Reviewed-on: #37
2026-04-21 17:33:52 +08:00
dd6f27cf2b upd: update api monitoring 2026-04-21 17:29:47 +08:00
02cf404bc9 Merge pull request 'upd: claude' (#36) from amalia/20-apr-26 into join
Reviewed-on: #36
2026-04-20 17:36:16 +08:00
545e668bef upd: claude 2026-04-20 17:28:12 +08:00
ad6c5157e9 Merge pull request 'upd: fix route laporan divisi' (#35) from amalia/17-apr-26 into join
Reviewed-on: #35
2026-04-17 17:39:26 +08:00
73b19e0dd1 upd: fix route laporan divisi 2026-04-17 15:27:33 +08:00
abcbb3cd7f Merge pull request 'upd : api monitoring' (#34) from amalia/13-apr-26 into join
Reviewed-on: #34
2026-04-13 17:19:01 +08:00
ea3bf2cc3c upd : api monitoring 2026-04-13 11:36:26 +08:00
6b17378679 Merge pull request 'upd: fx api monitoring' (#33) from amalia/10-apr-26 into join
Reviewed-on: #33
2026-04-10 13:45:08 +08:00
d861a3ea86 upd: fx api monitoring 2026-04-10 13:44:15 +08:00
2f97ce81e4 Merge pull request 'upd : api monitoring' (#32) from amalia/09-apr-26 into join
Reviewed-on: #32
2026-04-09 17:34:25 +08:00
3c0a5639b6 upd : api monitoring 2026-04-09 17:33:21 +08:00
3ce650a27d Merge pull request 'amalia/08-apr-26' (#31) from amalia/08-apr-26 into join
Reviewed-on: #31
2026-04-08 17:27:06 +08:00
5efb96a92a upd: api monitoring--user 2026-04-08 17:24:50 +08:00
93ae77d335 upd: api monitoring log activity 2026-04-08 14:50:12 +08:00
0c131b80ef Merge pull request 'amalia/07-apr-26' (#30) from amalia/07-apr-26 into join
Reviewed-on: #30
2026-04-07 17:31:04 +08:00
5fd5c15394 upd: api monitoring detail desa 2026-04-07 17:25:14 +08:00
cb565ba0bd upd: api monitoring menu desa 2026-04-07 14:52:46 +08:00
940fa5a5b7 Merge pull request 'upd: api monitoring' (#29) from amalia/06-apr-26 into join
Reviewed-on: #29
2026-04-06 17:35:18 +08:00
0b9f07e543 upd: api monitoring 2026-04-06 17:23:32 +08:00
8440374424 Merge pull request 'upd: url otp' (#28) from amalia/27-mar-26 into join
Reviewed-on: #28
2026-03-27 14:09:48 +08:00
eaa1a74290 upd: url otp 2026-03-27 14:07:35 +08:00
1326338335 Merge pull request 'upd: api noc' (#27) from amalia/25-mar-26 into join
Reviewed-on: #27
2026-03-25 17:05:36 +08:00
d1f553ee32 upd: api noc 2026-03-25 17:02:26 +08:00
b14ae8e5ff Merge pull request 'upd: api version' (#26) from amalia/16-mar-26 into join
Reviewed-on: #26
2026-03-16 16:13:58 +08:00
270875a95c upd: api version 2026-03-16 10:39:23 +08:00
09bd75d5e5 Merge pull request 'upd: api noc' (#25) from amalia/12-mar-26 into join
Reviewed-on: #25
2026-03-12 16:11:03 +08:00
339b1e25cc upd: api noc 2026-03-12 16:08:42 +08:00
d9c6f486a9 Merge pull request 'upd: api noc' (#24) from amalia/11-mar-26 into join
Reviewed-on: #24
2026-03-11 16:41:40 +08:00
14 changed files with 2115 additions and 203 deletions

81
CLAUDE.md Normal file
View File

@@ -0,0 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
bun install # Install dependencies
bun run dev # Dev server with experimental HTTPS (localhost:3000)
bun run build # Production build
bun run start # Start production server
bun run lint # Run ESLint
# Database
npx prisma migrate dev # Run/create migrations
npx prisma db seed # Seed with initial data
npx prisma generate # Regenerate Prisma client after schema changes
```
## Architecture
**Sistem Desa Mandiri** is a village administration platform built on Next.js 14 (App Router) with PostgreSQL.
### Key Layers
- **`src/app/(application)/`** — Auth-protected pages grouped by feature (announcement, division, project, discussion, member, profile, home, group)
- **`src/app/(auth)/`** — Login/register pages
- **`src/app/api/`** — REST API endpoints; subdirectories map to resource types (`/api/announcement`, `/api/project`, `/api/task`, etc.). Mobile-specific endpoints live under `/api/mobile/`
- **`src/module/`** — Business logic modules, one per feature (19 modules). Each module contains hooks, components, and API call functions for that domain
- **`src/lib/`** — Shared utilities: Prisma client singleton (`prisma.ts`), Firebase init, route definitions (`routes.ts`), push notification hooks
### Data Access Pattern
All DB access goes through the Prisma client singleton in `src/lib/prisma.ts`. Prisma schema is at `prisma/schema.prisma` (40+ models). Migrations live in `prisma/migrations/`.
### State Management
- **Hookstate** (`@hookstate/core` + `@hookstate/localstored`) for client-side global state with localStorage persistence
- **Iron-session** for server-side session management / auth
- **Jose** for JWT handling
### UI Stack
- **Mantine 7** is the primary UI library (components, forms, modals, notifications, charts, dates, etc.)
- **Tailwind CSS** for utility classes — used alongside Mantine
- **PostCSS** configured with Mantine preset (`postcss.config.mjs`)
### Real-time & Notifications
- **Firebase FCM** (`src/lib/firebase/`) for mobile push notifications
- **Web Push + VAPID keys** (`src/lib/usePushNotifications.ts`) for browser push
- **wibu-realtime** (custom library) for WebSocket-based real-time updates
### User Roles
Five roles with distinct access levels (see `PANDUAN PENGGUNAAN.md`):
1. **Super Admin** — full system access
2. **Admin Desa** — village-level administration
3. **Ketua Divisi** — division leader
4. **Anggota Divisi** — division member
5. **Warga/Perangkat Desa** — village resident/official
## Environment Variables
Copy `.env.example` to `.env`. Required variables:
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `GOOGLE_PROJECT_ID`, `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY` | Firebase Admin SDK (FCM) |
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | Web Push |
| `WS_APIKEY` | WebSocket/file storage API key |
| `WIBU_REALTIME_KEY` | Real-time communication |
| `FCM_KEY` | Firebase Cloud Messaging |
## Deployment
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).

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ export async function POST(req: NextRequest) {
const { phone }: ILogin = await req.json();
const user = await prisma.user.findUnique({
where: { phone, isActive: true },
select: { id: true, phone: true, isWithoutOTP: true },
select: { id: true, phone: true, isWithoutOTP: true, Village: { select: { isActive: true } } },
});
if (!user) {
@@ -17,6 +17,13 @@ export async function POST(req: NextRequest) {
});
}
if (!user.Village?.isActive) {
return Response.json({
success: false,
message: "Akun anda tidak aktif, silahkan hubungi admin",
});
}
return Response.json({
success: true,
message: "Sukses",

View File

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

View File

@@ -2,6 +2,7 @@ import { prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth";
import _, { ceil } from "lodash";
import { NextResponse } from "next/server";
import moment from "moment";
export async function GET(request: Request) {
try {
@@ -38,10 +39,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -54,10 +55,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -102,10 +103,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -117,10 +118,10 @@ export async function GET(request: Request) {
DivisionProjectTask: {
some: {
dateStart: {
gte: new Date(String(date))
gte: moment(String(date)).startOf('day').toDate()
},
dateEnd: {
lte: new Date(String(dateAkhir))
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -171,8 +172,8 @@ export async function GET(request: Request) {
idGroup: String(grup)
},
createdAt: {
gte: new Date(String(date)),
lte: new Date(String(dateAkhir))
gte: moment(String(date)).startOf('day').toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
},
}
} else {
@@ -181,8 +182,8 @@ export async function GET(request: Request) {
category: 'FILE',
idDivision: String(division),
createdAt: {
gte: new Date(String(date)),
lte: new Date(String(dateAkhir))
gte: moment(String(date)).startOf('day').toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
},
}
}
@@ -252,8 +253,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gte: new Date(String(date)),
lte: new Date()
gte: moment(String(date)).startOf('day').toDate(),
lte: moment().toDate()
}
}
}
@@ -267,8 +268,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gt: new Date(),
lte: new Date(String(dateAkhir))
gt: moment().toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}
@@ -293,8 +294,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gte: new Date(String(date)),
lte: new Date()
gte: moment(String(date)).startOf('day').toDate(),
lte: moment().toDate()
}
}
}
@@ -306,8 +307,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: {
some: {
dateStart: {
gt: new Date(),
lte: new Date(String(dateAkhir))
gt: moment().toDate(),
lte: moment(String(dateAkhir)).endOf('day').toDate()
}
}
}

View File

@@ -44,7 +44,8 @@ export async function GET(request: Request, context: { params: { id: string } })
},
Village:{
select:{
name:true
name:true,
isActive:true,
}
}
},
@@ -57,8 +58,9 @@ export async function GET(request: Request, context: { params: { id: string } })
const phone = users?.phone.substr(2)
const role = users?.UserRole.name
const village = users?.Village.name
const villageIsActive = users?.Village.isActive
const result = { ...userData, group, position, idUserRole, phone, role, village };
const result = { ...userData, group, position, idUserRole, phone, role, village, villageIsActive };
const omitData = _.omit(result, ["Group", "Position", "UserRole", "Village"]);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

11
src/lib/formatDateTime.ts Normal file
View File

@@ -0,0 +1,11 @@
function formatDateTime(date: Date) {
return new Intl.DateTimeFormat('id-ID', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
}
export default formatDateTime

38
src/lib/timeAgo.ts Normal file
View File

@@ -0,0 +1,38 @@
function timeAgo(date: Date) {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
// 🔥 cek apakah masih hari yang sama
const isToday =
now.getDate() === d.getDate() &&
now.getMonth() === d.getMonth() &&
now.getFullYear() === d.getFullYear();
if (isToday) {
if (seconds < 60) return `${seconds} detik lalu`;
if (minutes < 60) return `${minutes} menit lalu`;
return `${hours} jam lalu`;
}
// 🔥 kalau bukan hari ini → tampil tanggal + jam
const time = d.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
});
const datePart = d.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
return `${time} ${datePart}`;
}
export default timeAgo

View File

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

View File

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