Compare commits

...

28 Commits

Author SHA1 Message Date
d7e77da16a upd: api detail pengaduan jenna ai 2025-11-26 14:29:00 +08:00
decf6dd972 Merge pull request 'upd: dashboard admin' (#39) from amalia/26-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/39
2025-11-26 12:14:55 +08:00
5b72f1a9cc upd: dashboard admin
Deskripsi:
- login input
- login redirect sesuai dg akses
- tampilan jika tidak ada data ttd pada setting desa
- disable button pada list kategori pengaduan dg value id == lainnya
- disable button aksi pada list role dg value id == developer
- tidak menampilkan list data menu akses pada modal tambah dan edi role
- tampilan list permission pada table role
- order data permission yg telah terpilih sesuai dengan data json menu

NO Issues
2025-11-26 12:14:09 +08:00
acb5ae7cd1 Merge pull request 'amalia/25-nov-25' (#38) from amalia/25-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/38
2025-11-25 17:27:07 +08:00
6bdb0246c9 upd: api upload
Deskripsi:
- summary upload file form data

No Issues'
2025-11-25 16:47:47 +08:00
3f68f212cd upd: api jenna ai
Deskripsi:
- api upsert warga pada create pengaduan
- tampilan detail pengaduan jika tidak ada gambar

NO Issues
2025-11-25 16:17:44 +08:00
bipproduction
e0236a907f tambahan 2025-11-25 15:11:22 +08:00
e4189d40e9 Merge pull request 'amalia/25-nov-25' (#37) from amalia/25-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/37
2025-11-25 15:01:35 +08:00
94e7604afb upd: api jenna ai
Deskripsi:
- tambah pengaduan

NO Issues
2025-11-25 15:01:01 +08:00
a253d40d19 upd: api jenna ai
Deskripsi:
- tambah pengaduan

NO Issues
2025-11-25 14:58:40 +08:00
26c7357ca3 Merge pull request 'amalia/25-nov-25' (#36) from amalia/25-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/36
2025-11-25 14:05:46 +08:00
15c5140902 upd: dashboard admin
Deskripsi:
- nama field pada modal edit dan tambah role user

No Issues
2025-11-25 12:17:03 +08:00
c5b1452955 upd: dashboard admin
Deskripsi:
- tambah role user
- edit role user

No Issues
2025-11-25 12:15:29 +08:00
e1431fafb2 Merge pull request 'amalia/24-nov-25' (#35) from amalia/24-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/35
2025-11-24 17:41:22 +08:00
ad7b40523c upd: dashboard admin
Deskripsi:
- tambah role user
- api edit tambah dan delete role user

NO Issues
2025-11-24 17:40:27 +08:00
10db3f922e up: dashboard admin
Deskripsi:
- akses role pada menu dashboard
- akses role pada setting
- akses role pada pelayanan surat
- akses role pada pengaduan warga
- akses role pada warga

NO Issues
2025-11-24 16:27:35 +08:00
0a3afb7b9c upd: dashboard admin
Deskripsi:
- databse
- seeder
- list user role

NO Issues
2025-11-24 14:27:19 +08:00
c72ef5a755 fix: dashboard admin
Deskripsi
- list warga
- list pelayanan

No Issues
2025-11-24 11:15:14 +08:00
4c047324bc upd: dashboard admin
Deskripsi:
- view file seafile

No Issuesg
2025-11-24 10:56:28 +08:00
e4a03e3a8f Merge pull request 'amalia/21-nov-25' (#34) from amalia/21-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/34
2025-11-21 17:46:42 +08:00
41af733c6e upd: dashbaord admin/
Deksirps:
- format surat
- view file
- api

No Issues
2025-11-21 17:45:12 +08:00
bipproduction
436016641b tambahan 2025-11-21 14:33:25 +08:00
bipproduction
6fbddb3806 tambahan 2025-11-21 14:28:53 +08:00
bipproduction
eb1eaa11ea tambahan 2025-11-21 14:23:56 +08:00
bipproduction
54ae3b746d tambahan 2025-11-21 14:21:00 +08:00
bipproduction
7781882531 tambahan 2025-11-21 14:13:55 +08:00
558d8aaafb upd: dashboard admin
Deskripsi:
- ttd pada semua format surat
- fix api warga -- salah summary
- nama file surat saat download

No Issues
2025-11-21 12:13:02 +08:00
d7267abdb3 Merge pull request 'amalia/20-nov-25' (#33) from amalia/20-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/33
2025-11-20 17:32:19 +08:00
44 changed files with 3131 additions and 591 deletions

250
bak/mcp_route.ts.txt Normal file
View File

@@ -0,0 +1,250 @@
import { Elysia } from "elysia";
import { getMcpTools } from "../lib/mcp_tool_convert";
var tools = [] as any[];
const OPENAPI_URL = process.env.BUN_PUBLIC_BASE_URL + "/docs/json";
const FILTER_TAG = "mcp";
if (!process.env.BUN_PUBLIC_BASE_URL) {
throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set");
}
// =====================
// MCP Protocol Types
// =====================
type JSONRPCRequest = {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: any;
};
type JSONRPCResponse = {
jsonrpc: "2.0";
id: string | number;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
};
// =====================
// Tool Executor
// =====================
export async function executeTool(
tool: any,
args: Record<string, any> = {},
baseUrl: string
) {
const x = tool["x-props"] || {};
const method = (x.method || "GET").toUpperCase();
const path = x.path || `/${tool.name}`;
const url = `${baseUrl}${path}`;
const opts: RequestInit = {
method,
headers: { "Content-Type": "application/json" },
};
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
opts.body = JSON.stringify(args || {});
}
const res = await fetch(url, opts);
const contentType = res.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await res.json()
: await res.text();
return {
success: res.ok,
status: res.status,
method,
path,
data,
};
}
// =====================
// MCP Handler (Async)
// =====================
async function handleMCPRequestAsync(
request: JSONRPCRequest
): Promise<JSONRPCResponse> {
const { id, method, params } = request;
switch (method) {
case "initialize":
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "elysia-mcp-server", version: "1.0.0" },
},
};
case "tools/list":
return {
jsonrpc: "2.0",
id,
result: {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
},
};
case "tools/call": {
const toolName = params?.name;
const tool = tools.find((t) => t.name === toolName);
if (!tool) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool '${toolName}' not found` },
};
}
try {
const baseUrl =
process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
const result = await executeTool(tool, params?.arguments || {}, baseUrl);
const data = result.data.data;
const isObject = typeof data === "object" && data !== null;
return {
jsonrpc: "2.0",
id,
result: {
content: [
isObject
? { type: "json", data: data }
: { type: "text", text: JSON.stringify(data || result.data || result) },
],
},
};
} catch (error: any) {
return {
jsonrpc: "2.0",
id,
error: { code: -32603, message: error.message },
};
}
}
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method '${method}' not found` },
};
}
}
// =====================
// Elysia MCP Server
// =====================
export const MCPRoute = new Elysia({
tags: ["MCP Server"]
})
.post("/mcp", async ({ request, set }) => {
if (!tools.length) {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
try {
const body = await request.json();
if (!Array.isArray(body)) {
const res = await handleMCPRequestAsync(body);
return res;
}
const results = await Promise.all(
body.map((req) => handleMCPRequestAsync(req))
);
return results;
} catch (error: any) {
set.status = 400;
return {
jsonrpc: "2.0",
id: null,
error: {
code: -32700,
message: "Parse error",
data: error.message,
},
};
}
})
// Tools list (debug)
.get("/mcp/tools", async ({ set }) => {
if (!tools.length) {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Access-Control-Allow-Origin"] = "*";
return {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
};
})
// MCP status
.get("/mcp/status", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "active", timestamp: Date.now() };
})
// Health check
.get("/health", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "ok", timestamp: Date.now(), tools: tools.length };
})
.get("/mcp/init", async ({ set }) => {
const _tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
tools = _tools;
return {
success: true,
message: "MCP initialized",
tools: tools.length,
};
})
// CORS
.options("/mcp", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
})
.options("/mcp/tools", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
});

381
bak/mcp_tool_convert.ts.txt Normal file
View File

@@ -0,0 +1,381 @@
import _ from "lodash";
interface McpTool {
name: string;
description: string;
inputSchema: any;
"x-props": {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
/**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions.
*/
export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] {
const tools: McpTool[] = [];
if (!openApiJson || typeof openApiJson !== "object") {
console.warn("Invalid OpenAPI JSON");
return tools;
}
const paths = openApiJson.paths || {};
if (Object.keys(paths).length === 0) {
console.warn("No paths found in OpenAPI spec");
return tools;
}
for (const [path, methods] of Object.entries(paths)) {
if (!path || typeof path !== "string") continue;
if (path.startsWith("/mcp")) continue;
if (!methods || typeof methods !== "object") continue;
for (const [method, operation] of Object.entries<any>(methods)) {
const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"];
if (!validMethods.includes(method.toLowerCase())) continue;
if (!operation || typeof operation !== "object") continue;
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
if (!tags.length || !tags.some(t =>
typeof t === "string" && t.toLowerCase().includes(filterTag)
)) continue;
try {
const tool = createToolFromOperation(path, method, operation, tags);
if (tool) {
tools.push(tool);
}
} catch (error) {
console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error);
continue;
}
}
}
return tools;
}
/**
* Buat MCP tool dari operation OpenAPI
*/
function createToolFromOperation(
path: string,
method: string,
operation: any,
tags: string[]
): McpTool | null {
try {
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
if (!name || name === "unnamed_tool") {
console.warn(`Invalid tool name for ${method} ${path}`);
return null;
}
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
// ✅ Extract schema berdasarkan method
let schema;
if (method.toLowerCase() === "get") {
// ✅ Untuk GET, ambil dari parameters (query/path)
schema = extractParametersSchema(operation.parameters || []);
} else {
// ✅ Untuk POST/PUT/etc, ambil dari requestBody
schema = extractRequestBodySchema(operation);
}
const inputSchema = createInputSchema(schema);
return {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema,
};
} catch (error) {
console.error(`Failed to create tool from operation:`, error);
return null;
}
}
/**
* Extract schema dari parameters (untuk GET requests)
*/
function extractParametersSchema(parameters: any[]): any {
if (!Array.isArray(parameters) || parameters.length === 0) {
return null;
}
const properties: any = {};
const required: string[] = [];
for (const param of parameters) {
if (!param || typeof param !== "object") continue;
// ✅ Support path, query, dan header parameters
if (["path", "query", "header"].includes(param.in)) {
const paramName = param.name;
if (!paramName || typeof paramName !== "string") continue;
properties[paramName] = {
type: param.schema?.type || "string",
description: param.description || `${param.in} parameter: ${paramName}`,
};
// ✅ Copy field tambahan dari schema
if (param.schema) {
const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"];
for (const field of allowedFields) {
if (param.schema[field] !== undefined) {
properties[paramName][field] = param.schema[field];
}
}
}
if (param.required === true) {
required.push(paramName);
}
}
}
if (Object.keys(properties).length === 0) {
return null;
}
return {
type: "object",
properties,
required,
};
}
/**
* Extract schema dari requestBody (untuk POST/PUT/etc requests)
*/
function extractRequestBodySchema(operation: any): any {
if (!operation.requestBody?.content) {
return null;
}
const content = operation.requestBody.content;
const contentTypes = [
"application/json",
"multipart/form-data",
"application/x-www-form-urlencoded",
"text/plain",
];
for (const contentType of contentTypes) {
if (content[contentType]?.schema) {
return content[contentType].schema;
}
}
for (const [_, value] of Object.entries<any>(content)) {
if (value?.schema) {
return value.schema;
}
}
return null;
}
/**
* Buat input schema yang valid untuk MCP
*/
function createInputSchema(schema: any): any {
const defaultSchema = {
type: "object",
properties: {},
additionalProperties: false,
};
if (!schema || typeof schema !== "object") {
return defaultSchema;
}
try {
const properties: any = {};
const required: string[] = [];
const originalRequired = Array.isArray(schema.required) ? schema.required : [];
if (schema.properties && typeof schema.properties === "object") {
for (const [key, prop] of Object.entries<any>(schema.properties)) {
if (!key || typeof key !== "string") continue;
try {
const cleanProp = cleanProperty(prop);
if (cleanProp) {
properties[key] = cleanProp;
// ✅ PERBAIKAN: Check optional flag dengan benar
const isOptional = prop?.optional === true || prop?.optional === "true";
const isInRequired = originalRequired.includes(key);
// ✅ Hanya masukkan ke required jika memang required DAN bukan optional
if (isInRequired && !isOptional) {
required.push(key);
}
}
} catch (error) {
console.error(`Error cleaning property ${key}:`, error);
continue;
}
}
}
return {
type: "object",
properties,
required,
additionalProperties: false,
};
} catch (error) {
console.error("Error creating input schema:", error);
return defaultSchema;
}
}
/**
* Bersihkan property dari field custom
*/
function cleanProperty(prop: any): any | null {
if (!prop || typeof prop !== "object") {
return { type: "string" };
}
try {
const cleaned: any = {
type: prop.type || "string",
};
const allowedFields = [
"description",
"examples",
"example",
"default",
"enum",
"pattern",
"minLength",
"maxLength",
"minimum",
"maximum",
"format",
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
];
for (const field of allowedFields) {
if (prop[field] !== undefined && prop[field] !== null) {
cleaned[field] = prop[field];
}
}
if (prop.properties && typeof prop.properties === "object") {
cleaned.properties = {};
for (const [key, value] of Object.entries(prop.properties)) {
const cleanedNested = cleanProperty(value);
if (cleanedNested) {
cleaned.properties[key] = cleanedNested;
}
}
if (Array.isArray(prop.required)) {
cleaned.required = prop.required.filter((r: any) => typeof r === "string");
}
}
if (prop.items) {
cleaned.items = cleanProperty(prop.items);
}
if (Array.isArray(prop.oneOf)) {
cleaned.oneOf = prop.oneOf.map(cleanProperty).filter(Boolean);
}
if (Array.isArray(prop.anyOf)) {
cleaned.anyOf = prop.anyOf.map(cleanProperty).filter(Boolean);
}
if (Array.isArray(prop.allOf)) {
cleaned.allOf = prop.allOf.map(cleanProperty).filter(Boolean);
}
return cleaned;
} catch (error) {
console.error("Error cleaning property:", error);
return null;
}
}
/**
* Bersihkan nama tool
*/
function cleanToolName(name: string): string {
if (!name || typeof name !== "string") {
return "unnamed_tool";
}
try {
return name
.replace(/[{}]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.replace(/^(get|post|put|delete|patch|api)_/i, "")
.replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "")
.replace(/(^_|_$)/g, "")
|| "unnamed_tool";
} catch (error) {
console.error("Error cleaning tool name:", error);
return "unnamed_tool";
}
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
*/
export async function getMcpTools(url: string, filterTag: string): Promise<McpTool[]> {
try {
console.log(`Fetching OpenAPI spec from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const openApiJson = await response.json();
const tools = convertOpenApiToMcpTools(openApiJson, filterTag);
console.log(`✅ Successfully generated ${tools.length} MCP tools`);
return tools;
} catch (error) {
console.error("Error fetching MCP tools:", error);
throw error;
}
}

View File

@@ -9,11 +9,13 @@ datasource db {
}
model Role {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User[]
id String @id @default(cuid())
name String
permissions Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User[]
}
model User {

View File

@@ -1,5 +1,6 @@
import { categoryPelayananSurat } from "@/lib/categoryPelayananSurat";
import { confDesa } from "@/lib/configurationDesa";
import permissionConfig from "@/lib/listPermission.json"; // JSON yang kita buat
import { prisma } from "@/server/lib/prisma";
const category = [
@@ -29,14 +30,6 @@ const role = [
{
id: "developer",
name: "developer"
},
{
id: "admin",
name: "admin"
},
{
id: "pelaksana",
name: "pelaksana"
}
]
@@ -51,11 +44,30 @@ const user = [
];
(async () => {
const allKeys: string[] = [];
function collectKeys(items: any[]) {
items.forEach((item) => {
allKeys.push(item.key);
if (item.children) collectKeys(item.children);
});
}
collectKeys(permissionConfig.menus);
for (const r of role) {
await prisma.role.upsert({
where: { id: r.id },
create: r,
update: r
create: {
id: r.id,
name: r.name,
permissions: allKeys as any,
},
update: {
name: r.name,
permissions: allKeys as any,
}
})
console.log(`✅ Role ${r.name} seeded successfully`)

View File

@@ -16,13 +16,14 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useState } from "react";
import useSWR from "swr";
import ModalFile from "./ModalFile";
import notification from "./notificationGlobal";
import _ from "lodash";
export default function DesaSetting() {
export default function DesaSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(false);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
@@ -50,7 +51,8 @@ export default function DesaSetting() {
let finalData = { ...dataEdit }; // ← buffer data terbaru
if (dataEdit.name === "TTD") {
const resImg = await apiFetch.api.pengaduan.upload.post({ file: img });
const oldImg = await apiFetch.api.pengaduan["delete-image"].post({ file: dataEdit.value, folder: "lainnya" });
const resImg = await apiFetch.api.pengaduan.upload.post({ file: img, folder: "lainnya" });
if (resImg.status === 200) {
finalData = {
@@ -176,7 +178,7 @@ export default function DesaSetting() {
<ModalFile
open={openedPreview && !_.isEmpty(viewImg)}
onClose={() => setOpenedPreview(false)}
folder="syarat-dokumen"
folder="lainnya"
fileName={viewImg}
/>
@@ -204,20 +206,24 @@ export default function DesaSetting() {
{
v.name == "TTD"
?
<Anchor href="#" onClick={() => { setViewImg(v.value); setOpenedPreview(true); }} underline="always">
Lihat
</Anchor>
v.value ?
<Anchor href="#" onClick={() => { setViewImg(v.value); setOpenedPreview(true); }} underline="always">
Lihat
</Anchor>
:
"-"
:
v.value
}
</Table.Td>
<Table.Td>
<Tooltip label="Edit Setting">
<Tooltip label={permissions.includes("setting.desa.edit") ? "Edit Setting" : "Edit Setting - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.desa.edit")}
>
<IconEdit size={20} />
</ActionIcon>

View File

@@ -18,11 +18,12 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPelayananSurat() {
export default function KategoriPelayananSurat({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [openedDetail, { open: openDetail, close: closeDetail }] =
@@ -52,6 +53,7 @@ export default function KategoriPelayananSurat() {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
@@ -533,15 +535,19 @@ export default function KategoriPelayananSurat() {
<Title order={4} c="gray.2">
Kategori Pelayanan Surat
</Title>
<Tooltip label="Tambah Kategori Pelayanan Surat">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
{
permissions.includes("setting.kategori_pelayanan.tambah") && (
<Tooltip label="Tambah Kategori Pelayanan Surat">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
@@ -572,7 +578,7 @@ export default function KategoriPelayananSurat() {
<IconEye size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit Kategori">
<Tooltip label={permissions.includes("setting.kategori_pelayanan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -581,11 +587,12 @@ export default function KategoriPelayananSurat() {
setDataChoose(v);
open();
}}
disabled={!permissions.includes("setting.kategori_pelayanan.edit")}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Kategori">
<Tooltip label={permissions.includes("setting.kategori_pelayanan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -595,6 +602,7 @@ export default function KategoriPelayananSurat() {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes("setting.kategori_pelayanan.delete")}
>
<IconTrash size={20} />
</ActionIcon>

View File

@@ -15,11 +15,12 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPengaduan() {
export default function KategoriPengaduan({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [btnDisable, setBtnDisable] = useState(true);
@@ -293,15 +294,19 @@ export default function KategoriPengaduan() {
<Title order={4} c="gray.2">
Kategori Pengaduan
</Title>
<Tooltip label="Tambah Kategori Pengaduan">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
{
permissions.includes("setting.kategori_pengaduan.tambah") && (
<Tooltip label="Tambah Kategori Pengaduan">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
@@ -318,17 +323,18 @@ export default function KategoriPengaduan() {
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Edit Kategori">
<Tooltip label={permissions.includes("setting.kategori_pengaduan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.kategori_pengaduan.edit") || v.id == "lainnya"}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Kategori">
<Tooltip label={permissions.includes("setting.kategori_pengaduan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -338,6 +344,7 @@ export default function KategoriPengaduan() {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes("setting.kategori_pengaduan.delete") || v.id == "lainnya"}
>
<IconTrash size={20} />
</ActionIcon>

View File

@@ -1,10 +1,12 @@
import { detectFileType } from "@/server/lib/detect-type-of-file";
import { Flex, Image, Loader, Modal } from "@mantine/core";
import { useEffect, useState } from "react";
import notification from "./notificationGlobal";
export default function ModalFile({ open, onClose, folder, fileName }: { open: boolean, onClose: () => void, folder: string, fileName: string }) {
const [viewImg, setViewImg] = useState<string>("");
const [viewFile, setViewFile] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [typeFile, setTypeFile] = useState<string>("");
useEffect(() => {
if (open && fileName) {
@@ -12,12 +14,18 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
}
}, [open, fileName]);
const loadImage = async () => {
try {
setViewImg("");
setViewFile("");
setLoading(true);
// detect type of file
const { ext, type } = detectFileType(fileName);
setTypeFile(type || "");
// load file
const urlApi = '/api/pengaduan/image?folder=' + folder + '&fileName=' + fileName;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
@@ -28,7 +36,7 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
setViewFile(url);
} catch (err) {
console.error("Gagal load gambar:", err);
} finally {
@@ -43,7 +51,7 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
opened={open}
onClose={onClose}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size="lg"
size="xl"
withCloseButton
removeScrollProps={{ allowPinchZoom: true }}
title="File"
@@ -53,13 +61,19 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
<Loader />
</Flex>
)}
{viewImg && (
<Image
radius="md"
h={300}
fit="contain"
src={viewImg}
/>
{viewFile && (
<>
{typeFile == "pdf" ? (
<embed src={viewFile} type="application/pdf" width="100%" height="950" />
) : (
<Image
radius="md"
h={300}
fit="contain"
src={viewFile}
/>
)}
</>
)}
</Modal>
);

View File

@@ -44,7 +44,6 @@ export default function ModalSurat({ open, onClose, surat }: { open: boolean, on
const downloadPDF = async () => {
const element = hiddenRef.current;
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
@@ -64,7 +63,7 @@ export default function ModalSurat({ open, onClose, surat }: { open: boolean, on
pdf.addImage(imgData, "JPEG", 0, 0, imgWidth, imgHeight);
pdf.save("surat-keterangan-usaha.pdf");
pdf.save(`${data?.data?.surat?.nameCategory}.pdf`);
};
return (

View File

@@ -0,0 +1,67 @@
import { groupPermissions } from "@/lib/groupPermission";
import { Anchor, Flex, Stack, Text } from "@mantine/core";
import { useState } from "react";
interface Node {
label: string;
children: any;
actions: string[];
}
function RenderNode({ node }: { node: Node }) {
const sub = Object.values(node.children || {});
return (
<Stack pl="md" gap={6}>
{/* Title */}
<Text size="sm">- {node.label}</Text>
{/* Children */}
{sub.map((child: any, i) => (
<RenderNode key={i} node={child} />
))}
</Stack>
);
}
function RenderNode2({ node }: { node: Node }) {
const sub = Object.values(node.children || {});
return (
<Flex direction={"row"} wrap={'wrap'} gap={6}>
{/* Title */}
<Text size="sm">{node.label},</Text>
{/* Children */}
{sub.map((child: any, i) => (
<RenderNode2 key={i} node={child} />
))}
</Flex>
);
}
export default function PermissionRole({ permissions }: { permissions: string[] }) {
const [showAll, setShowAll] = useState(false);
if (!permissions?.length) return <Text c="dimmed">-</Text>;
const groups = groupPermissions(permissions);
const rootNodes = Object.values(groups);
return (
<Stack gap="sm">
{
showAll ?
rootNodes.map((node: any, idx) => (
<RenderNode key={idx} node={node} />
))
:
rootNodes.slice(0, 2).map((node: any, idx) => (
<RenderNode2 key={idx} node={node} />
))
}
<Anchor size="xs" onClick={() => setShowAll(!showAll)} >
{showAll ? "View less" : "View more"}
</Anchor>
</Stack>
);
}

View File

@@ -0,0 +1,177 @@
import permissionConfig from "@/lib/listPermission.json";
import { ActionIcon, Checkbox, Collapse, Group, Stack, Text } from "@mantine/core";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { useState } from "react";
interface Node {
label: string;
key: string;
children?: Node[];
}
export default function PermissionTree({
selected,
onChange,
}: {
selected: string[];
onChange: (val: string[]) => void;
}) {
// Ambil semua child dari node
const [openNodes, setOpenNodes] = useState<Record<string, boolean>>({});
function toggleNode(label: string) {
setOpenNodes(prev => ({ ...prev, [label]: !prev[label] }));
}
function getAllChildKeys(node: Node): string[] {
let result: string[] = [];
if (node.children) {
node.children.forEach((c) => {
result.push(c.key);
result = [...result, ...getAllChildKeys(c)];
});
}
return result;
}
// Dapatkan parentKey, jika ada
function getParentKey(key: string) {
const split = key.split(".");
if (split.length <= 1) return null;
split.pop();
return split.join(".");
}
// Update parent ke atas secara rekursif
function updateParent(next: string[], parentKey: string | null): string[] {
if (!parentKey) return next;
const allChildKeys = findAllChildKeysFromKey(parentKey);
const selectedChild = allChildKeys.filter((c) => next.includes(c));
if (selectedChild.length === 0) {
// Semua child uncheck → parent uncheck
next = next.filter((x) => x !== parentKey);
} else if (selectedChild.length === allChildKeys.length) {
// Semua child check → parent check
if (!next.includes(parentKey)) {
next.push(parentKey);
}
} else {
// Sebagian child check → parent intermediate (checked = true, rendered sebagai indeterminate)
if (!next.includes(parentKey)) {
next.push(parentKey);
}
}
// Rekursif naik ke atas
return updateParent(next, getParentKey(parentKey));
}
// dapatkan child dari string key
function findAllChildKeysFromKey(parentKey: string) {
const list: string[] = [];
function traverse(nodes: Node[]) {
nodes.forEach((n) => {
if (n.key.startsWith(parentKey + ".") && n.key !== parentKey) {
list.push(n.key);
}
if (n.children) traverse(n.children);
});
}
traverse(permissionConfig.menus);
return list;
}
const RenderMenu = ({ menu }: { menu: Node }) => {
const hasChild = menu.children && menu.children.length > 0;
const open = openNodes[menu.label] ?? false;
const childKeys = getAllChildKeys(menu);
const isChecked = selected.includes(menu.key);
const isIndeterminate =
!isChecked &&
selected.some(
(x) =>
typeof x === "string" &&
x.startsWith(menu.key + ".")
);
function handleCheck() {
let next = [...selected];
if (childKeys.length > 0) {
// klik parent
if (!isChecked) {
next = [...new Set([...next, menu.key, ...childKeys])];
} else {
next = next.filter((x) => x !== menu.key && !childKeys.includes(x));
}
next = updateParent(next, getParentKey(menu.key));
onChange(next);
return;
}
// klik child
if (isChecked) {
next = next.filter((x) => x !== menu.key);
} else {
next.push(menu.key);
}
next = updateParent(next, getParentKey(menu.key));
onChange(next);
}
return (
<Stack gap={4}>
<Group gap="xs">
{menu.children && menu.children.length > 0 ? (
<ActionIcon
variant="subtle"
onClick={() => toggleNode(menu.label)}
>
{openNodes[menu.label] ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</ActionIcon>
) : (
<div style={{ width: 28 }} />
)}
<Checkbox
label={menu.label}
checked={isChecked}
indeterminate={isIndeterminate}
onChange={handleCheck}
/>
</Group>
{menu.children && (
<Collapse in={open}>
<Stack gap={4} pl="md">
{menu.children.map((child) => (
<RenderMenu key={child.key} menu={child} />
))}
</Stack>
</Collapse>
)}
</Stack>
);
};
return (
<Stack>
<Text size="sm">Hak Akses</Text>
{permissionConfig.menus.filter((menu: Node) => !menu.key.startsWith("api") && !menu.key.startsWith("credential")).map((menu: Node) => (
<RenderMenu key={menu.key} menu={menu} />
))}
</Stack>
);
}

View File

@@ -9,10 +9,11 @@ import {
Stack,
Title,
} from "@mantine/core";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import notification from "./notificationGlobal";
export default function ProfileUser() {
export default function ProfileUser({ permissions }: { permissions: JsonValue[] }) {
const [opened, setOpened] = useState(false);
const [openedPassword, setOpenedPassword] = useState(false);
const [pwdBaru, setPwdBaru] = useState("");
@@ -126,12 +127,21 @@ export default function ProfileUser() {
Profile Pengguna
</Title>
<Group gap="md">
<Button variant="light" onClick={() => setOpened(true)}>
Edit
</Button>
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
{
permissions.includes("setting.profile.edit") && (
<Button variant="light" onClick={() => setOpened(true)}>
Edit
</Button>
)
}
{
permissions.includes("setting.profile.password") && (
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
)
}
</Group>
</Flex>
<Divider my={0} />

View File

@@ -0,0 +1,427 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Text,
Title,
Tooltip
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import listMenu from "../lib/listPermission.json";
import notification from "./notificationGlobal";
import PermissionRole from "./PermissionRole";
import PermissionTree from "./PermissionTree";
interface MenuNode {
key: string;
label: string;
default: boolean;
children?: MenuNode[];
}
export default function UserRoleSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const {
data: dataRole,
mutate: mutateRole,
isLoading: isLoadingRole,
} = useSWR("user-role", () => apiFetch.api.user.role.get());
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const { data, mutate, isLoading } = useSWR("role-list", () =>
apiFetch.api.user.role.get(),
);
const list = data?.data || [];
const listRole = dataRole?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
permissions: [],
});
const [dataTambah, setDataTambah] = useState({
name: "",
permissions: [],
});
const [error, setError] = useState({
name: false,
permissions: false,
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-create"].post(dataTambah as any);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
permissions: [],
});
notification({
title: "Success",
message: "Your role have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-update"].post(dataEdit as any);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your role have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-delete"].post({ id: dataDelete });
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your role have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({ data }: { data: { id: string; name: string; permissions: []; }; }) {
setDataEdit({
id: data.id, name: data.name, permissions: data.permissions ? data.permissions : []
});
open();
}
function onValidation({ kat, value, aksi, }: { kat: "name" | "permission"; value: string | null; aksi: "edit" | "tambah"; }) {
if (value == null || value.length < 1) {
setBtnDisable(true);
setError({ ...error, [kat]: true });
} else {
setBtnDisable(false);
setError({ ...error, [kat]: false });
}
if (aksi === "edit") {
setDataEdit({ ...dataEdit, [kat]: value });
} else {
setDataTambah({ ...dataTambah, [kat]: value });
}
}
function buildOrderList(menus: MenuNode[]): string[] {
const list: string[] = [];
const traverse = (nodes: MenuNode[]) => {
nodes.forEach((node) => {
list.push(node.key);
if (node.children) traverse(node.children);
});
};
traverse(menus);
return list;
}
function sortByJsonOrder(arrayData: string[]): string[] {
const orderList = buildOrderList(listMenu.menus);
return arrayData.sort((a, b) => {
return orderList.indexOf(a) - orderList.indexOf(b);
});
}
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="ld">
<Input.Wrapper label="Nama Role">
<Input
value={dataEdit.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<PermissionTree
selected={dataEdit.permissions}
onChange={(permissions) => {
setDataEdit({ ...dataEdit, permissions: sortByJsonOrder(permissions) as never[] });
}}
/>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={
btnDisable ||
dataEdit.name.length < 1 ||
dataEdit.permissions?.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="ld">
<Input.Wrapper
label="Nama Role"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={dataTambah.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<PermissionTree
selected={dataTambah.permissions}
onChange={(permissions) => {
setDataTambah({ ...dataTambah, permissions: sortByJsonOrder(permissions) as never[] });
}}
/>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={
btnDisable ||
dataTambah.name.length < 1 ||
dataTambah.permissions.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus role ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Daftar Role
</Title>
{
permissions.includes('setting.user_role.tambah') && (
<Tooltip label="Tambah Role">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Role</Table.Th>
<Table.Th>Permission</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list.length > 0 ? (
list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td w={"150"}>{v.name}</Table.Td>
<Table.Td>
<PermissionRole permissions={v.permissions} />
</Table.Td>
<Table.Td w={"100"}>
<Group>
<Tooltip label={permissions.includes('setting.user_role.edit') ? "Edit Role" : "Edit Role - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user_role.edit') || v.id == "developer"}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes('setting.user_role.delete') ? "Delete Role" : "Delete Role - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes('setting.user_role.delete') || v.id == "developer"}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5} align="center">
Data Role Tidak Ditemukan
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -16,11 +16,12 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function UserSetting() {
export default function UserSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
@@ -106,19 +107,19 @@ export default function UserSetting() {
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.update.post(dataEdit);
const res = await apiFetch.api.user.update.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
message: "Your data have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
message: "Failed to edit user",
type: "error",
});
}
@@ -126,7 +127,7 @@ export default function UserSetting() {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
message: "Failed to edit user2",
type: "error",
});
} finally {
@@ -221,9 +222,10 @@ export default function UserSetting() {
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Edit Kategori">
<Input.Wrapper label="Nama">
<Input
value={dataEdit.name}
error={error.name ? "Field is required" : ""}
onChange={(e) =>
onValidation({
kat: "name",
@@ -233,6 +235,51 @@ export default function UserSetting() {
}
/>
</Input.Wrapper>
<Select
label="Role"
placeholder="Pilih Role"
data={listRole.map((r: any) => ({
value: r.id,
label: r.name,
}))}
value={dataEdit.roleId || null}
error={error.roleId ? "Field is required" : ""}
onChange={(_value, option) => {
onValidation({
kat: "roleId",
value: option?.value,
aksi: "edit",
});
}}
/>
<Input.Wrapper label="Phone" description="">
<Input
value={dataEdit.phone}
onChange={(e) =>
onValidation({
kat: "phone",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={dataEdit.email}
onChange={(e) =>
onValidation({
kat: "email",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
@@ -390,15 +437,20 @@ export default function UserSetting() {
<Title order={4} c="gray.2">
Daftar User
</Title>
<Tooltip label="Tambah User">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
{
permissions.includes('setting.user.tambah') && (
<Tooltip label="Tambah User">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
@@ -422,17 +474,18 @@ export default function UserSetting() {
<Table.Td>{v.roleId}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Edit User">
<Tooltip label={permissions.includes('setting.user.edit') ? "Edit User" : "Edit User - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user.edit') || v.roleId == "developer"}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete User">
<Tooltip label={permissions.includes('setting.user.delete') ? "Delete User" : "Delete User - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -442,6 +495,7 @@ export default function UserSetting() {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes('setting.user.delete') || v.roleId == "developer"}
>
<IconTrash size={20} />
</ActionIcon>

View File

@@ -1,11 +1,42 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKBedaBiodataDiri({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>();
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.25" }}>
{/* HEADER */}
@@ -113,13 +144,12 @@ export default function SKBedaBiodataDiri({ data }: { data: any }) {
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "30px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ marginTop: "0px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br /><br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br /><br /><br />
{data.setting.perbekelNama} <br />
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>

View File

@@ -1,11 +1,41 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKBelumKawin({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>();
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
@@ -62,13 +92,14 @@ export default function SKBelumKawin({ data }: { data: any }) {
<div style={{ textAlign: "center" }}>
<br /><br />
Pemohon
<br /><br /><br /><br />
<br /><br /><br /><br /><br /><br />
<u>{getValue("nama")}</u> <br />
</div>
<div style={{ textAlign: "center" }}>
<br /><br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br /><br /><br />
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>

View File

@@ -1,11 +1,41 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKDomisiliOrganisasi({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
@@ -79,11 +109,11 @@ export default function SKDomisiliOrganisasi({ data }: { data: any }) {
{/* TANDA TANGAN */}
<div style={{ marginTop: "40px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br /><br />
<br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br /><br /><br />
{data.setting.perbekelNama} <br />
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>

View File

@@ -1,11 +1,41 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKKelahiran({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.2" }}>
@@ -102,8 +132,9 @@ export default function SKKelahiran({ data }: { data: any }) {
<div style={{ marginTop: "40px", width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br /><br /><br />
{data.setting.perbekelNama} <br />
<br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>

View File

@@ -1,11 +1,41 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKKelakuanBaik({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
@@ -103,8 +133,9 @@ export default function SKKelakuanBaik({ data }: { data: any }) {
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa {data.setting.desaNama}
<br /><br /><br /><br />
{data.setting.perbekelNama}<br />
<br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u><br />
NIP. {data.setting.perbekelNIP}
</div>
</div>

View File

@@ -1,11 +1,41 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKKematian({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
@@ -72,13 +102,14 @@ export default function SKKematian({ data }: { data: any }) {
<div style={{ textAlign: "center" }}>
<br /><br />
Pemohon
<br /><br /><br /><br />
<br /><br /><br /><br /> <br />
<u>{getValue("nama")}</u> <br />
</div>
<div style={{ textAlign: "center" }}>
<br /><br />
<br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br /><br /><br />
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>

View File

@@ -1,11 +1,41 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKPenghasilan({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (jenis: string) =>
_.upperFirst(
data.surat.dataText.find((item: any) => item.jenis === jenis)?.value || ""
);
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
{/* HEADER */}
@@ -102,8 +132,9 @@ export default function SKPenghasilan({ data }: { data: any }) {
<div style={{ marginTop: "40px", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br /><br /><br />
{data.setting.perbekelNama} <br />
<br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>

View File

@@ -1,9 +1,40 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKTempatUsaha({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (key: string) =>
_.upperFirst(data.surat.dataText.find((i: any) => i.jenis === key)?.value || "");
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.5" }}>
{/* TITLE */}
@@ -68,8 +99,8 @@ export default function SKTempatUsaha({ data }: { data: any }) {
{/* TANDA TANGAN */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
{data.setting.desaNama}, {data.surat.createdAt} <br /><br /><br />
{data.setting.desaKabupaten}, {data.surat.createdAt} <br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u><br />
{data.setting.perbekelJabatan + " " + data.setting.desaNama}
</div>

View File

@@ -1,9 +1,40 @@
import _ from "lodash";
import { useEffect, useState } from "react";
import notification from "../notificationGlobal";
export default function SKTidakMampu({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (key: string) =>
_.upperFirst(data.surat.dataText.find((i: any) => i.jenis === key)?.value || "");
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.5" }}>
{/* TITLE */}
@@ -59,8 +90,8 @@ export default function SKTidakMampu({ data }: { data: any }) {
{/* TANDA TANGAN */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
{data.setting.desaNama}, {data.surat.createdAt} <br /><br /><br />
{data.setting.desaKabupaten}, {data.surat.createdAt} <br /> <br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /><br />
<u>{data.setting.perbekelNama}</u><br />
{data.setting.perbekelJabatan + " " + data.setting.desaNama}
</div>

View File

@@ -14,7 +14,7 @@ export default function SKUsaha({ data }: { data: any }) {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=syarat-dokumen&fileName=' + data.setting.perbekelTTD;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
@@ -132,13 +132,12 @@ export default function SKUsaha({ data }: { data: any }) {
</div>
{/* TANDA TANGAN */}
<div style={{ marginTop: "20px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ marginTop: "10px", display: "flex", justifyContent: "flex-end", width: "100%" }}>
<div style={{ textAlign: "center" }}>
<br /><br />
<br />
Kepala Desa / Lurah {data.setting.desaNama}
<br /><br />
<img src={viewImg} alt="ttd perbekel" width={100} />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} />
<br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}

View File

@@ -1,10 +1,40 @@
import { useShallowEffect } from "@mantine/hooks";
import _ from "lodash";
import { useState } from "react";
import notification from "../notificationGlobal";
export default function SKYatim({ data }: { data: any }) {
const [viewImg, setViewImg] = useState<string>("");
const getValue = (key: string) =>
_.upperFirst(data.surat.dataText.find((i: any) => i.jenis === key)?.value || "");
const loadImage = async () => {
try {
setViewImg("");
if (!data.setting.perbekelTTD) return;
const urlApi = '/api/pengaduan/image?folder=lainnya&fileName=' + data.setting.perbekelTTD;
// Fetch manual agar mendapatkan Response asli
const res = await fetch(urlApi);
if (!res.ok)
return notification({
title: "Error",
message: "Failed to load image sign",
type: "error",
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewImg(url);
} catch (err) {
console.error("Gagal load gambar:", err);
}
};
useShallowEffect(() => {
loadImage();
}, [data]);
return (
<div style={{ lineHeight: "1.3" }}>
@@ -146,14 +176,15 @@ export default function SKYatim({ data }: { data: any }) {
</tbody>
</table>
<br /><br />
<br />
{/* TTD */}
<div style={{ width: "100%", display: "flex", justifyContent: "flex-end" }}>
<div style={{ textAlign: "center" }}>
Kepala Desa {data.setting.desaNama}
<br /><br /><br /><br />
{data.setting.perbekelNama} <br />
<br /><br />
<img src={viewImg || undefined} alt="ttd perbekel" width={100} /> <br />
<u>{data.setting.perbekelNama}</u> <br />
NIP. {data.setting.perbekelNIP}
</div>
</div>

View File

@@ -0,0 +1,59 @@
import config from "@/lib/listPermission.json";
export interface PermissionNode {
key: string;
label: string;
children?: PermissionNode[];
}
interface Grouped {
[key: string]: {
label: string;
children: Grouped;
actions: string[];
};
}
/* --- Build lookup table --- */
const permissionMap: Record<string, string[]> = {};
function walk(nodes: PermissionNode[], path: string[] = []) {
nodes.forEach((n) => {
const full = [...path, n.label];
permissionMap[n.key] = full;
if (n.children) walk(n.children, full);
});
}
walk(config.menus);
/* --- Convert keys → hierarchical grouped --- */
export function groupPermissions(keys: string[]) {
const tree: Grouped = {};
keys.forEach((key) => {
const path = permissionMap[key];
if (!path) return;
let pointer = tree;
path.forEach((label, idx) => {
if (!pointer[label]) {
pointer[label] = {
label,
children: {},
actions: []
};
}
// last item = actual permission action
if (idx === path.length - 1) {
pointer[label].actions.push(label);
}
pointer = pointer[label].children;
});
});
return tree;
}

310
src/lib/listPermission.json Normal file
View File

@@ -0,0 +1,310 @@
{
"menus": [
{
"key": "dashboard",
"label": "Dashboard",
"default": true,
"children": [
{
"key": "dashboard.view",
"label": "Melihat Dashboard",
"default": true
}
]
},
{
"key": "pengaduan",
"label": "Pengaduan",
"default": true,
"children": [
{
"key": "pengaduan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pengaduan.antrian",
"label": "Detail pengaduan dengan status antrian",
"default": true,
"children": [
{
"key": "pengaduan.antrian.tolak",
"label": "Menolak pengaduan",
"default": true
},
{
"key": "pengaduan.antrian.terima",
"label": "Menerima pengaduan",
"default": true
}
]
},
{
"key": "pengaduan.diterima",
"label": "Detail pengaduan dengan status diterima",
"default": true,
"children": [
{
"key": "pengaduan.diterima.dikerjakan",
"label": "Menegerjakan pengaduan",
"default": true
}
]
},
{
"key": "pengaduan.dikerjakan",
"label": "Detail pengaduan dengan status dikerjakan",
"default": true,
"children": [
{
"key": "pengaduan.dikerjakan.selesai",
"label": "Menyelesaikan pengaduan",
"default": true
}
]
}
]
},
{
"key": "pelayanan",
"label": "Pelayanan",
"default": true,
"children": [
{
"key": "pelayanan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pelayanan.antrian",
"label": "Detail pelayanan dengan status antrian",
"default": true,
"children": [
{
"key": "pelayanan.antrian.tolak",
"label": "Menolak pelayanan",
"default": true
},
{
"key": "pelayanan.antrian.terima",
"label": "Menerima pelayanan",
"default": true
}
]
},
{
"key": "pelayanan.diterima",
"label": "Detail pelayanan dengan status diterima",
"default": true,
"children": [
{
"key": "pelayanan.diterima.tolak",
"label": "Menolak pelayanan",
"default": true
},
{
"key": "pelayanan.diterima.setujui",
"label": "Menyetujui pelayanan",
"default": true
}
]
}
]
},
{
"key": "warga",
"label": "Warga",
"default": true,
"children": [
{
"key": "warga.view",
"label": "Melihat List & Detail",
"default": true
}
]
},
{
"key": "setting",
"label": "Setting",
"default": true,
"children": [
{
"key": "setting.profile",
"label": "Profile",
"default": true,
"children": [
{
"key": "setting.profile.view",
"label": "View",
"default": true
},
{
"key": "setting.profile.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.profile.password",
"label": "Ubah Password",
"default": true
}
]
},
{
"key": "setting.user",
"label": "User",
"default": true,
"children": [
{
"key": "setting.user.view",
"label": "View List",
"default": true
},
{
"key": "setting.user.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.user.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.user.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.user_role",
"label": "User Role",
"default": true,
"children": [
{
"key": "setting.user_role.view",
"label": "View List",
"default": true
},
{
"key": "setting.user_role.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.user_role.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.user_role.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.kategori_pengaduan",
"label": "Kategori Pengaduan",
"default": true,
"children": [
{
"key": "setting.kategori_pengaduan.view",
"label": "View List",
"default": true
},
{
"key": "setting.kategori_pengaduan.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.kategori_pengaduan.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.kategori_pengaduan.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.kategori_pelayanan",
"label": "Kategori Pelayanan Surat",
"default": true,
"children": [
{
"key": "setting.kategori_pelayanan.view",
"label": "View List",
"default": true
},
{
"key": "setting.kategori_pelayanan.detail",
"label": "View Detail",
"default": true
},
{
"key": "setting.kategori_pelayanan.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.kategori_pelayanan.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.kategori_pelayanan.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.desa",
"label": "Desa",
"default": true,
"children": [
{
"key": "setting.desa.view",
"label": "View List",
"default": true
},
{
"key": "setting.desa.edit",
"label": "Edit",
"default": true
}
]
}
]
},
{
"key": "api_key",
"label": "API Key",
"default": true,
"children": [
{
"key": "api_key.view",
"label": "View List",
"default": true
}
]
},
{
"key": "credential",
"label": "Credential",
"default": true,
"children": [
{
"key": "credential.view",
"label": "View List",
"default": true
}
]
}
]
}

View File

@@ -1,20 +1,50 @@
import clientRoutes from "@/clientRoutes";
import {
Button,
Container,
Group,
PasswordInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useState } from "react";
import apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
function navigateToRoute(akses: string) {
switch (akses) {
case "dashboard":
window.location.href = clientRoutes["/scr/dashboard/dashboard-home"];
break;
case "pengaduan":
window.location.href = clientRoutes["/scr/dashboard/pengaduan/list"];
break;
case "warga":
window.location.href = clientRoutes["/scr/dashboard/warga/list-warga"];
break;
case "credential":
window.location.href = clientRoutes["/scr/dashboard/credential/credential"];
break;
case "setting":
window.location.href = clientRoutes["/scr/dashboard/setting/detail-setting"];
break;
case "api_key":
window.location.href = clientRoutes["/scr/dashboard/apikey/apikey"];
break;
case "pelayanan":
window.location.href = clientRoutes["/scr/dashboard/pelayanan-surat/list-pelayanan"];
break;
default:
window.location.href = clientRoutes["/scr/dashboard"];
break;
}
}
const handleSubmit = async () => {
setLoading(true);
try {
@@ -25,7 +55,7 @@ export default function Login() {
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
window.location.href = clientRoutes["/scr/dashboard"];
navigateToRoute(response.data.akses || "dashboard");
return;
}
@@ -48,7 +78,7 @@ export default function Login() {
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextInput
<PasswordInput
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}

View File

@@ -35,6 +35,7 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
@@ -212,36 +213,54 @@ function HostView() {
function NavigationDashboard() {
const navigate = useNavigate();
const location = useLocation();
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchPermissions() {
const { data } = await apiFetch.api.user.find.get();
if (Array.isArray(data?.permissions)) {
setPermissions(data.permissions);
} else {
setPermissions([]);
}
}
fetchPermissions();
}, []);
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path]);
const navItems = [
{
key: "dashboard",
path: "/scr/dashboard/dashboard-home",
icon: <IconDashboard size={20} />,
label: "Dashboard Overview",
description: "Quick summary and insights",
},
{
key: "pengaduan",
path: "/scr/dashboard/pengaduan/list",
icon: <IconMessageReport size={20} />,
label: "Pengaduan Warga",
description: "Manage pengaduan warga",
},
{
key: "pelayanan",
path: "/scr/dashboard/pelayanan-surat/list-pelayanan",
icon: <IconFileCertificate size={20} />,
label: "Pelayanan Surat",
description: "Manage pelayanan surat",
},
{
key: "warga",
path: "/scr/dashboard/warga/list-warga",
icon: <IconUsersGroup size={20} />,
label: "Warga",
description: "Manage warga",
},
{
key: "setting",
path: "/scr/dashboard/setting/detail-setting",
icon: <IconSettings size={20} />,
label: "Setting",
@@ -249,12 +268,14 @@ function NavigationDashboard() {
"Manage setting (category pengaduan dan pelayanan surat, desa, etc)",
},
{
key: "api_key",
path: "/scr/dashboard/apikey/apikey",
icon: <IconKey size={20} />,
label: "API Key Manager",
description: "Create and manage API keys",
},
{
key: "credential",
path: "/scr/dashboard/credential/credential",
icon: <IconLock size={20} />,
label: "Credentials",
@@ -264,7 +285,7 @@ function NavigationDashboard() {
return (
<Stack gap="xs" p="sm">
{navItems.map((item) => (
{navItems.filter((item) => permissions.includes(item.key)).map((item) => (
<NavLink
key={item.path}
active={isActive(item.path as keyof typeof clientRoute)}

View File

@@ -31,6 +31,7 @@ import {
IconUser
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
@@ -76,11 +77,17 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
const [host, setHost] = useState<User | null>(null);
const [noSurat, setNoSurat] = useState("");
const [openedPreview, setOpenedPreview] = useState(false);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
if (data?.permissions && Array.isArray(data.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("pelayanan"));
setPermissions(onlySetting);
}
}
fetchHost();
}, []);
@@ -276,6 +283,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
data?.status === "antrian" ? (
<Group justify="center" grow>
<Button
disabled={!permissions.includes("pelayanan.antrian.tolak")}
variant="light"
onClick={() => {
setCatModal("tolak");
@@ -285,6 +293,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.antrian.terima")}
variant="filled"
onClick={() => {
setCatModal("terima");
@@ -297,6 +306,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
) : data?.status === "diterima" ? (
<Group justify="center" grow>
<Button
disabled={!permissions.includes("pelayanan.diterima.tolak")}
variant="light"
onClick={() => {
setCatModal("tolak");
@@ -306,6 +316,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.diterima.setujui")}
variant="filled"
onClick={() => {
setCatModal("terima");

View File

@@ -175,7 +175,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
}
/>
</Group>
{list?.length === 0 ? (
{Array.isArray(list) && list?.length === 0 ? (
<Flex justify="center" align="center" py={"xl"}>
<Stack gap={4} align="center">
<IconFileSad size={32} color="gray" />

View File

@@ -31,6 +31,7 @@ import {
IconUser,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
@@ -77,11 +78,17 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
useDisclosure(false);
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
if (data?.permissions && Array.isArray(data.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("pengaduan"));
setPermissions(onlySetting);
}
}
fetchHost();
}, []);
@@ -256,9 +263,18 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<IconPhotoScan size={20} />
<Text size="md">Gambar</Text>
</Group>
<Anchor href="#" onClick={() => { }}>
Lihat Gambar
</Anchor>
{
data?.image != null && data?.image != ""
?
<Anchor href="#" onClick={() => { }}>
Lihat Gambar
</Anchor>
:
<Text size="md" c="white">
-
</Text>
}
</Flex>
</Stack>
</Grid.Col>
@@ -294,6 +310,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<Group justify="center" grow>
<Button
variant="light"
disabled={!permissions.includes("pengaduan.antrian.tolak")}
onClick={() => {
setCatModal("tolak");
open();
@@ -303,6 +320,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
</Button>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.antrian.terima")}
onClick={() => {
setCatModal("terima");
open();
@@ -315,6 +333,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<Group justify="center" grow>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.diterima.dikerjakan")}
onClick={() => {
setCatModal("terima");
open();
@@ -327,6 +346,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<Group justify="center" grow>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.dikerjakan.selesai")}
onClick={() => {
setCatModal("terima");
open();

View File

@@ -2,7 +2,9 @@ import DesaSetting from "@/components/DesaSetting";
import KategoriPelayananSurat from "@/components/KategoriPelayananSurat";
import KategoriPengaduan from "@/components/KategoriPengaduan";
import ProfileUser from "@/components/ProfileUser";
import UserRoleSetting from "@/components/UserRoleSetting";
import UserSetting from "@/components/UserSetting";
import apiFetch from "@/lib/apiFetch";
import {
Card,
Container,
@@ -14,14 +16,78 @@ import {
IconCategory2,
IconMailSpark,
IconUserCog,
IconUsersGroup,
IconUserScreen,
IconUsersGroup
} from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
export default function DetailSettingPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const type = query.get("type");
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchPermissions() {
const { data } = await apiFetch.api.user.find.get();
if (Array.isArray(data?.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("setting"));
setPermissions(onlySetting);
} else {
setPermissions([]);
}
}
fetchPermissions();
}, []);
const navItems = [
{
key: "setting.profile",
path: "profile",
icon: <IconUserCog size={20} />,
label: "Profile",
description: "Manage profile settings",
},
{
key: "setting.user",
path: "user",
icon: <IconUsersGroup size={20} />,
label: "User",
description: "Manage user accounts",
},
{
key: "setting.user_role",
path: "role",
icon: <IconUserScreen size={20} />,
label: "Role",
description: "Manage user roles",
},
{
key: "setting.kategori_pengaduan",
path: "cat-pengaduan",
icon: <IconCategory2 size={20} />,
label: "Kategori Pengaduan",
description: "Manage complaint categories",
},
{
key: "setting.kategori_pelayanan",
path: "cat-pelayanan",
icon: <IconMailSpark size={20} />,
label: "Kategori Pelayanan Surat",
description: "Manage letter service categories",
},
{
key: "setting.desa",
path: "desa",
icon: <IconBuildingBank size={20} />,
label: "Desa",
description: "Manage desa information",
}
];
return (
<Container size="xl" py="xl" w={"100%"}>
@@ -38,36 +104,17 @@ export default function DetailSettingPage() {
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<NavLink
href={`?type=profile`}
label="Profile"
leftSection={<IconUserCog size={16} stroke={1.5} />}
active={type === "profile" || !type}
/>
<NavLink
href={`?type=user`}
label="User"
leftSection={<IconUsersGroup size={16} stroke={1.5} />}
active={type === "user"}
/>
<NavLink
href={`?type=cat-pengaduan`}
label="Kategori Pengaduan"
leftSection={<IconCategory2 size={16} stroke={1.5} />}
active={type === "cat-pengaduan"}
/>
<NavLink
href={`?type=cat-pelayanan`}
label="Kategori Pelayanan Surat"
leftSection={<IconMailSpark size={16} stroke={1.5} />}
active={type === "cat-pelayanan"}
/>
<NavLink
href={`?type=desa`}
label="Desa"
leftSection={<IconBuildingBank size={16} stroke={1.5} />}
active={type === "desa"}
/>
{
navItems.filter((item) => permissions.includes(item.key)).map((item) => (
<NavLink
key={item.key}
href={'?type=' + item.path}
label={item.label}
leftSection={item.icon}
active={type === item.path || (!type && item.path === 'profile')}
/>
))
}
</Card>
</Grid.Col>
<Grid.Col span={9}>
@@ -83,15 +130,17 @@ export default function DetailSettingPage() {
}}
>
{type === "cat-pengaduan" ? (
<KategoriPengaduan />
<KategoriPengaduan permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pengaduan"))} />
) : type === "cat-pelayanan" ? (
<KategoriPelayananSurat />
<KategoriPelayananSurat permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pelayanan"))} />
) : type === "desa" ? (
<DesaSetting />
<DesaSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.desa"))} />
) : type === "user" ? (
<UserSetting />
<UserSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.user."))} />
) : type === "role" ? (
<UserRoleSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.user_role"))} />
) : (
<ProfileUser />
<ProfileUser permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.profile"))} />
)}
</Card>
</Grid.Col>

View File

@@ -78,12 +78,12 @@ export default function ListWargaPage() {
</Table.Thead>
<Table.Tbody>
{
list?.length === 0 ? (
Array.isArray(list) && list?.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3} align="center">Tidak ada data</Table.Td>
</Table.Tr>
) : (
list?.map((item, i) => (
Array.isArray(list) && list?.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>{item.phone}</Table.Td>

View File

@@ -0,0 +1,25 @@
function getExtension(fileName: string): string | null {
if (!fileName || typeof fileName !== "string") return null;
const parts = fileName.split(".");
if (parts.length <= 1) return null;
return parts.pop()?.toLowerCase() || null;
}
export function detectFileType(fileName: string) {
const ext = getExtension(fileName);
if (!ext) return { ext: null, type: "unknown" };
if (["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
return { ext, type: "image" };
}
if (ext === "pdf") {
return { ext, type: "pdf" };
}
return { ext, type: "other" };
}

View File

@@ -16,8 +16,11 @@ interface McpTool {
/**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions.
* * @param openApiJson OpenAPI JSON specification object.
* @param filterTag A string or array of strings. Operations must match at least one tag
* (case-insensitive partial match).
*/
export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] {
export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string | string[]): McpTool[] {
const tools: McpTool[] = [];
if (!openApiJson || typeof openApiJson !== "object") {
@@ -25,6 +28,15 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M
return tools;
}
// Cast filterTag to an array and normalize to lowercase for comparison
const filterTags = _.castArray(filterTag)
.filter(t => typeof t === "string" && t.trim() !== "")
.map(t => t.toLowerCase());
if (filterTags.length === 0) {
console.warn("Filter tag is empty or invalid. Returning all tools with tags.");
}
const paths = openApiJson.paths || {};
if (Object.keys(paths).length === 0) {
@@ -34,7 +46,6 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M
for (const [path, methods] of Object.entries(paths)) {
if (!path || typeof path !== "string") continue;
if (path.startsWith("/mcp")) continue;
if (!methods || typeof methods !== "object") continue;
@@ -45,10 +56,19 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M
if (!operation || typeof operation !== "object") continue;
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
const lowerCaseTags = tags.map(t => typeof t === "string" ? t.toLowerCase() : "");
if (!tags.length || !tags.some(t =>
typeof t === "string" && t.toLowerCase().includes(filterTag)
)) continue;
// ✅ MODIFIKASI: Pengecekan filterTags
if (filterTags.length > 0) {
const isTagMatch = lowerCaseTags.some(opTag =>
filterTags.some(fTag => opTag.includes(fTag))
);
if (!isTagMatch) continue;
} else if (tags.length === 0) {
// Jika tidak ada filter, hanya proses operation yang memiliki tags
continue;
}
try {
const tool = createToolFromOperation(path, method, operation, tags);
@@ -75,18 +95,20 @@ function createToolFromOperation(
tags: string[]
): McpTool | null {
try {
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
const rawName = _.snakeCase(`${operation.operationId}` || `${method}_${path}`) || "unnamed_tool";
const name = _.snakeCase(cleanToolName(operation.summary)) || cleanToolName(rawName);
if (!name || name === "unnamed_tool") {
console.warn(`Invalid tool name for ${method} ${path}`);
return null;
}
const description =
let description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
operation.summary;
description += `\n
Execute ${method.toUpperCase()} ${path}`;
// ✅ Extract schema berdasarkan method
let schema;
@@ -343,9 +365,8 @@ function cleanToolName(name: string): string {
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.replace(/^(get|post|put|delete|patch|api)_/i, "")
.replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "")
.replace(/(^_|_$)/g, "")
// ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate)
.toLowerCase()
|| "unnamed_tool";
} catch (error) {
console.error("Error cleaning tool name:", error);
@@ -353,10 +374,14 @@ function cleanToolName(name: string): string {
}
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
* * @param url URL of the OpenAPI spec.
* @param filterTag A string or array of strings. Operations must match at least one tag
* (case-insensitive partial match).
*/
export async function getMcpTools(url: string, filterTag: string): Promise<McpTool[]> {
export async function getMcpTools(url: string, filterTag: string | string[]): Promise<McpTool[]> {
try {
console.log(`Fetching OpenAPI spec from: ${url}`);
@@ -370,12 +395,12 @@ export async function getMcpTools(url: string, filterTag: string): Promise<McpTo
const openApiJson = await response.json();
const tools = convertOpenApiToMcpTools(openApiJson, filterTag);
console.log(`✅ Successfully generated ${tools.length} MCP tools`);
const filterStr = _.castArray(filterTag).join(", ");
console.log(`✅ Successfully generated ${tools.length} MCP tools for tags: [${filterStr}]`);
return tools;
} catch (error) {
console.error("Error fetching MCP tools:", error);
throw error;
}
}
}

View File

@@ -94,7 +94,6 @@ export async function fetchWithAuth(config: Config, url: string, options: Reques
} catch {
console.error('🔍 Could not read response body');
}
process.exit(1);
}
return response;
}
@@ -139,7 +138,7 @@ export async function catFile(config: Config, folder: string, fileName: string):
return buffer;
}
export async function uploadFile(config: Config, file: File): Promise<string> {
export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
const remoteName = path.basename(file.name);
// 1. Dapatkan upload link (pakai Authorization)
@@ -152,7 +151,7 @@ export async function uploadFile(config: Config, file: File): Promise<string> {
// 2. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 3. Upload file TANPA Authorization header, token di query param
@@ -232,10 +231,10 @@ export async function uploadFileToFolder(config: Config, base64File: { name: str
}
export async function removeFile(config: Config, fileName: string, folder: string): Promise<string> {
const res = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`, { method: 'DELETE' });
export async function removeFile(config: Config, fileName: string): Promise<string> {
await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`, { method: 'DELETE' });
if (!res.ok) return 'gagal menghapus file';
return `🗑️ Removed ${fileName}`
}

View File

@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { prisma } from '@/server/lib/prisma'
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
import { type ElysiaCookie } from 'elysia/cookies'
import { prisma } from '@/server/lib/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
@@ -75,6 +75,15 @@ async function login({
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
password: true,
Role: {
select: {
permissions: true
}
}
}
})
if (!user) {
@@ -87,6 +96,12 @@ async function login({
return { message: 'Invalid password' }
}
const rawPermissions = user.Role?.permissions;
const akses = Array.isArray(rawPermissions)
? rawPermissions[0]?.toString()
: undefined;
const token = await issueToken({
jwt,
cookie,
@@ -94,7 +109,7 @@ async function login({
role: 'user',
expiresAt: Math.floor(Date.now() / 1000) + NINETY_YEARS,
})
return { token }
return { token, akses }
} catch (error) {
console.error('Error logging in:', error)
return {
@@ -146,7 +161,7 @@ const Auth = new Elysia({
detail: {
summary: 'logout',
description: 'Logout (clear token cookie)',
},
}
)

View File

@@ -1,250 +1,476 @@
// server/mcpServer.ts
import { Elysia } from "elysia";
import { getMcpTools } from "../lib/mcp_tool_convert";
var tools = [] as any[];
const OPENAPI_URL = process.env.BUN_PUBLIC_BASE_URL + "/docs/json";
const FILTER_TAG = "mcp";
/**
* Refactored Elysia-based MCP server
* - Fixes inconsistent "text/json" handling by normalizing response extraction
* - Robust executeTool: supports path/query/header/cookie/body params (if provided in x-props)
* - Proper baseUrl/path normalization and URLSearchParams building (repeated keys for arrays)
* - Consistent MCP content conversion: always returns either { type: 'json', data } or { type: 'text', text }
* - Safer error handling, batch support, Promise.allSettled to avoid full failure on single-item error
* - Lightweight in-memory tools cache with explicit init endpoint (keeps original behavior)
*/
/* -------------------------
Environment & Globals
------------------------- */
if (!process.env.BUN_PUBLIC_BASE_URL) {
throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set");
throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set");
}
// =====================
// MCP Protocol Types
// =====================
const OPENAPI_URL = `${process.env.BUN_PUBLIC_BASE_URL.replace(/\/+$/, "")}/docs/json`;
const FILTER_TAG = "mcp";
let tools: any[] = [];
/* -------------------------
MCP Types
------------------------- */
type JSONRPCRequest = {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: any;
jsonrpc: "2.0";
id: string | number;
method: string;
params?: any;
credentials?: any;
};
type JSONRPCResponse = {
jsonrpc: "2.0";
id: string | number;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
jsonrpc: "2.0";
id: string | number | null;
result?: any;
error?: { code: number; message: string; data?: any };
};
// =====================
// Tool Executor
// =====================
/* -------------------------
Helpers
------------------------- */
/** Ensure baseUrl doesn't end with slash; ensure path begins with slash */
function joinBasePath(base: string, path: string) {
const normalizedBase = base.replace(/\/+$/, "");
const normalizedPath = path ? (path.startsWith("/") ? path : `/${path}`) : "";
return `${normalizedBase}${normalizedPath}`;
}
/** Serialize query object to repeated-key QS when arrays provided */
function buildQueryString(q: Record<string, any>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === null) continue;
if (Array.isArray(v)) {
for (const item of v) {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(item))}`);
}
} else if (typeof v === "object") {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(JSON.stringify(v))}`);
} else {
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
}
return parts.length ? `?${parts.join("&")}` : "";
}
/** Safely extract "useful" payload from a fetch result:
* Prefer resp.data if present, otherwise resp itself.
* If resp is a string, keep as string.
*/
function extractRaw(result: { data: any } | any) {
// If result shaped as { data: ... } prefer inner .data
if (result && typeof result === "object" && "data" in result) {
return result.data;
}
return result;
}
/** Convert various payloads into MCP content shape */
function convertToMcpContent(payload: any) {
if (typeof payload === "string") {
return { type: "text", text: payload };
}
if (payload == null) {
return { type: "text", text: String(payload) };
}
// If payload looks like an image/audio wrapper produced by converter
if (payload?.__mcp_type === "image" && payload.base64) {
return { type: "image", data: payload.base64, mimeType: payload.mimeType || "image/png" };
}
if (payload?.__mcp_type === "audio" && payload.base64) {
return { type: "audio", data: payload.base64, mimeType: payload.mimeType || "audio/mpeg" };
}
// If already an object/array → return JSON
if (typeof payload === "object") {
return { type: "json", data: payload };
}
// Fallback — stringify
try {
return { type: "text", text: JSON.stringify(payload) };
} catch {
return { type: "text", text: String(payload) };
}
}
/* -------------------------
executeTool (robust)
------------------------- */
/**
* Execute a tool converted from OpenAPI -> expected x-props shape:
* x-props may contain:
* - method, path
* - parameters: [{ name, in, required? }]
*
* If x.parameters present, we inspect args and place them accordingly.
*/
export async function executeTool(
tool: any,
args: Record<string, any> = {},
baseUrl: string
tool: any,
args: Record<string, any> = {},
baseUrl: string
) {
const x = tool["x-props"] || {};
const x = tool["x-props"] || {};
const method = (x.method || "GET").toUpperCase();
const method = (x.method || "GET").toUpperCase();
const path = x.path || `/${tool.name}`;
const url = `${baseUrl}${path}`;
// Start with provided path (may contain {param})
let path = x.path ?? `/${tool.name}`;
const opts: RequestInit = {
method,
headers: { "Content-Type": "application/json" },
};
// Headers, cookies, query, body collection
const headers: Record<string, any> = {
"Content-Type": "application/json",
...(x.defaultHeaders || {}),
};
const query: Record<string, any> = {};
const cookies: string[] = [];
let bodyPayload: any = undefined;
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
opts.body = JSON.stringify(args || {});
// If parameters described, map args accordingly
if (Array.isArray(x.parameters)) {
for (const p of x.parameters) {
try {
const name: string = p.name;
const value = args?.[name];
// skip undefined unless required — we let API validate required semantics
if (value === undefined) continue;
switch (p.in) {
case "path":
if (path.includes(`{${name}}`)) {
path = path.replace(new RegExp(`{${name}}`, "g"), encodeURIComponent(String(value)));
} else {
// fallback to query
query[name] = value;
}
break;
case "query":
query[name] = value;
break;
case "header":
headers[name] = value;
break;
case "cookie":
cookies.push(`${name}=${value}`);
break;
case "body":
case "requestBody":
bodyPayload = value;
break;
default:
// unknown location -> place into body
bodyPayload = bodyPayload ?? {};
bodyPayload[name] = value;
break;
}
} catch (err) {
// best-effort: skip problematic param
console.warn(`[MCP] Skipping parameter ${String(p?.name)} due to error:`, err);
}
}
} else {
// no param descriptions: assume all args are body
bodyPayload = Object.keys(args || {}).length ? args : undefined;
}
const res = await fetch(url, opts);
const contentType = res.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await res.json()
: await res.text();
if (cookies.length) {
headers["Cookie"] = cookies.join("; ");
}
return {
success: res.ok,
status: res.status,
method,
path,
data,
};
// Build full URL
const urlBase = baseUrl || process.env.BUN_PUBLIC_BASE_URL!;
let url = joinBasePath(urlBase, path);
const qs = buildQueryString(query);
if (qs) url += qs;
// Build RequestInit
const opts: RequestInit & { headers?: Record<string, any> } = { method, headers };
// Body handling for applicable methods
const bodyMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const contentTypeLower = (headers["Content-Type"] || "").toLowerCase();
if (bodyMethods.has(method) && bodyPayload !== undefined) {
// Support tiny formdata marker shape: { __formdata: true, entries: [ [k,v], ... ] }
if (bodyPayload && bodyPayload.__formdata === true && Array.isArray(bodyPayload.entries)) {
const form = new FormData();
for (const [k, v] of bodyPayload.entries) {
form.append(k, v as any);
}
// Let fetch set boundary
delete opts.headers!["Content-Type"];
opts.body = form as any;
} else if (contentTypeLower.includes("application/x-www-form-urlencoded")) {
opts.body = new URLSearchParams(bodyPayload as Record<string, string>).toString();
} else if (contentTypeLower.includes("multipart/form-data")) {
// If caller explicitly requested multipart but didn't pass FormData — convert object to form
const form = new FormData();
if (typeof bodyPayload === "object") {
for (const [k, v] of Object.entries(bodyPayload)) {
form.append(k, (v as any) as any);
}
} else {
form.append("payload", String(bodyPayload));
}
delete opts.headers!["Content-Type"];
opts.body = form as any;
} else {
// Default JSON
opts.body = JSON.stringify(bodyPayload);
}
}
// Execute fetch
console.log(`[MCP] → ${method} ${url}`);
const res = await fetch(url, opts);
const resContentType = (res.headers.get("content-type") || "").toLowerCase();
let data: any;
try {
if (resContentType.includes("application/json")) {
data = await res.json();
} else {
data = await res.text();
}
} catch (err) {
// fallback to text
try {
data = await res.text();
} catch {
data = null;
}
}
return {
success: res.ok,
status: res.status,
method,
url,
path,
headers: res.headers,
data,
};
}
// =====================
// MCP Handler (Async)
// =====================
async function handleMCPRequestAsync(
request: JSONRPCRequest
): Promise<JSONRPCResponse> {
const { id, method, params } = request;
/* -------------------------
JSON-RPC Handler
------------------------- */
async function handleMCPRequestAsync(request: JSONRPCRequest): Promise<JSONRPCResponse> {
const { id, method, params } = request;
switch (method) {
case "initialize":
const makeError = (code: number, message: string, data?: any): JSONRPCResponse => ({
jsonrpc: "2.0",
id: id ?? null,
error: { code, message, data },
});
switch (method) {
case "initialize":
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "elysia-mcp-server", version: "1.0.0" },
},
};
case "tools/list":
return {
jsonrpc: "2.0",
id,
result: {
tools: tools.map((t) => {
const inputSchema =
typeof t.inputSchema === "object" && t.inputSchema?.type === "object"
? t.inputSchema
: { type: "object", properties: {}, required: [] };
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "elysia-mcp-server", version: "1.0.0" },
},
name: t.name,
description: t.description || "No description provided",
inputSchema,
"x-props": t["x-props"],
};
}),
},
};
case "tools/list":
return {
jsonrpc: "2.0",
id,
result: {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
},
};
case "tools/call": {
const toolName = params?.name;
const tool = tools.find((t) => t.name === toolName);
if (!tool) return makeError(-32601, `Tool '${toolName}' not found`);
case "tools/call": {
const toolName = params?.name;
const tool = tools.find((t) => t.name === toolName);
try {
const baseUrl = (params?.credentials?.baseUrl as string) || process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
const args = params?.arguments || {};
if (!tool) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool '${toolName}' not found` },
};
}
const result = await executeTool(tool, args, baseUrl);
try {
const baseUrl =
process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
const result = await executeTool(tool, params?.arguments || {}, baseUrl);
const data = result.data.data;
const isObject = typeof data === "object" && data !== null;
// Extract the meaningful payload (prefer nested .data if present)
const raw = extractRaw(result.data);
return {
jsonrpc: "2.0",
id,
result: {
content: [
isObject
? { type: "json", data: data }
: { type: "text", text: JSON.stringify(data || result.data || result) },
],
},
};
} catch (error: any) {
return {
jsonrpc: "2.0",
id,
error: { code: -32603, message: error.message },
};
}
}
// Normalize content shape consistently:
const contentItem = convertToMcpContent(raw ?? result.data ?? result);
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method '${method}' not found` },
};
return {
jsonrpc: "2.0",
id,
result: {
content: [contentItem],
},
};
} catch (err: any) {
// avoid leaking secrets — small debug
const dbg = { message: err?.message };
return makeError(-32603, err?.message ?? "Internal error", dbg);
}
}
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return makeError(-32601, `Method '${method}' not found`);
}
}
// =====================
// Elysia MCP Server
// =====================
export const MCPRoute = new Elysia({
tags: ["MCP Server"]
})
.post("/mcp", async ({ request, set }) => {
if (!tools.length) {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
/* -------------------------
Elysia App & Routes
------------------------- */
export const MCPRoute = new Elysia({ tags: ["MCP Server"] })
.post("/mcp", async ({ request, set }) => {
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
try {
const body = await request.json();
// Lazy load the tools (keeps previous behavior)
if (!tools.length) {
try {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
} catch (err) {
console.error("[MCP] Failed to load tools during lazy init:", err);
}
}
if (!Array.isArray(body)) {
const res = await handleMCPRequestAsync(body);
return res;
}
try {
const body = await request.json();
const results = await Promise.all(
body.map((req) => handleMCPRequestAsync(req))
);
return results;
} catch (error: any) {
set.status = 400;
return {
// If batch array -> allSettled for resilience
if (Array.isArray(body)) {
const promises = body.map((req: JSONRPCRequest) => handleMCPRequestAsync(req));
const settled = await Promise.allSettled(promises);
const responses = settled.map((s) =>
s.status === "fulfilled"
? s.value
: ({
jsonrpc: "2.0",
id: null,
error: {
code: -32700,
message: "Parse error",
data: error.message,
code: -32000,
message: "Unhandled handler error",
data: String((s as PromiseRejectedResult).reason),
},
};
}
})
} as JSONRPCResponse)
);
return responses;
}
// Tools list (debug)
.get("/mcp/tools", async ({ set }) => {
if (!tools.length) {
const single = await handleMCPRequestAsync(body as JSONRPCRequest);
return single;
} catch (err: any) {
set.status = 400;
return {
jsonrpc: "2.0",
id: null,
error: { code: -32700, message: "Parse error", data: err?.message ?? String(err) },
} as JSONRPCResponse;
}
})
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Access-Control-Allow-Origin"] = "*";
return {
tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({
name,
description,
inputSchema,
"x-props": x,
})),
};
})
/* Debug / management endpoints */
.get("/mcp/tools", async ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
if (!tools.length) {
try {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
} catch (err) {
console.error("[MCP] Failed to load tools for /mcp/tools:", err);
}
}
return {
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
"x-props": t["x-props"],
})),
};
})
// MCP status
.get("/mcp/status", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "active", timestamp: Date.now() };
})
.get("/mcp/status", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "active", timestamp: Date.now() };
})
// Health check
.get("/health", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "ok", timestamp: Date.now(), tools: tools.length };
})
.get("/mcp/init", async ({ set }) => {
.get("/health", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
return { status: "ok", timestamp: Date.now(), tools: tools.length };
})
const _tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
tools = _tools;
return {
success: true,
message: "MCP initialized",
tools: tools.length,
};
})
// Force re-init (useful for admin / CI)
.get("/mcp/init", async ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
try {
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
return { success: true, message: "MCP initialized", tools: tools.length };
} catch (err) {
set.status = 500;
return { success: false, message: "Failed to initialize tools", error: String(err) };
}
})
// CORS
.options("/mcp", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
})
.options("/mcp/tools", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS";
set.headers["Access-Control-Allow-Headers"] =
"Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
});
/* CORS preflight */
.options("/mcp", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS";
set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
})
.options("/mcp/tools", ({ set }) => {
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS";
set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key";
set.status = 204;
return "";
});
/* -------------------------
End
------------------------- */

View File

@@ -1,5 +1,6 @@
import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma"
import _ from "lodash"
import { v4 as uuidv4 } from "uuid"
import { getLastUpdated } from "../lib/get-last-updated"
import { mimeToExtension } from "../lib/mimetypeToExtension"
@@ -7,7 +8,7 @@ import { generateNoPengaduan } from "../lib/no-pengaduan"
import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma"
import { renameFile } from "../lib/rename-file"
import { catFile, defaultConfigSF, uploadFile, uploadFileBase64 } from "../lib/seafile"
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileBase64 } from "../lib/seafile"
const PengaduanRoute = new Elysia({
prefix: "pengaduan",
@@ -107,11 +108,11 @@ const PengaduanRoute = new Elysia({
// --- PENGADUAN ---
.post("/create", async ({ body }) => {
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, wargaId, noTelepon } = body
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, namaWarga, noTelepon } = body
let imageFix = namaGambar
const noPengaduan = await generateNoPengaduan()
let idCategoryFix = kategoriId
let idWargaFix = wargaId
let idWargaFix = ""
if (idCategoryFix) {
const category = await prisma.categoryPengaduan.findUnique({
@@ -138,45 +139,32 @@ const PengaduanRoute = new Elysia({
idCategoryFix = "lainnya"
}
const warga = await prisma.warga.findUnique({
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const dataWarga = await prisma.warga.upsert({
where: {
id: wargaId,
phone: nomorHP
},
create: {
name: namaWarga,
phone: nomorHP,
},
update: {
name: namaWarga,
},
select: {
id: true
}
})
if (!warga) {
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const cariWarga = await prisma.warga.findUnique({
where: {
phone: nomorHP,
}
})
idWargaFix = dataWarga.id
if (!cariWarga) {
const wargaCreate = await prisma.warga.create({
data: {
name: wargaId,
phone: nomorHP,
},
select: {
id: true
}
})
idWargaFix = wargaCreate.id
} else {
idWargaFix = cariWarga.id
}
}
const pengaduan = await prisma.pengaduan.create({
data: {
title: judulPengaduan,
detail: detailPengaduan,
idCategory: idCategoryFix,
idWarga: idWargaFix,
idWarga: idWargaFix || "",
location: lokasi,
image: imageFix,
noPengaduan,
@@ -218,23 +206,20 @@ const PengaduanRoute = new Elysia({
description: "Alamat atau titik lokasi pengaduan"
}),
namaGambar: t.String({
optional: true,
namaGambar: t.Optional(t.String({
examples: ["sampah.jpg"],
description: "Nama file gambar yang telah diupload (opsional)"
}),
})),
kategoriId: t.String({
optional: true,
kategoriId: t.Optional(t.String({
examples: ["kebersihan"],
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
}),
})),
wargaId: t.String({
optional: true,
namaWarga: t.Optional(t.String({
examples: ["budiman"],
description: "ID unik warga yang melapor (jika sudah terdaftar)"
}),
description: "Nama warga yang melapor"
})),
noTelepon: t.String({
error: "Nomor telepon harus diisi",
@@ -245,23 +230,7 @@ const PengaduanRoute = new Elysia({
detail: {
summary: "Buat Pengaduan Warga",
description: `
Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga.
Alur proses:
1. Sistem memvalidasi kategori pengaduan berdasarkan ID.
- Jika ID kategori tidak ditemukan, sistem akan mencari berdasarkan nama kategori.
- Jika tetap tidak ditemukan, kategori akan diset menjadi "lainnya".
2. Sistem memvalidasi data warga berdasarkan ID.
- Jika warga tidak ditemukan, sistem akan mencari berdasarkan nomor telepon.
- Jika tetap tidak ditemukan, data warga baru akan dibuat secara otomatis.
3. Sistem menghasilkan nomor pengaduan unik (noPengaduan).
4. Data pengaduan akan disimpan ke database, termasuk judul, detail, lokasi, gambar (opsional), dan data warga.
5. Sistem juga membuat catatan riwayat awal pengaduan dengan deskripsi "Pengaduan dibuat".
Respon:
- success: true jika pengaduan berhasil dibuat.
- message: berisi pesan sukses dan nomor pengaduan yang dapat digunakan untuk melacak status pengaduan.`,
description: `Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga`,
tags: ["mcp"]
}
})
@@ -364,7 +333,7 @@ Respon:
const dataHistory = await prisma.historyPengaduan.findMany({
where: {
idPengaduan: id,
idPengaduan: data?.id,
},
select: {
id: true,
@@ -380,23 +349,20 @@ Respon:
}
})
const dataHistoryFix = dataHistory.map((item) => {
return {
id: item.id,
deskripsi: item.deskripsi,
status: item.status,
createdAt: item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
}),
idUser: item.idUser,
nameUser: item.User?.name,
}
})
const dataHistoryFix = dataHistory.map((item: any) => ({
..._.omit(item, ["User", "createdAt"]),
nameUser: item.User?.name,
createdAt: item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
}),
}))
const warga = {
name: data?.Warga?.name,
@@ -517,7 +483,7 @@ Respon:
}
})
.post("/upload", async ({ body }) => {
const { file } = body;
const { file, folder } = body;
// Validasi file
if (!file) {
@@ -530,7 +496,7 @@ Respon:
// Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
// const buffer = await file.arrayBuffer();
const result = await uploadFile(defaultConfigSF, renamedFile);
const result = await uploadFile(defaultConfigSF, renamedFile, folder);
if (result == 'gagal') {
return { success: false, message: "Upload gagal" };
}
@@ -544,11 +510,12 @@ Respon:
};
}, {
body: t.Object({
file: t.Any()
file: t.Any(),
folder: t.String(),
}),
detail: {
summary: "Upload File",
description: "Tool untuk upload file ke Seafile",
summary: "Upload File (FormData)",
description: "Tool untuk upload file ke folder tujuan dengan memakai FormData",
tags: ["mcp"],
consumes: ["multipart/form-data"]
},
@@ -728,12 +695,14 @@ Respon:
const hasil = await catFile(defaultConfigSF, folder, fileName);
const ext = fileName.split(".").pop()?.toLowerCase();
const mime =
ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "png"
? "image/png"
: "application/octet-stream";
let mime = "application/octet-stream"; // default
if (["jpg", "jpeg"].includes(ext!)) mime = "image/jpeg";
if (["png"].includes(ext!)) mime = "image/png";
if (["gif"].includes(ext!)) mime = "image/gif";
if (["webp"].includes(ext!)) mime = "image/webp";
if (["svg"].includes(ext!)) mime = "image/svg+xml";
if (["pdf"].includes(ext!)) mime = "application/pdf";
set.headers["Content-Type"] = mime;
set.headers["Content-Length"] = hasil.byteLength.toString();
@@ -749,6 +718,33 @@ Respon:
description: "tool untuk mendapatkan gambar",
}
})
.post("/delete-image", async ({ body }) => {
const { file, folder } = body;
// Validasi file
if (!file) {
return { success: false, message: "File tidak ditemukan" };
}
const result = await removeFile(defaultConfigSF, file, folder);
if (result == 'gagal') {
return { success: false, message: "Delete gagal" };
}
return {
success: true,
message: "Delete berhasil",
};
}, {
body: t.Object({
file: t.String(),
folder: t.String(),
}),
detail: {
summary: "Delete File",
description: "Tool untuk delete file Seafile",
},
})
;

View File

@@ -21,6 +21,11 @@ const SuratRoute = new Elysia({
select: {
DataTextPelayanan: true,
}
},
CategoryPelayanan: {
select: {
name: true,
}
}
}
})
@@ -37,6 +42,7 @@ const SuratRoute = new Elysia({
surat: {
id: dataSurat?.id,
idCategory: dataSurat?.idCategory,
nameCategory: dataSurat?.CategoryPelayanan?.name,
noSurat: dataSurat?.noSurat,
dataText: dataSurat?.PelayananAjuan?.DataTextPelayanan,
createdAt: dataSurat?.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),

View File

@@ -4,7 +4,8 @@ import { generateNoPengaduan } from "../lib/no-pengaduan";
import { normalizePhoneNumber } from "../lib/normalizePhone";
const TestPengaduanRoute = new Elysia({
prefix: "online-pengaduan"
prefix: "online-pengaduan",
tags: ["test"]
})
.get("/category", async () => {
const data = await prisma.categoryPengaduan.findMany({
@@ -20,7 +21,6 @@ const TestPengaduanRoute = new Elysia({
}
})
return { data }
}, {
detail: {
@@ -31,71 +31,61 @@ const TestPengaduanRoute = new Elysia({
})
.post("/create", async ({ body }) => {
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, wargaId, noTelepon } = body
let imageFix = namaGambar
const { judulPengaduan, detailPengaduan, lokasi, kategoriId, noTelepon, image } = body
const noPengaduan = await generateNoPengaduan()
let idCategoryFix = kategoriId
let idWargaFix = wargaId
const category = await prisma.categoryPengaduan.findUnique({
where: {
id: kategoriId,
}
})
if (!category) {
const cariCategory = await prisma.categoryPengaduan.findFirst({
if (idCategoryFix) {
const category = await prisma.categoryPengaduan.findUnique({
where: {
name: kategoriId,
id: idCategoryFix,
}
})
if (!cariCategory) {
idCategoryFix = "lainnya"
} else {
idCategoryFix = cariCategory.id
}
}
const warga = await prisma.warga.findUnique({
where: {
id: wargaId,
}
})
if (!warga) {
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const cariWarga = await prisma.warga.findUnique({
where: {
phone: nomorHP,
}
})
if (!cariWarga) {
const wargaCreate = await prisma.warga.create({
data: {
name: wargaId,
phone: nomorHP,
},
select: {
id: true
if (!category) {
const cariCategory = await prisma.categoryPengaduan.findFirst({
where: {
name: kategoriId,
}
})
idWargaFix = wargaCreate.id
} else {
idWargaFix = cariWarga.id
}
if (!cariCategory) {
idCategoryFix = "lainnya"
} else {
idCategoryFix = cariCategory.id
}
}
} else {
idCategoryFix = "lainnya"
}
const nomorHP = normalizePhoneNumber({ phone: "089697338821" })
const cariWarga = await prisma.warga.upsert({
where: {
phone: nomorHP,
},
create: {
name: "malik",
phone: nomorHP,
},
update: {
name: "malik",
phone: nomorHP,
},
})
const pengaduan = await prisma.pengaduan.create({
data: {
title: judulPengaduan,
detail: detailPengaduan,
idCategory: idCategoryFix,
idWarga: idWargaFix,
idWarga: cariWarga.id,
location: lokasi,
image: imageFix,
image: body.image || "",
noPengaduan,
},
select: {
@@ -117,69 +107,18 @@ const TestPengaduanRoute = new Elysia({
return { success: true, message: 'pengaduan sudah dibuat dengan nomer ' + noPengaduan + ', nomer ini akan digunakan untuk mengakses pengaduan ini' }
}, {
body: t.Object({
judulPengaduan: t.String({
error: "Judul pengaduan harus diisi dan minimal 3 karakter",
examples: ["Sampah menumpuk di depan rumah"],
description: "Judul singkat dari pengaduan warga"
}),
detailPengaduan: t.String({
error: "Deskripsi pengaduan harus diisi dan minimal 10 karakter",
examples: ["Terdapat sampah yang menumpuk selama seminggu di depan rumah saya"],
description: "Penjelasan lebih detail mengenai pengaduan"
}),
lokasi: t.String({
error: "Lokasi pengaduan harus diisi",
examples: ["Jl. Raya No. 1, RT 01 RW 02, Darmasaba"],
description: "Alamat atau titik lokasi pengaduan"
}),
namaGambar: t.String({
optional: true,
examples: ["sampah.jpg"],
description: "Nama file gambar yang telah diupload (opsional)"
}),
kategoriId: t.String({
error: "ID kategori pengaduan harus diisi",
examples: ["kebersihan"],
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
}),
wargaId: t.String({
error: "ID warga harus diisi",
examples: ["budiman"],
description: "ID unik warga yang melapor (jika sudah terdaftar)"
}),
noTelepon: t.String({
error: "Nomor telepon harus diisi",
examples: ["08123456789", "+628123456789"],
description: "Nomor telepon warga pelapor"
}),
judulPengaduan: t.String(),
detailPengaduan: t.String(),
lokasi: t.String(),
kategoriId: t.String(),
noTelepon: t.Optional(t.String()),
image: t.Optional(t.String()),
}),
detail: {
summary: "Buat Pengaduan Warga",
description: `
Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga.
Alur proses:
1. Sistem memvalidasi kategori pengaduan berdasarkan ID.
- Jika ID kategori tidak ditemukan, sistem akan mencari berdasarkan nama kategori.
- Jika tetap tidak ditemukan, kategori akan diset menjadi "lainnya".
2. Sistem memvalidasi data warga berdasarkan ID.
- Jika warga tidak ditemukan, sistem akan mencari berdasarkan nomor telepon.
- Jika tetap tidak ditemukan, data warga baru akan dibuat secara otomatis.
3. Sistem menghasilkan nomor pengaduan unik (noPengaduan).
4. Data pengaduan akan disimpan ke database, termasuk judul, detail, lokasi, gambar (opsional), dan data warga.
5. Sistem juga membuat catatan riwayat awal pengaduan dengan deskripsi "Pengaduan dibuat".
Respon:
- success: true jika pengaduan berhasil dibuat.
- message: berisi pesan sukses dan nomor pengaduan yang dapat digunakan untuk melacak status pengaduan.`,
tags: ["test"]
Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga.`
}
})

View File

@@ -6,10 +6,16 @@ const UserRoute = new Elysia({
prefix: "user",
tags: ["user"],
})
.get('/find', (ctx) => {
.get('/find', async (ctx) => {
const { user } = ctx as any
const permissions = await prisma.role.findFirst({
where: { id: user?.roleId },
select: { permissions: true }
});
return {
user: user as User
user: user as User,
permissions: permissions?.permissions || []
}
}, {
detail: {
@@ -150,7 +156,14 @@ const UserRoute = new Elysia({
}
})
.get("/role", async () => {
const data = await prisma.role.findMany()
const data = await prisma.role.findMany({
where: {
isActive: true
},
orderBy: {
name: "asc"
}
})
return data
}, {
detail: {
@@ -182,5 +195,80 @@ const UserRoute = new Elysia({
description: "delete user",
}
})
.post("role-create", async ({ body }) => {
const { name, permissions } = body;
const create = await prisma.role.create({
data: {
name,
permissions: permissions
}
});
return {
success: true,
message: "Role created successfully",
};
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
permissions: t.Any(),
}),
detail: {
summary: "create-role",
description: "create role",
}
})
.post("/role-update", async ({ body }) => {
const { id, name, permissions } = body;
const update = await prisma.role.update({
where: {
id
},
data: {
name,
permissions
}
});
return {
success: true,
message: "User role updated successfully",
};
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" }),
name: t.String({ minLength: 1, error: "name is required" }),
permissions: t.Any()
}),
detail: {
summary: "update-role",
description: "update role",
}
})
.post("role-delete", async ({ body }) => {
const { id } = body;
await prisma.role.update({
where: {
id
},
data: {
isActive: false
}
});
return {
success: true,
message: "Role deleted successfully",
};
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "delete-role",
description: "delete role",
}
})
;
export default UserRoute

View File

@@ -62,8 +62,8 @@ const WargaRoute = new Elysia({
phone: t.String({ minLength: 1 })
}),
detail: {
summary: "edit konfigurasi desa",
description: `tool untuk edit konfigurasi desa`
summary: "Edit Warga",
description: `tool untuk edit warga`
}
})
.get("/detail", async ({ query }) => {