Compare commits
28 Commits
amalia/20-
...
amalia/26-
| Author | SHA1 | Date | |
|---|---|---|---|
| d7e77da16a | |||
| decf6dd972 | |||
| 5b72f1a9cc | |||
| acb5ae7cd1 | |||
| 6bdb0246c9 | |||
| 3f68f212cd | |||
|
|
e0236a907f | ||
| e4189d40e9 | |||
| 94e7604afb | |||
| a253d40d19 | |||
| 26c7357ca3 | |||
| 15c5140902 | |||
| c5b1452955 | |||
| e1431fafb2 | |||
| ad7b40523c | |||
| 10db3f922e | |||
| 0a3afb7b9c | |||
| c72ef5a755 | |||
| 4c047324bc | |||
| e4a03e3a8f | |||
| 41af733c6e | |||
|
|
436016641b | ||
|
|
6fbddb3806 | ||
|
|
eb1eaa11ea | ||
|
|
54ae3b746d | ||
|
|
7781882531 | ||
| 558d8aaafb | |||
| d7267abdb3 |
250
bak/mcp_route.ts.txt
Normal file
250
bak/mcp_route.ts.txt
Normal 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
381
bak/mcp_tool_convert.ts.txt
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
67
src/components/PermissionRole.tsx
Normal file
67
src/components/PermissionRole.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/components/PermissionTree.tsx
Normal file
177
src/components/PermissionTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
427
src/components/UserRoleSetting.tsx
Normal file
427
src/components/UserRoleSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
59
src/lib/groupPermission.ts
Normal file
59
src/lib/groupPermission.ts
Normal 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
310
src/lib/listPermission.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
25
src/server/lib/detect-type-of-file.ts
Normal file
25
src/server/lib/detect-type-of-file.ts
Normal 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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)',
|
||||
|
||||
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
------------------------- */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user