Compare commits
77 Commits
amalia/24-
...
amalia/09-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6428f5084e | |||
| bfc292ec6c | |||
| 5680466c98 | |||
| f5cc45937c | |||
| 5b4164b151 | |||
| 225c58b346 | |||
| b8b3aed86e | |||
| fc530399dd | |||
| 281e34ea69 | |||
| f928fc504f | |||
| 4fb98d0480 | |||
| bfb33e2105 | |||
| 2579714000 | |||
| d69189cf7d | |||
| 20e24a03aa | |||
| c256f4b729 | |||
| 9430ad3728 | |||
| c6c3ba95f8 | |||
|
|
3c58230c3a | ||
| d22b4b973f | |||
| 700fbe3bd7 | |||
| 9b7a61e134 | |||
| 2d376663bb | |||
| 7c669f3494 | |||
| 0ed9dc6ddd | |||
| b9984c6337 | |||
| 48a7d43713 | |||
| 6a52d10faa | |||
| 2b94684570 | |||
| cc7c8eb704 | |||
| 4996da4189 | |||
| c32cce838f | |||
| 5af9b720ca | |||
| 35618bb438 | |||
| eee8aadb1a | |||
| 1f95c7d7d8 | |||
| 23df516aad | |||
| 70175cedc6 | |||
| f52f5f87ca | |||
| ea17357638 | |||
| 9c7c9d8595 | |||
| 5ecf264155 | |||
| 4cc28c4311 | |||
| c25e5eeba0 | |||
| 574603e290 | |||
| ba76eb5e59 | |||
| 6ae83ec19c | |||
| ba0414a99c | |||
| 4dd66dbd9a | |||
| fa201274d4 | |||
| dff1aa61c5 | |||
| 90b8fdf573 | |||
| 239b1bdc1b | |||
| f99da3b2a6 | |||
| 3866f71e2d | |||
| d57ff92aa9 | |||
| f901cff3b1 | |||
| fe3ebf4bd3 | |||
| 075cb12417 | |||
| cd7e602254 | |||
| f9b84f89eb | |||
| cca1840922 | |||
| c622565bb7 | |||
| d7e77da16a | |||
| decf6dd972 | |||
| 5b72f1a9cc | |||
| acb5ae7cd1 | |||
| 6bdb0246c9 | |||
| 3f68f212cd | |||
|
|
e0236a907f | ||
| e4189d40e9 | |||
| 94e7604afb | |||
| a253d40d19 | |||
| 26c7357ca3 | |||
| 15c5140902 | |||
| c5b1452955 | |||
| e1431fafb2 |
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;
|
||||
}
|
||||
}
|
||||
|
||||
23
bun.lock
23
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "jenna-mcp",
|
||||
@@ -21,6 +22,8 @@
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"add": "^2.0.6",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.5",
|
||||
"elysia": "^1.4.15",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.3",
|
||||
@@ -289,6 +292,10 @@
|
||||
|
||||
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
|
||||
|
||||
"echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="],
|
||||
|
||||
"echarts-for-react": ["echarts-for-react@3.0.5", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "size-sensor": "^1.0.1" }, "peerDependencies": { "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "react": "^15.0.0 || >=16.0.0" } }, "sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg=="],
|
||||
|
||||
"editor": ["editor@1.0.0", "", {}, "sha512-SoRmbGStwNYHgKfjOrX2L0mUvp9bUVv0uPppZSOMAntEbcFtoC3MKF5b3T6HQPXKIV+QGY3xPO3JK5it5lVkuw=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
@@ -645,6 +652,8 @@
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"size-sensor": ["size-sensor@1.0.2", "", {}, "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
|
||||
@@ -687,7 +696,7 @@
|
||||
|
||||
"tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
@@ -743,6 +752,8 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
|
||||
|
||||
"zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="],
|
||||
|
||||
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
@@ -769,12 +780,22 @@
|
||||
|
||||
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"request/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"request/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="],
|
||||
|
||||
"request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
|
||||
|
||||
"use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
|
||||
|
||||
3
kirim.sh
Normal file
3
kirim.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
curl -X POST https://cld-dkr-prod-jenna-mcp.wibudev.com/api/pengaduan/upload-file-form-data \
|
||||
-H "Accept: application/json" \
|
||||
-F "file=@image.png"
|
||||
@@ -28,6 +28,8 @@
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"add": "^2.0.6",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.5",
|
||||
"elysia": "^1.4.15",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.3",
|
||||
|
||||
98
src/components/DashboardCountData.tsx
Normal file
98
src/components/DashboardCountData.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Card, Flex, Grid, Group, Stack, Text } from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { IconFileCertificate, IconMessageReport, IconUsers } from "@tabler/icons-react";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function DashboardCountData() {
|
||||
const { data, mutate, isLoading } = useSWR("/", () =>
|
||||
apiFetch.api.dashboard.count.get()
|
||||
);
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<MetricCard
|
||||
icon={<IconMessageReport size={28} />}
|
||||
label="Pengaduan Hari Ini"
|
||||
value={String(data?.data?.pengaduan?.today)}
|
||||
change={String(data?.data?.pengaduan?.kenaikan) + "%"}
|
||||
color={"gray"}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<MetricCard
|
||||
icon={<IconFileCertificate size={28} />}
|
||||
label="Pengajuan Surat Hari Ini"
|
||||
value={String(data?.data?.pelayanan?.today)}
|
||||
change={String(data?.data?.pelayanan?.kenaikan) + "%"}
|
||||
color="gray"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
|
||||
<MetricCard
|
||||
icon={<IconUsers size={28} />}
|
||||
label="Warga"
|
||||
value={String(data?.data?.warga)}
|
||||
color="blue"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function MetricCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
change?: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
radius="lg"
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(30,30,30,0.95), rgba(55,55,55,0.9))",
|
||||
borderColor: "rgba(100,100,100,0.2)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.boxShadow = "0 0 10px rgba(0,255,200,0.2)")
|
||||
}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
{icon}
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Text fw={600} size="xl" c="gray.0">
|
||||
{value}
|
||||
</Text>
|
||||
{change && (
|
||||
<Text size="sm" c={color}>
|
||||
{change}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
src/components/DashboardGrafik.tsx
Normal file
83
src/components/DashboardGrafik.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Card, Divider, Flex, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconChartBar } from "@tabler/icons-react";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import EChartsReact from "echarts-for-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function DashboardGrafik() {
|
||||
const [options, setOptions] = useState<EChartsOption>({});
|
||||
const { data, mutate, isLoading } = useSWR(
|
||||
"grafik-dashboard",
|
||||
async () => {
|
||||
return apiFetch.api.dashboard.grafik.get().then(res => res.data);
|
||||
}
|
||||
);
|
||||
|
||||
const loadData = () => {
|
||||
if (!data) return;
|
||||
const option: EChartsOption = {
|
||||
darkMode: true,
|
||||
animation: true,
|
||||
legend: {
|
||||
textStyle: { color: "#fff" }
|
||||
},
|
||||
tooltip: {},
|
||||
dataset: {
|
||||
dimensions: data.dimensions,
|
||||
source: data.source
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
axisLabel: { color: "#fff" }
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
minInterval: 1,
|
||||
axisLabel: { color: "#fff" }
|
||||
},
|
||||
color: ["#1abc9c", "#10816aff"],
|
||||
series: [
|
||||
{ type: "bar" },
|
||||
{ type: "bar" }
|
||||
]
|
||||
};
|
||||
|
||||
setOptions(option);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) loadData();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
radius="lg"
|
||||
p="xl"
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
|
||||
borderColor: "rgba(100,100,100,0.2)",
|
||||
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex direction={"column"}>
|
||||
<Title order={4} c="gray.0">
|
||||
Grafik Pengaduan dan Pelayanan Surat
|
||||
</Title>
|
||||
<Text size="sm">7 Hari Terakhir</Text>
|
||||
</Flex>
|
||||
<IconChartBar size={20} color="gray" />
|
||||
</Flex>
|
||||
<Divider my="xs" />
|
||||
<Stack gap="sm">
|
||||
<EChartsReact style={{ height: 400 }} option={options} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
133
src/components/DashboardLastData.tsx
Normal file
133
src/components/DashboardLastData.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Badge, Button, Card, Flex, Group, Stack, Text, Title, Tooltip } from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function DashboardLastData() {
|
||||
const navigate = useNavigate();
|
||||
const { data, mutate, isLoading } = useSWR("last-update", async () => {
|
||||
const res = await apiFetch.api.dashboard["last-update"].get();
|
||||
return res.data
|
||||
});
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" gap="md">
|
||||
<Card
|
||||
radius="lg"
|
||||
p="xl"
|
||||
withBorder
|
||||
w={"50%"}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
|
||||
borderColor: "rgba(100,100,100,0.2)",
|
||||
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Flex align="center" pb={"sm"} justify="space-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
<Title order={4} c="gray.0">
|
||||
Last update pengaduan
|
||||
</Title>
|
||||
<Button variant="subtle" size="xs" radius="md" onClick={() => navigate(`/scr/dashboard/pengaduan/list`)}>View All</Button>
|
||||
</Flex>
|
||||
<Stack gap="sm" mt="md" align="stretch" justify="center">
|
||||
{
|
||||
data && Array.isArray(data.pengaduan) && data.pengaduan.length > 0 ? data.pengaduan.map((item: any, index: number) => (
|
||||
<PengaduanSection
|
||||
key={index}
|
||||
id={item.id}
|
||||
nomer={item.noPengaduan}
|
||||
judul={item.title}
|
||||
status={item.status}
|
||||
updated={item.updatedAt}
|
||||
kategori="pengaduan"
|
||||
/>
|
||||
)) : <Text c="dimmed" ta={"center"} >Tidak ada data</Text>
|
||||
}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
radius="lg"
|
||||
p="xl"
|
||||
withBorder
|
||||
w={"50%"}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
|
||||
borderColor: "rgba(100,100,100,0.2)",
|
||||
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Flex align="center" pb={"sm"} justify="space-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
<Title order={4} c="gray.0">
|
||||
Last update pelayanan surat
|
||||
</Title>
|
||||
<Button variant="subtle" size="xs" radius="md" onClick={() => navigate(`/scr/dashboard/pelayanan-surat/list-pelayanan`)}>View All</Button>
|
||||
</Flex>
|
||||
<Stack gap="sm" mt="md" align="stretch" justify="center">
|
||||
{
|
||||
data && Array.isArray(data.pelayanan) && data.pelayanan.length > 0 ? data.pelayanan.map((item: any, index: number) => (
|
||||
<PengaduanSection
|
||||
key={index}
|
||||
id={item.id}
|
||||
nomer={item.noPengajuan}
|
||||
judul={item.title}
|
||||
status={item.status}
|
||||
updated={item.updatedAt}
|
||||
kategori="pelayanan"
|
||||
/>
|
||||
)) : <Text c="dimmed" ta={"center"} >Tidak ada data</Text>
|
||||
}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function PengaduanSection({ id, nomer, judul, status, updated, kategori }: { id: string, nomer: string, judul: string, status: string, updated: string, kategori: 'pengaduan' | 'pelayanan' }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap="xs"
|
||||
onClick={() => navigate(kategori == "pelayanan" ? `/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${id}` : `/scr/dashboard/pengaduan/detail?id=${id}`)}
|
||||
>
|
||||
<Flex align="center" pb={"sm"} justify="space-between" gap="md" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
<Flex direction={"column"}>
|
||||
<Text size="md" c="gray.2" lineClamp={1}>
|
||||
{judul}
|
||||
</Text>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
#{nomer} ∙ {updated}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
<Tooltip label={status}>
|
||||
<Badge size="xs" circle color={
|
||||
status === "diterima"
|
||||
? "green"
|
||||
: status === "ditolak"
|
||||
? "red"
|
||||
: status === "selesai"
|
||||
? "blue"
|
||||
: status === "dikerjakan"
|
||||
? "gray"
|
||||
: "yellow"
|
||||
} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -206,9 +206,12 @@ export default function DesaSetting({ permissions }: { permissions: JsonValue[]
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ export default function KategoriPengaduan({ permissions }: { permissions: JsonVa
|
||||
size="sm"
|
||||
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
|
||||
onClick={() => chooseEdit({ data: v })}
|
||||
disabled={!permissions.includes("setting.kategori_pengaduan.edit")}
|
||||
disabled={!permissions.includes("setting.kategori_pengaduan.edit") || v.id == "lainnya"}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</ActionIcon>
|
||||
@@ -344,7 +344,7 @@ export default function KategoriPengaduan({ permissions }: { permissions: JsonVa
|
||||
setDataDelete(v.id);
|
||||
openDelete();
|
||||
}}
|
||||
disabled={!permissions.includes("setting.kategori_pengaduan.delete")}
|
||||
disabled={!permissions.includes("setting.kategori_pengaduan.delete") || v.id == "lainnya"}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
|
||||
const [viewFile, setViewFile] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [typeFile, setTypeFile] = useState<string>("");
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && fileName) {
|
||||
@@ -27,23 +28,36 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
|
||||
// load file
|
||||
const urlApi = '/api/pengaduan/image?folder=' + folder + '&fileName=' + fileName;
|
||||
const res = await fetch(urlApi);
|
||||
if (!res.ok)
|
||||
if (!res.ok) {
|
||||
setError(true);
|
||||
return notification({
|
||||
title: "Error",
|
||||
message: "Failed to load image",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setViewFile(url);
|
||||
} catch (err) {
|
||||
console.error("Gagal load gambar:", err);
|
||||
setError(true);
|
||||
notification({
|
||||
title: "Error",
|
||||
message: "Failed to load image",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onClose();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { groupPermissions } from "@/lib/groupPermission";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { Anchor, Flex, Stack, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Node {
|
||||
@@ -14,7 +14,7 @@ function RenderNode({ node }: { node: Node }) {
|
||||
return (
|
||||
<Stack pl="md" gap={6}>
|
||||
{/* Title */}
|
||||
<Text fw={600}>- {node.label}</Text>
|
||||
<Text size="sm">- {node.label}</Text>
|
||||
|
||||
{/* Children */}
|
||||
{sub.map((child: any, i) => (
|
||||
@@ -24,6 +24,22 @@ function RenderNode({ node }: { node: Node }) {
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -32,7 +48,7 @@ export default function PermissionRole({ permissions }: { permissions: string[]
|
||||
const rootNodes = Object.values(groups);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="sm">
|
||||
{
|
||||
showAll ?
|
||||
rootNodes.map((node: any, idx) => (
|
||||
@@ -40,18 +56,12 @@ export default function PermissionRole({ permissions }: { permissions: string[]
|
||||
))
|
||||
:
|
||||
rootNodes.slice(0, 2).map((node: any, idx) => (
|
||||
<RenderNode key={idx} node={node} />
|
||||
<RenderNode2 key={idx} node={node} />
|
||||
))
|
||||
}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
w="fit-content"
|
||||
ml="md"
|
||||
>
|
||||
<Anchor size="xs" onClick={() => setShowAll(!showAll)} >
|
||||
{showAll ? "View less" : "View more"}
|
||||
</Button>
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,112 +16,148 @@ export default function PermissionTree({
|
||||
selected: string[];
|
||||
onChange: (val: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({});
|
||||
// Ambil semua child dari node
|
||||
const [openNodes, setOpenNodes] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setOpen((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
function toggleNode(label: string) {
|
||||
setOpenNodes(prev => ({ ...prev, [label]: !prev[label] }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Ambil semua key dari node termasuk semua keturunannya
|
||||
const collectKeys = (n: Node): string[] => {
|
||||
if (!n.children) return [n.key];
|
||||
return [n.key, ...n.children.flatMap(collectKeys)];
|
||||
};
|
||||
|
||||
const checkState = (node: Node): { all: boolean; some: boolean } => {
|
||||
const children = node.children || [];
|
||||
// Jika tidak ada anak → nilai hanya berdasarkan dirinya sendiri
|
||||
if (children.length === 0) {
|
||||
const checked = selected.includes(node.key);
|
||||
return { all: checked, some: checked };
|
||||
function getAllChildKeys(node: Node): string[] {
|
||||
let result: string[] = [];
|
||||
if (node.children) {
|
||||
node.children.forEach((c) => {
|
||||
result.push(c.key);
|
||||
result = [...result, ...getAllChildKeys(c)];
|
||||
});
|
||||
}
|
||||
// Rekursif ke anak
|
||||
let all = selected.includes(node.key);
|
||||
let some = selected.includes(node.key);
|
||||
for (const c of children) {
|
||||
const childState = checkState(c);
|
||||
if (!childState.all) all = false;
|
||||
if (childState.some) some = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
return { all, some };
|
||||
};
|
||||
|
||||
// Untuk ordering sesuai urutan JSON
|
||||
const getOrderedKeys = (nodes: Node[]): string[] =>
|
||||
nodes.flatMap((n) => [n.key, ...getOrderedKeys(n.children || [])]);
|
||||
// Rekursif naik ke atas
|
||||
return updateParent(next, getParentKey(parentKey));
|
||||
}
|
||||
|
||||
// dapatkan child dari string key
|
||||
function findAllChildKeysFromKey(parentKey: string) {
|
||||
const list: string[] = [];
|
||||
|
||||
const RenderNode = ({ node }: { node: Node }) => {
|
||||
const children = node.children || [];
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
const state = checkState(node); // ← gunakan recursive evaluator
|
||||
traverse(permissionConfig.menus);
|
||||
return list;
|
||||
}
|
||||
|
||||
const isChecked = state.all;
|
||||
const isIndeterminate = !state.all && state.some;
|
||||
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 + ".")
|
||||
);
|
||||
|
||||
const showChildren = open[node.key] ?? false;
|
||||
function handleCheck() {
|
||||
let next = [...selected];
|
||||
|
||||
// Ambil semua key anak + parent
|
||||
const collectKeys = (n: Node): string[] => {
|
||||
if (!n.children) return [n.key];
|
||||
return [n.key, ...n.children.flatMap(collectKeys)];
|
||||
};
|
||||
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));
|
||||
}
|
||||
|
||||
const allKeys = collectKeys(node);
|
||||
|
||||
const toggleCheck = (checked: boolean) => {
|
||||
let updated = new Set(selected);
|
||||
|
||||
if (checked) {
|
||||
// parent + semua child
|
||||
allKeys.forEach((k) => updated.add(k));
|
||||
} else {
|
||||
// hilangkan parent + semua child
|
||||
allKeys.forEach((k) => updated.delete(k));
|
||||
next = updateParent(next, getParentKey(menu.key));
|
||||
onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
// ⬇⬇⬇ PERBAIKAN PENTING ⬇⬇⬇
|
||||
//
|
||||
// Jika node indeterminate → parent harus tetap ada di selected
|
||||
//
|
||||
if (isIndeterminate) {
|
||||
updated.add(node.key);
|
||||
}
|
||||
|
||||
// Jika semua child tercentang → parent harus checked
|
||||
// klik child
|
||||
if (isChecked) {
|
||||
updated.add(node.key);
|
||||
next = next.filter((x) => x !== menu.key);
|
||||
} else {
|
||||
next.push(menu.key);
|
||||
}
|
||||
|
||||
onChange([...updated]);
|
||||
};
|
||||
next = updateParent(next, getParentKey(menu.key));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} pl="xs">
|
||||
<Group wrap="nowrap">
|
||||
{children.length > 0 ? (
|
||||
<ActionIcon variant="subtle" onClick={() => toggle(node.key)}>
|
||||
{showChildren ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
<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: 24 }} />
|
||||
<div style={{ width: 28 }} />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={node.label}
|
||||
label={menu.label}
|
||||
checked={isChecked}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => toggleCheck(e.target.checked)}
|
||||
onChange={handleCheck}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{children.length > 0 && (
|
||||
<Collapse in={showChildren}>
|
||||
{menu.children && (
|
||||
<Collapse in={open}>
|
||||
<Stack gap={4} pl="md">
|
||||
{children.map((c) => (
|
||||
<RenderNode key={c.key} node={c} />
|
||||
{menu.children.map((child) => (
|
||||
<RenderMenu key={child.key} menu={child} />
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
@@ -130,14 +166,11 @@ export default function PermissionTree({
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text size="sm">Hak Akses</Text>
|
||||
|
||||
{permissionConfig.menus.map((menu: Node) => (
|
||||
<RenderNode key={menu.key} node={menu} />
|
||||
{permissionConfig.menus.filter((menu: Node) => !menu.key.startsWith("api") && !menu.key.startsWith("credential")).map((menu: Node) => (
|
||||
<RenderMenu key={menu.key} menu={menu} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -18,10 +18,18 @@ 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);
|
||||
@@ -72,13 +80,13 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
});
|
||||
notification({
|
||||
title: "Success",
|
||||
message: "Your user have been saved",
|
||||
message: "Your role have been saved",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
notification({
|
||||
title: "Error",
|
||||
message: "Failed to create user ",
|
||||
message: "Failed to create role",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -86,7 +94,7 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
console.error(error);
|
||||
notification({
|
||||
title: "Error",
|
||||
message: "Failed to create user",
|
||||
message: "Failed to create role",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -97,19 +105,19 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
async function handleEdit() {
|
||||
try {
|
||||
setBtnLoading(true);
|
||||
const res = await apiFetch.api.pengaduan.category.update.post(dataEdit);
|
||||
const res = await apiFetch.api.user["role-update"].post(dataEdit as any);
|
||||
if (res.status === 200) {
|
||||
mutate();
|
||||
close();
|
||||
notification({
|
||||
title: "Success",
|
||||
message: "Your category have been saved",
|
||||
message: "Your role have been saved",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
notification({
|
||||
title: "Error",
|
||||
message: "Failed to edit category",
|
||||
message: "Failed to edit role",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -117,7 +125,7 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
console.error(error);
|
||||
notification({
|
||||
title: "Error",
|
||||
message: "Failed to edit category",
|
||||
message: "Failed to edit role",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -156,16 +164,10 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
}
|
||||
}
|
||||
|
||||
function chooseEdit({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: [];
|
||||
};
|
||||
}) {
|
||||
setDataEdit(data);
|
||||
function chooseEdit({ data }: { data: { id: string; name: string; permissions: []; }; }) {
|
||||
setDataEdit({
|
||||
id: data.id, name: data.name, permissions: data.permissions ? data.permissions : []
|
||||
});
|
||||
open();
|
||||
}
|
||||
|
||||
@@ -185,7 +187,27 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
}
|
||||
}
|
||||
|
||||
console.log("dataTambah", dataTambah);
|
||||
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) {
|
||||
@@ -200,11 +222,11 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={"Edit"}
|
||||
centered
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
size={"lg"}
|
||||
>
|
||||
<Stack gap="ld">
|
||||
<Input.Wrapper label="Edit Kategori">
|
||||
<Input.Wrapper label="Nama Role">
|
||||
<Input
|
||||
value={dataEdit.name}
|
||||
onChange={(e) =>
|
||||
@@ -216,6 +238,12 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
}
|
||||
/>
|
||||
</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
|
||||
@@ -223,7 +251,11 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={handleEdit}
|
||||
disabled={btnDisable}
|
||||
disabled={
|
||||
btnDisable ||
|
||||
dataEdit.name.length < 1 ||
|
||||
dataEdit.permissions?.length < 1
|
||||
}
|
||||
loading={btnLoading}
|
||||
>
|
||||
Simpan
|
||||
@@ -238,10 +270,11 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
onClose={closeTambah}
|
||||
title={"Tambah"}
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
size={"lg"}
|
||||
>
|
||||
<Stack gap="ld">
|
||||
<Input.Wrapper
|
||||
label="Nama"
|
||||
label="Nama Role"
|
||||
description=""
|
||||
error={error.name ? "Field is required" : ""}
|
||||
>
|
||||
@@ -259,7 +292,7 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
<PermissionTree
|
||||
selected={dataTambah.permissions}
|
||||
onChange={(permissions) => {
|
||||
setDataTambah({ ...dataTambah, permissions: permissions as never[] });
|
||||
setDataTambah({ ...dataTambah, permissions: sortByJsonOrder(permissions) as never[] });
|
||||
}}
|
||||
/>
|
||||
<Group justify="center" grow>
|
||||
@@ -342,11 +375,11 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
{list.length > 0 ? (
|
||||
list?.map((v: any) => (
|
||||
<Table.Tr key={v.id}>
|
||||
<Table.Td>{v.name}</Table.Td>
|
||||
<Table.Td w={"150"}>{v.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<PermissionRole permissions={v.permissions} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Table.Td w={"100"}>
|
||||
<Group>
|
||||
<Tooltip label={permissions.includes('setting.user_role.edit') ? "Edit Role" : "Edit Role - Anda tidak memiliki akses"}>
|
||||
<ActionIcon
|
||||
@@ -354,7 +387,7 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
size="sm"
|
||||
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
|
||||
onClick={() => chooseEdit({ data: v })}
|
||||
disabled={!permissions.includes('setting.user_role.edit')}
|
||||
disabled={!permissions.includes('setting.user_role.edit') || v.id == "developer"}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</ActionIcon>
|
||||
@@ -369,7 +402,7 @@ export default function UserRoleSetting({ permissions }: { permissions: JsonValu
|
||||
setDataDelete(v.id);
|
||||
openDelete();
|
||||
}}
|
||||
disabled={!permissions.includes('setting.user_role.delete')}
|
||||
disabled={!permissions.includes('setting.user_role.delete') || v.id == "developer"}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -107,19 +107,19 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
console.error(error);
|
||||
notification({
|
||||
title: "Error",
|
||||
message: "Failed to edit category",
|
||||
message: "Failed to edit user2",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -222,9 +222,10 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
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",
|
||||
@@ -234,6 +235,51 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
}
|
||||
/>
|
||||
</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
|
||||
@@ -419,13 +465,13 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{list.length > 0 ? (
|
||||
{list && Array.isArray(list) && list.length > 0 ? (
|
||||
list?.map((v: any) => (
|
||||
<Table.Tr key={v.id}>
|
||||
<Table.Td>{v.name}</Table.Td>
|
||||
<Table.Td>{v.phone}</Table.Td>
|
||||
<Table.Td>{v.email}</Table.Td>
|
||||
<Table.Td>{v.roleId}</Table.Td>
|
||||
<Table.Td>{v.nameRole}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group>
|
||||
<Tooltip label={permissions.includes('setting.user.edit') ? "Edit User" : "Edit User - Anda tidak memiliki akses"}>
|
||||
@@ -434,7 +480,7 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
size="sm"
|
||||
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
|
||||
onClick={() => chooseEdit({ data: v })}
|
||||
disabled={!permissions.includes('setting.user.edit')}
|
||||
disabled={!permissions.includes('setting.user.edit') || v.roleId == "developer"}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</ActionIcon>
|
||||
@@ -449,7 +495,7 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
|
||||
setDataDelete(v.id);
|
||||
openDelete();
|
||||
}}
|
||||
disabled={!permissions.includes('setting.user.delete')}
|
||||
disabled={!permissions.includes('setting.user.delete') || v.roleId == "developer"}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -10,14 +10,15 @@ import Auth from "./server/routes/auth_route";
|
||||
import ConfigurationDesaRoute from "./server/routes/configuration_desa_route";
|
||||
import CredentialRoute from "./server/routes/credential_route";
|
||||
import DarmasabaRoute from "./server/routes/darmasaba_route";
|
||||
import DashboardRoute from "./server/routes/dashboard_route";
|
||||
import LayananRoute from "./server/routes/layanan_route";
|
||||
import { MCPRoute } from "./server/routes/mcp_route";
|
||||
import PelayananRoute from "./server/routes/pelayanan_surat_route";
|
||||
import PengaduanRoute from "./server/routes/pengaduan_route";
|
||||
import SuratRoute from "./server/routes/surat_route";
|
||||
import TestPengaduanRoute from "./server/routes/test_pengaduan";
|
||||
import UserRoute from "./server/routes/user_route";
|
||||
import WargaRoute from "./server/routes/warga_route";
|
||||
import SuratRoute from "./server/routes/surat_route";
|
||||
|
||||
const Docs = new Elysia({
|
||||
tags: ["docs"],
|
||||
@@ -31,6 +32,7 @@ const Api = new Elysia({
|
||||
prefix: "/api",
|
||||
tags: ["api"],
|
||||
})
|
||||
.use(DashboardRoute)
|
||||
.use(PengaduanRoute)
|
||||
.use(PelayananRoute)
|
||||
.use(ConfigurationDesaRoute)
|
||||
|
||||
@@ -17,7 +17,7 @@ export const categoryPelayananSurat = [
|
||||
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
|
||||
{ name: "akta cerai", desc: "Fotokopi Akta Cerai bagi yang berstatus janda/duda" }
|
||||
],
|
||||
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "status perkawinan"]
|
||||
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "agama", "pekerjaan"]
|
||||
},
|
||||
{
|
||||
id: "skdomisiliorganisasi",
|
||||
@@ -27,7 +27,7 @@ export const categoryPelayananSurat = [
|
||||
{ name: "skt organisasi", desc: "Fotokopi Surat Keterangan Terdaftar (SKT) Organisasi atau Pengukuhan Kelompok" },
|
||||
{ name: "susunan pengurus", desc: "Jika Pengajuan baru pembuatan SKT maka melengkapi Susunan Pengurus lengkap denganKop Organisasi" }
|
||||
],
|
||||
dataText: ["nama organisasi", "alamat organisasi", "nama pemohon", "jabatan pemohon", "kontak", "penanggung jawab", "tanggal berdiri"]
|
||||
dataText: ["nama organisasi", "jenis organisasi", "alamat organisasi/sekretariat", "no telepon", "nama pimpinan", "keperluan"]
|
||||
},
|
||||
{
|
||||
id: "skkelahiran",
|
||||
@@ -45,7 +45,7 @@ export const categoryPelayananSurat = [
|
||||
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
|
||||
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" }
|
||||
],
|
||||
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "polsek"]
|
||||
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "agama", "alamat", "pekerjaan", "polsek"]
|
||||
},
|
||||
{
|
||||
id: "skkematian",
|
||||
@@ -55,7 +55,7 @@ export const categoryPelayananSurat = [
|
||||
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
|
||||
{ name: "surat kematian", desc: "Surat Keterangan Kematian dari Rumah Sakit/Dokter (jika ada)" }
|
||||
],
|
||||
dataText: ["nama almarhum", "nik", "tempat tanggal lahir", "alamat", "tanggal kematian", "waktu kematian", "penyebab kematian"]
|
||||
dataText: ["nik pelapor", "nama pelapor", "pekerjaan pelapor", "alamat pelapor", "hubungan pelapor dengan almarhum", "nama almarhum", "nik almarhum", "tempat tanggal lahir almarhum", "alamat almarhum", "agama almarhum", "tanggal kematian", "waktu kematian", "tempat kematian", "penyebab kematian"]
|
||||
},
|
||||
{
|
||||
id: "skpenghasilan",
|
||||
@@ -65,7 +65,7 @@ export const categoryPelayananSurat = [
|
||||
{ name: "ktp ortu/kk", desc: "Fotokopi KTP orang tua atau Kartu Keluarga" },
|
||||
{ name: "surat pernyataan", desc: "Surat Pernyataan Penghasilan bermaterai" }
|
||||
],
|
||||
dataText: ["nama", "nik", "alamat", "pekerjaan", "jenis usaha", "penghasilan", "alasan permohonan"]
|
||||
dataText: ["nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "pekerjaan", "penghasilan", "alasan permohonan"]
|
||||
},
|
||||
{
|
||||
id: "sktempatusaha",
|
||||
@@ -76,7 +76,7 @@ export const categoryPelayananSurat = [
|
||||
{ name: "foto lokasi", desc: "Foto lokasi usaha dicetak dalam selembar kertas, diparaf dan distempel oleh Kelian" },
|
||||
{ name: "sppt/sertifikat/sewa", desc: "Fotokopi SPPT, Sertifikat Hak Milik, Surat Perjanjian Sewa, atau Kwitansi Pembayaran Sewa 3 bulan terakhir" }
|
||||
],
|
||||
dataText: ["nama usaha", "bidang usaha", "alamat usaha", "status tempat usaha", "luas tempat usaha", "jumlah karyawan", "tujuan pembuatan surat"]
|
||||
dataText: ["nik", "nama pemilik", "tempat tanggal lahir", "alamat pemilik", "nama usaha", "bidang usaha", "alamat usaha", "status tempat usaha", "luas tempat usaha", "jumlah karyawan", "tujuan pembuatan surat"]
|
||||
},
|
||||
{
|
||||
id: "sktidakmampu",
|
||||
@@ -104,6 +104,6 @@ export const categoryPelayananSurat = [
|
||||
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
|
||||
{ name: "ktp/kia/kk", desc: "Fotokopi KTP, KIA, atau Kartu Keluarga" }
|
||||
],
|
||||
dataText: ["nama anak", "nama ayah", "status ayah", "nama ibu", "status ibu"]
|
||||
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "pekerjaan", "nama ayah", "status ayah", "nama ibu", "status ibu"]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -24,41 +24,41 @@
|
||||
},
|
||||
{
|
||||
"key": "pengaduan.antrian",
|
||||
"label": "Antrian",
|
||||
"label": "Detail pengaduan dengan status antrian",
|
||||
"default": true,
|
||||
"children": [
|
||||
{
|
||||
"key": "pengaduan.antrian.tolak",
|
||||
"label": "Menolak",
|
||||
"label": "Menolak pengaduan",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "pengaduan.antrian.terima",
|
||||
"label": "Menerima",
|
||||
"label": "Menerima pengaduan",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "pengaduan.diterima",
|
||||
"label": "Diterima",
|
||||
"label": "Detail pengaduan dengan status diterima",
|
||||
"default": true,
|
||||
"children": [
|
||||
{
|
||||
"key": "pengaduan.diterima.dikerjakan",
|
||||
"label": "Dikerjakan",
|
||||
"label": "Menegerjakan pengaduan",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "pengaduan.dikerjakan",
|
||||
"label": "Dikerjakan",
|
||||
"label": "Detail pengaduan dengan status dikerjakan",
|
||||
"default": true,
|
||||
"children": [
|
||||
{
|
||||
"key": "pengaduan.dikerjakan.selesai",
|
||||
"label": "Diselesaikan",
|
||||
"label": "Menyelesaikan pengaduan",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
@@ -77,34 +77,34 @@
|
||||
},
|
||||
{
|
||||
"key": "pelayanan.antrian",
|
||||
"label": "Antrian",
|
||||
"label": "Detail pelayanan dengan status antrian",
|
||||
"default": true,
|
||||
"children": [
|
||||
{
|
||||
"key": "pelayanan.antrian.tolak",
|
||||
"label": "Menolak",
|
||||
"label": "Menolak pelayanan",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "pelayanan.antrian.terima",
|
||||
"label": "Menerima",
|
||||
"label": "Menerima pelayanan",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "pelayanan.diterima",
|
||||
"label": "Diterima",
|
||||
"label": "Detail pelayanan dengan status diterima",
|
||||
"default": true,
|
||||
"children": [
|
||||
{
|
||||
"key": "pelayanan.diterima.tolak",
|
||||
"label": "Menolak",
|
||||
"label": "Menolak pelayanan",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "pelayanan.diterima.setujui",
|
||||
"label": "Menyetujui",
|
||||
"label": "Menyetujui pelayanan",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
@@ -300,7 +300,7 @@
|
||||
"default": true,
|
||||
"children": [
|
||||
{
|
||||
"key": "credential.viewØ",
|
||||
"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)}
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import DashboardCountData from "@/components/DashboardCountData";
|
||||
import DashboardGrafik from "@/components/DashboardGrafik";
|
||||
import DashboardLastData from "@/components/DashboardLastData";
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
Container,
|
||||
Flex,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Progress,
|
||||
Badge,
|
||||
Button,
|
||||
Grid,
|
||||
Divider,
|
||||
Title
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconActivity,
|
||||
IconUsers,
|
||||
IconServer,
|
||||
IconDatabase,
|
||||
IconSettings,
|
||||
IconArrowRight,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
@@ -43,144 +34,15 @@ export default function Dashboard() {
|
||||
Live
|
||||
</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: "teal", to: "cyan", deg: 45 }}
|
||||
radius="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
style={{
|
||||
boxShadow: "0 0 12px rgba(0,255,200,0.3)",
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
icon={<IconUsers size={28} />}
|
||||
label="Active Users"
|
||||
value="1,248"
|
||||
change="+12%"
|
||||
color="teal"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
icon={<IconServer size={28} />}
|
||||
label="Server Uptime"
|
||||
value="99.98%"
|
||||
change="+0.02%"
|
||||
color="cyan"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
icon={<IconDatabase size={28} />}
|
||||
label="Database Ops"
|
||||
value="82.4K"
|
||||
change="+5.6%"
|
||||
color="blue"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
icon={<IconActivity size={28} />}
|
||||
label="System Health"
|
||||
value="Stable"
|
||||
change=""
|
||||
color="green"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
radius="lg"
|
||||
p="xl"
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
|
||||
borderColor: "rgba(100,100,100,0.2)",
|
||||
boxShadow: "0 0 25px rgba(0,255,200,0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Title order={4} c="gray.0">
|
||||
System Performance
|
||||
</Title>
|
||||
<IconSettings size={20} color="gray" />
|
||||
</Flex>
|
||||
<Divider my="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Resource usage and performance indicators.
|
||||
</Text>
|
||||
<Stack gap="sm" mt="md">
|
||||
<ProgressSection label="CPU Usage" value={68} color="teal" />
|
||||
<ProgressSection label="Memory Usage" value={75} color="cyan" />
|
||||
<ProgressSection label="Network Load" value={42} color="blue" />
|
||||
<ProgressSection label="Disk Space" value={88} color="red" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<DashboardCountData />
|
||||
<DashboardGrafik />
|
||||
<DashboardLastData />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
change?: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
radius="lg"
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(30,30,30,0.95), rgba(55,55,55,0.9))",
|
||||
borderColor: "rgba(100,100,100,0.2)",
|
||||
transition: "transform 0.15s ease, box-shadow 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.boxShadow = "0 0 10px rgba(0,255,200,0.2)")
|
||||
}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
{icon}
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Text fw={600} size="xl" c="gray.0">
|
||||
{value}
|
||||
</Text>
|
||||
{change && (
|
||||
<Text size="sm" c={color}>
|
||||
{change}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressSection({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ModalFile from "@/components/ModalFile";
|
||||
import ModalSurat from "@/components/ModalSurat";
|
||||
import notification from "@/components/notificationGlobal";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
@@ -77,7 +78,9 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
|
||||
const [host, setHost] = useState<User | null>(null);
|
||||
const [noSurat, setNoSurat] = useState("");
|
||||
const [openedPreview, setOpenedPreview] = useState(false);
|
||||
const [openedPreviewFile, setOpenedPreviewFile] = useState(false);
|
||||
const [permissions, setPermissions] = useState<JsonValue[]>([]);
|
||||
const [viewImg, setViewImg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchHost() {
|
||||
@@ -128,8 +131,22 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
|
||||
}
|
||||
}
|
||||
|
||||
useShallowEffect(() => {
|
||||
if (viewImg) {
|
||||
setOpenedPreviewFile(true);
|
||||
}
|
||||
}, [viewImg]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalFile
|
||||
open={openedPreviewFile && !_.isEmpty(viewImg)}
|
||||
onClose={() => {
|
||||
setOpenedPreviewFile(false)
|
||||
}}
|
||||
folder="syarat-dokumen"
|
||||
fileName={viewImg}
|
||||
/>
|
||||
|
||||
{/* MODAL KONFIRMASI */}
|
||||
<Modal
|
||||
@@ -246,7 +263,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
|
||||
>
|
||||
{syaratDokumen?.map((v: any) => (
|
||||
<List.Item key={v.id}>
|
||||
<Anchor href="https://mantine.dev/" target="_blank">
|
||||
<Anchor onClick={() => { setViewImg(v.value) }}>
|
||||
{v.jenis}
|
||||
</Anchor>
|
||||
</List.Item>
|
||||
@@ -378,7 +395,17 @@ function DetailDataHistori({ data }: { data: any }) {
|
||||
{
|
||||
data?.map((item: any) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>
|
||||
{
|
||||
item.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
})
|
||||
}</Table.Td>
|
||||
<Table.Td>{item.deskripsi}</Table.Td>
|
||||
<Table.Td>{item.status}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
Pagination,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -113,22 +115,26 @@ type StatusKey =
|
||||
function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [value, setValue] = useState("");
|
||||
const { data, mutate, isLoading } = useSwr("/", async () => {
|
||||
const res = await apiFetch.api.pelayanan.list.get({
|
||||
const { data, mutate, isLoading } = useSwr("/", async () =>
|
||||
apiFetch.api.pelayanan.list.get({
|
||||
query: {
|
||||
status,
|
||||
search: value,
|
||||
take: "",
|
||||
page: "",
|
||||
page: page.toString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
useShallowEffect(() => {
|
||||
setPage(1);
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
|
||||
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
|
||||
});
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
}, [page]);
|
||||
|
||||
|
||||
useShallowEffect(() => {
|
||||
@@ -155,26 +161,39 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const list = data || [];
|
||||
const list = data?.data?.data || [];
|
||||
const total = data?.data?.total || 0;
|
||||
const totalPage = data?.data?.totalPages || 1;
|
||||
const pageSize = data?.data?.pageSize || 10;
|
||||
const pageNow = data?.data?.page || 1;
|
||||
const toDate = (d: any) => new Date(d);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group grow>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengajuan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Grid>
|
||||
<Grid.Col span={9}>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengajuan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="gray.5">{`${pageSize * (page - 1) + 1} – ${Math.min(total, pageSize * page)} of ${total}`}</Text>
|
||||
<Pagination total={totalPage} value={page} onChange={setPage} withPages={false} />
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{Array.isArray(list) && list?.length === 0 ? (
|
||||
<Flex justify="center" align="center" py={"xl"}>
|
||||
<Stack gap={4} align="center">
|
||||
@@ -214,7 +233,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
#{v.noPengajuan}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{v.updatedAt}
|
||||
{String(v.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
@@ -247,7 +266,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
Tanggal Ajuan
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="md">{v.createdAt}</Text>
|
||||
<Text size="md">{toDate(v.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })}</Text>
|
||||
</Flex>
|
||||
<Flex direction={"column"} justify="flex-start">
|
||||
<Group gap="xs">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ModalFile from "@/components/ModalFile";
|
||||
import notification from "@/components/notificationGlobal";
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import {
|
||||
@@ -10,13 +11,12 @@ import {
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
Title
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -73,9 +73,7 @@ export default function DetailPengaduanPage() {
|
||||
function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => void }) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [openedModalImage, { open: openModalImage, close: closeModalImage }] =
|
||||
useDisclosure(false);
|
||||
const [openedPreview, setOpenedPreview] = useState(false);
|
||||
const [keterangan, setKeterangan] = useState("");
|
||||
const [host, setHost] = useState<User | null>(null);
|
||||
const [permissions, setPermissions] = useState<JsonValue[]>([]);
|
||||
@@ -173,14 +171,12 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
|
||||
|
||||
|
||||
{/* MODAL GAMBAR */}
|
||||
<Modal
|
||||
opened={openedModalImage}
|
||||
onClose={closeModalImage}
|
||||
title="Gambar Pengaduan"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
>
|
||||
<Image src={imageSrc!} />
|
||||
</Modal>
|
||||
<ModalFile
|
||||
open={openedPreview && !_.isEmpty(data?.image)}
|
||||
onClose={() => setOpenedPreview(false)}
|
||||
folder="pengaduan"
|
||||
fileName={data?.image}
|
||||
/>
|
||||
|
||||
<Card
|
||||
radius="md"
|
||||
@@ -263,9 +259,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={() => { setOpenedPreview(true) }}>
|
||||
Lihat Gambar
|
||||
</Anchor>
|
||||
:
|
||||
<Text size="md" c="white">
|
||||
-
|
||||
</Text>
|
||||
}
|
||||
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
@@ -389,7 +394,16 @@ function DetailDataHistori({ data }: { data: any }) {
|
||||
{
|
||||
data?.map((item: any) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{
|
||||
item.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
})
|
||||
}</Table.Td>
|
||||
<Table.Td>{item.deskripsi}</Table.Td>
|
||||
<Table.Td>{item.status}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
Pagination,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -124,22 +126,25 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [value, setValue] = useState("");
|
||||
const { data, mutate, isLoading } = useSwr("/", async () => {
|
||||
const res = await apiFetch.api.pengaduan.list.get({
|
||||
const { data, mutate, isLoading } = useSwr("/", async () =>
|
||||
apiFetch.api.pengaduan.list.get({
|
||||
query: {
|
||||
status,
|
||||
search: value,
|
||||
take: "",
|
||||
page: "",
|
||||
page: page.toString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
|
||||
});
|
||||
useShallowEffect(() => {
|
||||
setPage(1);
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
}, [page]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
const unsubscribe = subscribe(state, () => mutate());
|
||||
@@ -163,31 +168,41 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const list = data || [];
|
||||
const list = data?.data?.data || [];
|
||||
const total = data?.data?.total || 0;
|
||||
const totalPage = data?.data?.totalPages || 1;
|
||||
const pageSize = data?.data?.pageSize || 10;
|
||||
const pageNow = data?.data?.page || 1;
|
||||
const toDate = (d: any) => new Date(d);
|
||||
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group grow>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengaduan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* <Group justify="flex-end">
|
||||
<Text size="sm">Menampilkan {Number(data?.data?.length) * (page - 1) + 1} – {Math.min(10, Number(data?.data?.length) * page)} dari {Number(data?.data?.length)}</Text>
|
||||
<Pagination total={Number(data?.data?.length)} value={page} onChange={setPage} withPages={false} />
|
||||
</Group> */}
|
||||
</Group>
|
||||
{list.length === 0 ? (
|
||||
<Grid>
|
||||
<Grid.Col span={9}>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengaduan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="gray.5">{`${pageSize * (page - 1) + 1} – ${Math.min(total, pageSize * page)} of ${total}`}</Text>
|
||||
<Pagination total={totalPage} value={page} onChange={setPage} withPages={false} />
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{Array.isArray(list) && list.length === 0 ? (
|
||||
<Flex justify="center" align="center" py={"xl"}>
|
||||
<Stack gap={4} align="center">
|
||||
<IconFileSad size={32} color="gray" />
|
||||
@@ -224,7 +239,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
#{v.noPengaduan}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{v.updatedAt}
|
||||
{String(v.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
@@ -257,7 +272,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
Tanggal Aduan
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="md">{v.createdAt}</Text>
|
||||
<Text size="md">{toDate(v.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })}</Text>
|
||||
</Flex>
|
||||
<Flex direction={"column"} justify="flex-start">
|
||||
<Group gap="xs">
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Input,
|
||||
Pagination,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
@@ -19,21 +22,32 @@ import useSWR from "swr";
|
||||
|
||||
export default function ListWargaPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, mutate, isLoading } = useSWR("/", () =>
|
||||
const [pages, setPages] = useState(1);
|
||||
const [value, setValue] = useState("");
|
||||
const { data, mutate } = useSWR("/", () =>
|
||||
apiFetch.api.warga.list.get({
|
||||
query: {
|
||||
search: value,
|
||||
page: pages,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const list = data?.data || [];
|
||||
const list = data?.data?.data || [];
|
||||
const total = data?.data?.total || 0;
|
||||
const totalPage = data?.data?.totalPages || 1;
|
||||
const pageSize = data?.data?.pageSize || 10;
|
||||
const pageNow = data?.data?.page || 1;
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useShallowEffect(() => {
|
||||
setPages(1);
|
||||
mutate();
|
||||
}, [value]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [value]);
|
||||
}, [pages]);
|
||||
|
||||
|
||||
return (
|
||||
@@ -48,10 +62,10 @@ export default function ListWargaPage() {
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Title order={3} c="gray.2">
|
||||
List Data Warga
|
||||
</Title>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Title order={3} c="gray.2">
|
||||
List Data Warga
|
||||
</Title>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari warga..."
|
||||
@@ -66,6 +80,10 @@ export default function ListWargaPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Group>
|
||||
<Text size="sm">{`${pageSize * (pages - 1) + 1} – ${Math.min(total, pageSize * pages)} of ${total}`}</Text>
|
||||
<Pagination total={totalPage} value={pages} onChange={setPages} withPages={false} />
|
||||
</Group>
|
||||
</Flex>
|
||||
<Divider my={0} />
|
||||
<Table>
|
||||
@@ -86,8 +104,8 @@ export default function ListWargaPage() {
|
||||
Array.isArray(list) && list?.map((item, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>{item.phone}</Table.Td>
|
||||
<Table.Td>
|
||||
<Table.Td w={250}>{item.phone}</Table.Td>
|
||||
<Table.Td w={150}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
export function isValidPhone(number: string): boolean {
|
||||
const clean = number.replace(/[\s.-]/g, ""); // hapus spasi, titik, strip
|
||||
const regex = /^(?:\+62|62|0)8\d{7,12}$/;
|
||||
return regex.test(clean);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber({ phone }: { phone: string }) {
|
||||
// Hapus semua spasi, tanda hubung, atau karakter non-digit (+ tetap dipertahankan untuk dicek)
|
||||
let cleaned = phone.trim().replace(/[\s-]/g, "");
|
||||
let cleaned = phone.trim().replace(/[\s.-]/g, "");
|
||||
|
||||
// Jika diawali dengan +62 → ganti jadi 62
|
||||
if (cleaned.startsWith("+62")) {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
278
src/server/routes/dashboard_route.ts
Normal file
278
src/server/routes/dashboard_route.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import Elysia from "elysia";
|
||||
import { getLastUpdated } from "../lib/get-last-updated";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
const DashboardRoute = new Elysia({
|
||||
prefix: "dashboard",
|
||||
tags: ["dashboard"],
|
||||
})
|
||||
|
||||
.get("/count", async () => {
|
||||
// ---- RANGE HARI INI ----
|
||||
const now = new Date();
|
||||
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfToday = new Date(now);
|
||||
endOfToday.setHours(23, 59, 59, 999);
|
||||
|
||||
// ---- RANGE KEMARIN ----
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const startOfYesterday = new Date(yesterday);
|
||||
startOfYesterday.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfYesterday = new Date(yesterday);
|
||||
endOfYesterday.setHours(23, 59, 59, 999);
|
||||
|
||||
// ---- QUERY ----
|
||||
|
||||
const dataWarga = await prisma.warga.count();
|
||||
|
||||
// Pengaduan
|
||||
const dataPengaduanToday = await prisma.pengaduan.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: "antrian",
|
||||
createdAt: {
|
||||
gte: startOfToday,
|
||||
lte: endOfToday,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dataPengaduanYesterday = await prisma.pengaduan.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: "antrian",
|
||||
createdAt: {
|
||||
gte: startOfYesterday,
|
||||
lte: endOfYesterday,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const kenaikanPengaduan =
|
||||
dataPengaduanYesterday === 0
|
||||
? dataPengaduanToday > 0
|
||||
? dataPengaduanToday * 100
|
||||
: 0
|
||||
: ((dataPengaduanToday - dataPengaduanYesterday) / dataPengaduanYesterday) * 100;
|
||||
|
||||
// Pelayanan
|
||||
const dataPelayananToday = await prisma.pelayananAjuan.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: "antrian",
|
||||
createdAt: {
|
||||
gte: startOfToday,
|
||||
lte: endOfToday,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dataPelayananYesterday = await prisma.pelayananAjuan.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: "antrian",
|
||||
createdAt: {
|
||||
gte: startOfYesterday,
|
||||
lte: endOfYesterday,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const kenaikanPelayanan =
|
||||
dataPelayananYesterday === 0
|
||||
? dataPelayananToday > 0
|
||||
? dataPelayananToday * 100
|
||||
: 0
|
||||
: ((dataPelayananToday - dataPelayananYesterday) / dataPelayananYesterday) * 100;
|
||||
|
||||
// ---- FINAL OUTPUT ----
|
||||
|
||||
const dataFix = {
|
||||
warga: dataWarga,
|
||||
pengaduan: {
|
||||
today: dataPengaduanToday,
|
||||
yesterday: dataPengaduanYesterday,
|
||||
kenaikan: Number(kenaikanPengaduan.toFixed(2)), // dalam persen
|
||||
},
|
||||
pelayanan: {
|
||||
today: dataPelayananToday,
|
||||
yesterday: dataPelayananYesterday,
|
||||
kenaikan: Number(kenaikanPelayanan.toFixed(2)), // dalam persen
|
||||
},
|
||||
};
|
||||
|
||||
return dataFix;
|
||||
|
||||
}, {
|
||||
detail: {
|
||||
summary: "Dashboard - Menghitung Data",
|
||||
description: `tool untuk menghitung data pengaduan dan pelayanan yg masuk hari ini dan data warga`,
|
||||
}
|
||||
})
|
||||
.get("/last-update", async () => {
|
||||
const dataPengaduan = await prisma.pengaduan.findMany({
|
||||
skip: 0,
|
||||
take: 5,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
})
|
||||
|
||||
const dataPengaduanFix = dataPengaduan.map((item) => {
|
||||
return {
|
||||
noPengaduan: item.noPengaduan,
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
|
||||
}
|
||||
})
|
||||
|
||||
const dataPelayanan = await prisma.pelayananAjuan.findMany({
|
||||
skip: 0,
|
||||
take: 5,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
noPengajuan: true,
|
||||
updatedAt: true,
|
||||
CategoryPelayanan: {
|
||||
select: {
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dataPelayananFix = dataPelayanan.map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
noPengajuan: item.noPengajuan,
|
||||
title: item.CategoryPelayanan.name,
|
||||
status: item.status,
|
||||
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
const dataFix = {
|
||||
pengaduan: dataPengaduanFix,
|
||||
pelayanan: dataPelayananFix,
|
||||
}
|
||||
|
||||
return dataFix;
|
||||
}, {
|
||||
detail: {
|
||||
summary: "Dashboard - List data pengaduan dan pelayanan terupdate",
|
||||
description: `tool untuk mendapatkan list data pengaduan dan pelayanan yg terupdate`,
|
||||
}
|
||||
})
|
||||
.get("/grafik", async () => {
|
||||
const now = new Date();
|
||||
|
||||
const start7Days = new Date(now);
|
||||
start7Days.setDate(start7Days.getDate() - 7);
|
||||
start7Days.setHours(0, 0, 0, 0);
|
||||
|
||||
const endToday = new Date(now);
|
||||
endToday.setHours(23, 59, 59, 999);
|
||||
|
||||
// Ambil semua data pengaduan & pelayanan dalam 7 hari
|
||||
const pengaduan = await prisma.pengaduan.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: start7Days,
|
||||
lte: endToday,
|
||||
},
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const pelayanan = await prisma.pelayananAjuan.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: start7Days,
|
||||
lte: endToday,
|
||||
},
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
// --- BUAT RANGE TANGGAL 7 HARI ---
|
||||
const resultMap: Record<string, { pengaduan: number; pelayanan: number }> = {};
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const d = new Date(start7Days);
|
||||
d.setDate(d.getDate() + i);
|
||||
|
||||
const formatted = d.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "long"
|
||||
});
|
||||
|
||||
resultMap[formatted] = { pengaduan: 0, pelayanan: 0 };
|
||||
}
|
||||
|
||||
// --- HITUNG PENGADUAN PER HARI ---
|
||||
pengaduan.forEach((item) => {
|
||||
const t = item.createdAt.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "long"
|
||||
});
|
||||
|
||||
if (resultMap[t]) {
|
||||
resultMap[t].pengaduan += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// --- HITUNG PELAYANAN PER HARI ---
|
||||
pelayanan.forEach((item) => {
|
||||
const t = item.createdAt.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "long"
|
||||
});
|
||||
|
||||
if (resultMap[t]) {
|
||||
resultMap[t].pelayanan += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// --- KONVERSI KE FORMAT FINAL ---
|
||||
const source = Object.keys(resultMap).map((tanggal) => ({
|
||||
tanggal,
|
||||
pengaduan: resultMap[tanggal]?.pengaduan,
|
||||
pelayanan: resultMap[tanggal]?.pelayanan,
|
||||
}));
|
||||
|
||||
return {
|
||||
dimensions: ["tanggal", "pengaduan", "pelayanan"],
|
||||
source,
|
||||
};
|
||||
}, {
|
||||
detail: {
|
||||
summary: "Dashboard - Grafik data pengaduan dan pelayanan",
|
||||
description: `tool untuk mendapatkan grafik data pengaduan dan pelayanan`,
|
||||
}
|
||||
})
|
||||
;
|
||||
|
||||
;
|
||||
|
||||
export default DashboardRoute
|
||||
@@ -1,250 +1,485 @@
|
||||
// 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,
|
||||
xPayload: Record<string, any> = {}
|
||||
) {
|
||||
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}`);
|
||||
for(const [key, value] of Object.entries(xPayload)) {
|
||||
opts.headers![key] = value;
|
||||
}
|
||||
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, xPayload: Record<string, any>): 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, xPayload);
|
||||
|
||||
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, headers }) => {
|
||||
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;
|
||||
}
|
||||
const xPayload = {
|
||||
['x-user']: headers['x-user'] || "",
|
||||
['x-phone']: headers['x-phone'] || ""
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
body.map((req) => handleMCPRequestAsync(req))
|
||||
);
|
||||
return results;
|
||||
} catch (error: any) {
|
||||
set.status = 400;
|
||||
return {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// If batch array -> allSettled for resilience
|
||||
if (Array.isArray(body)) {
|
||||
const promises = body.map((req: JSONRPCRequest) => handleMCPRequestAsync(req, xPayload));
|
||||
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, xPayload);
|
||||
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
|
||||
------------------------- */
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { StatusPengaduan } from "generated/prisma"
|
||||
import { createSurat } from "../lib/create-surat"
|
||||
import { getLastUpdated } from "../lib/get-last-updated"
|
||||
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
|
||||
import { normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { isValidPhone, normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { prisma } from "../lib/prisma"
|
||||
|
||||
const PelayananRoute = new Elysia({
|
||||
@@ -250,14 +250,7 @@ const PelayananRoute = new Elysia({
|
||||
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
|
||||
}),
|
||||
createdAt: item.createdAt,
|
||||
idUser: item.idUser,
|
||||
nameUser: item.User?.name,
|
||||
}
|
||||
@@ -298,11 +291,14 @@ const PelayananRoute = new Elysia({
|
||||
tags: ["mcp"]
|
||||
}
|
||||
})
|
||||
.post("/create", async ({ body }) => {
|
||||
const { kategoriId, wargaId, noTelepon, dataText, syaratDokumen } = body
|
||||
.post("/create", async ({ body, headers }) => {
|
||||
const { kategoriId, dataText, syaratDokumen } = body
|
||||
const namaWarga = headers['x-user'] || ""
|
||||
const noTelepon = headers['x-phone'] || ""
|
||||
const noPengajuan = await generateNoPengajuanSurat()
|
||||
let idCategoryFix = kategoriId
|
||||
let idWargaFix = wargaId
|
||||
let idWargaFix = ""
|
||||
|
||||
const category = await prisma.categoryPelayanan.findUnique({
|
||||
where: {
|
||||
id: kategoriId,
|
||||
@@ -324,36 +320,28 @@ const PelayananRoute = new Elysia({
|
||||
|
||||
}
|
||||
|
||||
const warga = await prisma.warga.findUnique({
|
||||
if (!isValidPhone(noTelepon)) {
|
||||
return { success: false, message: 'nomor telepon tidak valid, harap masukkan nomor yang benar' }
|
||||
}
|
||||
|
||||
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.findFirst({
|
||||
where: {
|
||||
phone: nomorHP,
|
||||
}
|
||||
})
|
||||
|
||||
if (!cariWarga) {
|
||||
const wargaCreate = await prisma.warga.create({
|
||||
data: {
|
||||
name: wargaId,
|
||||
phone: nomorHP,
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
idWargaFix = wargaCreate.id
|
||||
} else {
|
||||
idWargaFix = cariWarga.id
|
||||
}
|
||||
|
||||
}
|
||||
idWargaFix = dataWarga.id
|
||||
|
||||
const pengaduan = await prisma.pelayananAjuan.create({
|
||||
data: {
|
||||
@@ -374,6 +362,10 @@ const PelayananRoute = new Elysia({
|
||||
let dataInsertDataText = []
|
||||
|
||||
for (const item of syaratDokumen) {
|
||||
console.log('syarat dokumen', item)
|
||||
const jenisFix = (category?.syaratDokumen as Array<{ name: string; desc: string }>)
|
||||
?.find((v) => v.name === item.jenis || v.desc === item.jenis);
|
||||
console.log('jenis fix', jenisFix)
|
||||
dataInsertSyaratDokumen.push({
|
||||
idPengajuanLayanan: pengaduan.id,
|
||||
idCategory: idCategoryFix,
|
||||
@@ -383,6 +375,9 @@ const PelayananRoute = new Elysia({
|
||||
}
|
||||
|
||||
for (const item of dataText) {
|
||||
console.log('dataitem', item)
|
||||
const jenisFix = category?.dataText.find((v) => v.toLowerCase() == item.jenis.toLowerCase())
|
||||
console.log('data text fix', jenisFix)
|
||||
dataInsertDataText.push({
|
||||
idPengajuanLayanan: pengaduan.id,
|
||||
idCategory: idCategoryFix,
|
||||
@@ -391,6 +386,9 @@ const PelayananRoute = new Elysia({
|
||||
})
|
||||
}
|
||||
|
||||
console.log('datainsertsyaratdokumen', dataInsertSyaratDokumen)
|
||||
console.log('datainsertdatatext', dataInsertDataText)
|
||||
|
||||
await prisma.syaratDokumenPelayanan.createMany({
|
||||
data: dataInsertSyaratDokumen,
|
||||
})
|
||||
@@ -407,42 +405,36 @@ const PelayananRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, message: 'pengajuan surat sudah dibuat' }
|
||||
return { success: true, message: 'pengajuan layanan surat sudah dibuat dengan nomer ' + noPengajuan + ', nomer ini akan digunakan untuk mengakses pengajuan ini' }
|
||||
}, {
|
||||
body: t.Object({
|
||||
kategoriId: t.String({
|
||||
minLength: 1,
|
||||
description: "ID atau nama kategori pelayanan surat yang dipilih. Jika berupa nama, sistem akan mencocokkan secara otomatis.",
|
||||
examples: ["skusaha"],
|
||||
error: "ID kategori harus diisi"
|
||||
}),
|
||||
// namaWarga: t.String({
|
||||
// description: "Nama warga",
|
||||
// examples: ["Budi Santoso"],
|
||||
// error: "Nama warga harus diisi"
|
||||
// }),
|
||||
|
||||
wargaId: t.String({
|
||||
minLength: 1,
|
||||
description: "ID warga atau nama warga. Jika ID tidak ditemukan, sistem akan mencari berdasarkan nama.",
|
||||
examples: ["Budi Santoso"],
|
||||
error: "ID warga harus diisi"
|
||||
}),
|
||||
|
||||
noTelepon: t.String({
|
||||
minLength: 8,
|
||||
description: "Nomor HP warga yang akan dinormalisasi. Jika data warga tidak ditemukan berdasarkan idWarga, pencarian dilakukan via nomor ini.",
|
||||
examples: ["081234567890"],
|
||||
error: "Nomor telepon harus diisi"
|
||||
}),
|
||||
// noTelepon: t.String({
|
||||
// error: "Nomor telepon harus diisi",
|
||||
// examples: ["08123456789", "+628123456789"],
|
||||
// description: "Nomor telepon warga pelapor"
|
||||
// }),
|
||||
|
||||
dataText: t.Array(
|
||||
t.Object({
|
||||
jenis: t.String({
|
||||
minLength: 1,
|
||||
description: "Jenis field yang dibutuhkan oleh kategori pelayanan. Biasanya dinamis.",
|
||||
examples: ["nama", "alamat", "pekerjaan", "keperluan"],
|
||||
examples: ["nama", "jenis kelamin", "tempat tanggal lahir", "negara", "agama", "status perkawinan", "alamat", "pekerjaan", "jenis usaha", "alamat usaha"],
|
||||
error: "jenis harus diisi"
|
||||
}),
|
||||
value: t.String({
|
||||
minLength: 1,
|
||||
description: "Isi atau nilai dari jenis field terkait.",
|
||||
examples: ["Budi Santoso", "Jl. Mawar No. 10", "Karyawan Swasta"],
|
||||
examples: ["Budi Santoso", "Laki-laki", "Denpasar, 28 Februari 1990", "Indonesia", "Islam", "Belum menikah", "Jl. Mawar No. 10", "Karyawan Swasta", "usaha makanan", "Jl. Melati No. 21"],
|
||||
error: "value harus diisi"
|
||||
}),
|
||||
}),
|
||||
@@ -450,6 +442,14 @@ const PelayananRoute = new Elysia({
|
||||
description: "Kumpulan data text dinamis sesuai kategori layanan.",
|
||||
examples: [
|
||||
[
|
||||
{ jenis: "nama", value: "Budi Santoso" },
|
||||
{ jenis: "jenis kelamin", value: "Laki-laki" },
|
||||
{ jenis: "tempat tanggal lahir", value: "Denpasar, 28 Februari 1990" },
|
||||
{ jenis: "negara", value: "Indonesia" },
|
||||
{ jenis: "agama", value: "Islam" },
|
||||
{ jenis: "status perkawinan", value: "Belum menikah" },
|
||||
{ jenis: "alamat", value: "Jl. Mawar No. 10" },
|
||||
{ jenis: "pekerjaan", value: "Karyawan Swasta" },
|
||||
{ jenis: "jenis usaha", value: "usaha makanan" },
|
||||
{ jenis: "alamat usaha", value: "Jl. Melati No. 21" },
|
||||
]
|
||||
@@ -461,13 +461,11 @@ const PelayananRoute = new Elysia({
|
||||
syaratDokumen: t.Array(
|
||||
t.Object({
|
||||
jenis: t.String({
|
||||
minLength: 1,
|
||||
description: "Jenis dokumen persyaratan yang diminta oleh kategori layanan.",
|
||||
examples: ["ktp", "kk", "surat_pengantar_rt"],
|
||||
error: "jenis harus diisi"
|
||||
}),
|
||||
value: t.String({
|
||||
minLength: 1,
|
||||
description: "Nama file atau identifier file dokumen yang diupload.",
|
||||
examples: ["ktp_budi.png", "kk_budi.png"],
|
||||
error: "value harus diisi"
|
||||
@@ -487,7 +485,7 @@ const PelayananRoute = new Elysia({
|
||||
),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Create Pengajuan Pelayanan Surat",
|
||||
summary: "Buat Pengajuan Pelayanan Surat",
|
||||
description: `tool untuk membuat pengajuan pelayanan surat dengan syarat dokumen serta data text sesuai kategori pelayanan surat yang dipilih`,
|
||||
tags: ["mcp"]
|
||||
}
|
||||
@@ -580,6 +578,14 @@ const PelayananRoute = new Elysia({
|
||||
mode: "insensitive"
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Warga: {
|
||||
name: {
|
||||
contains: search ?? "",
|
||||
mode: "insensitive"
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -591,6 +597,11 @@ const PelayananRoute = new Elysia({
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const totalData = await prisma.pelayananAjuan.count({
|
||||
where
|
||||
});
|
||||
|
||||
const data = await prisma.pelayananAjuan.findMany({
|
||||
skip,
|
||||
take: !take ? 10 : Number(take),
|
||||
@@ -624,12 +635,20 @@ const PelayananRoute = new Elysia({
|
||||
category: item.CategoryPelayanan.name,
|
||||
warga: item.Warga.name,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
|
||||
}
|
||||
})
|
||||
|
||||
return dataFix
|
||||
const dataReturn = {
|
||||
data: dataFix,
|
||||
total: totalData,
|
||||
page: Number(page) || 1,
|
||||
pageSize: !take ? 10 : Number(take),
|
||||
totalPages: Math.ceil(totalData / (!take ? 10 : Number(take)))
|
||||
}
|
||||
|
||||
return dataReturn
|
||||
}, {
|
||||
query: t.Object({
|
||||
take: t.String({ optional: true }),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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"
|
||||
import { generateNoPengaduan } from "../lib/no-pengaduan"
|
||||
import { normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { isValidPhone, normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { prisma } from "../lib/prisma"
|
||||
import { renameFile } from "../lib/rename-file"
|
||||
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileBase64 } from "../lib/seafile"
|
||||
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileToFolder } from "../lib/seafile"
|
||||
|
||||
const PengaduanRoute = new Elysia({
|
||||
prefix: "pengaduan",
|
||||
@@ -106,12 +107,14 @@ const PengaduanRoute = new Elysia({
|
||||
|
||||
|
||||
// --- PENGADUAN ---
|
||||
.post("/create", async ({ body }) => {
|
||||
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, wargaId, noTelepon } = body
|
||||
.post("/create", async ({ body, headers }) => {
|
||||
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId } = body
|
||||
const namaWarga = headers['x-user'] || ""
|
||||
const noTelepon = headers['x-phone'] || ""
|
||||
let imageFix = namaGambar
|
||||
const noPengaduan = await generateNoPengaduan()
|
||||
let idCategoryFix = kategoriId
|
||||
let idWargaFix = wargaId
|
||||
let idWargaFix = ""
|
||||
|
||||
if (idCategoryFix) {
|
||||
const category = await prisma.categoryPengaduan.findUnique({
|
||||
@@ -138,38 +141,29 @@ const PengaduanRoute = new Elysia({
|
||||
idCategoryFix = "lainnya"
|
||||
}
|
||||
|
||||
if (!isValidPhone(noTelepon)) {
|
||||
return { success: false, message: `nomor telepon ${noTelepon} tidak valid, harap masukkan nomor yang benar` }
|
||||
}
|
||||
|
||||
|
||||
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: {
|
||||
@@ -228,37 +222,21 @@ const PengaduanRoute = new Elysia({
|
||||
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
|
||||
})),
|
||||
|
||||
wargaId: t.Optional(t.String({
|
||||
examples: ["budiman"],
|
||||
description: "ID unik warga yang melapor (jika sudah terdaftar)"
|
||||
})),
|
||||
// namaWarga: t.String({
|
||||
// examples: ["budiman"],
|
||||
// description: "Nama warga yang melapor"
|
||||
// }),
|
||||
|
||||
noTelepon: t.String({
|
||||
error: "Nomor telepon harus diisi",
|
||||
examples: ["08123456789", "+628123456789"],
|
||||
description: "Nomor telepon warga pelapor"
|
||||
}),
|
||||
// noTelepon: t.String({
|
||||
// error: "Nomor telepon harus diisi",
|
||||
// examples: ["08123456789", "+628123456789"],
|
||||
// description: "Nomor telepon warga pelapor"
|
||||
// }),
|
||||
}),
|
||||
|
||||
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"]
|
||||
}
|
||||
})
|
||||
@@ -313,6 +291,84 @@ Respon:
|
||||
description: `tool untuk update status pengaduan`
|
||||
}
|
||||
})
|
||||
.post("/update", async ({ body }) => {
|
||||
const { noPengaduan, judul, detail, lokasi, namaGambar } = body
|
||||
let dataUpdate = {}
|
||||
|
||||
const cek = await prisma.pengaduan.findFirst({
|
||||
where: {
|
||||
noPengaduan,
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (!cek) {
|
||||
return { success: false, message: 'gagal update status pengaduan, nomer ' + noPengaduan + ' tidak ditemukan' }
|
||||
}
|
||||
|
||||
if (judul) {
|
||||
dataUpdate = { title: judul }
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
dataUpdate = { ...dataUpdate, detail }
|
||||
}
|
||||
|
||||
if (lokasi) {
|
||||
dataUpdate = { ...dataUpdate, location: lokasi }
|
||||
}
|
||||
|
||||
if (namaGambar) {
|
||||
dataUpdate = { ...dataUpdate, image: namaGambar }
|
||||
}
|
||||
|
||||
const pengaduan = await prisma.pengaduan.updateMany({
|
||||
where: {
|
||||
noPengaduan
|
||||
},
|
||||
data: dataUpdate
|
||||
})
|
||||
|
||||
const keys = Object.keys(dataUpdate).join(", ");
|
||||
|
||||
await prisma.historyPengaduan.create({
|
||||
data: {
|
||||
idPengaduan: cek.id,
|
||||
deskripsi: `Pengaduan diupdate oleh warga (data yg diupdate: ${keys})`,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, message: 'pengaduan dengan nomer ' + noPengaduan + ' sudah diupdate' }
|
||||
}, {
|
||||
body: t.Object({
|
||||
noPengaduan: t.String({
|
||||
error: "nomer pengaduan harus diisi",
|
||||
description: "Nomer pengaduan yang ingin diupdate"
|
||||
}),
|
||||
judul: t.Optional(t.String({
|
||||
error: "judul harus diisi",
|
||||
description: "Judul pengaduan yang ingin diupdate"
|
||||
})),
|
||||
detail: t.Optional(t.String({
|
||||
description: "detail pengaduan yang ingin diupdate"
|
||||
})),
|
||||
lokasi: t.Optional(t.String({
|
||||
description: "lokasi pengaduan yang ingin diupdate"
|
||||
})),
|
||||
namaGambar: t.Optional(t.String({
|
||||
description: "Nama file gambar yang telah diupload untuk update data pengaduan"
|
||||
})),
|
||||
}),
|
||||
|
||||
detail: {
|
||||
summary: "Update Data Pengaduan",
|
||||
description: `tool untuk update data pengaduan`,
|
||||
tags: ["mcp"]
|
||||
}
|
||||
})
|
||||
.get("/detail", async ({ query }) => {
|
||||
const { id } = query
|
||||
|
||||
@@ -361,7 +417,7 @@ Respon:
|
||||
|
||||
const dataHistory = await prisma.historyPengaduan.findMany({
|
||||
where: {
|
||||
idPengaduan: id,
|
||||
idPengaduan: data?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -377,23 +433,13 @@ 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
|
||||
}))
|
||||
|
||||
|
||||
const warga = {
|
||||
name: data?.Warga?.name,
|
||||
@@ -545,16 +591,61 @@ Respon:
|
||||
folder: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload File",
|
||||
description: "Tool untuk upload file ke Seafile",
|
||||
tags: ["mcp"],
|
||||
summary: "Upload File (FormData)",
|
||||
description: "Tool untuk upload file ke folder tujuan dengan memakai FormData",
|
||||
// tags: ["mcp"],
|
||||
consumes: ["multipart/form-data"]
|
||||
},
|
||||
})
|
||||
.post("/upload-file-form-data", async ({ body }) => {
|
||||
const { file } = body;
|
||||
|
||||
// // Validasi file
|
||||
// if (!file) {
|
||||
// return { success: false, message: "File tidak ditemukan" };
|
||||
// }
|
||||
|
||||
// // Rename file
|
||||
// const renamedFile = renameFile({ oldFile: file, newName: 'random' });
|
||||
|
||||
|
||||
// // Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
|
||||
// // const buffer = await file.arrayBuffer();
|
||||
// const result = await uploadFile(defaultConfigSF, renamedFile, 'pengaduan');
|
||||
// if (result == 'gagal') {
|
||||
// return { success: false, message: "Upload gagal" };
|
||||
// }
|
||||
|
||||
return {
|
||||
success: true,
|
||||
file: JSON.stringify(file),
|
||||
fileInfo: {
|
||||
name: file.name || 'kosong',
|
||||
size: file.size || 0,
|
||||
type: file.type || 'kosong'
|
||||
}
|
||||
// message: "Upload berhasil",
|
||||
// filename: renamedFile.name,
|
||||
// size: renamedFile.size,
|
||||
// seafileResult: result
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
file: t.Any(),
|
||||
// folder: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload File (FormData)",
|
||||
description: "Tool untuk upload file ke folder tujuan dengan memakai FormData",
|
||||
// tags: ["mcp"],
|
||||
consumes: ["multipart/form-data"]
|
||||
},
|
||||
})
|
||||
.post("/upload-base64", async ({ body }) => {
|
||||
const { data, mimetype } = body;
|
||||
const { data, mimetype, kategori } = body;
|
||||
const ext = mimeToExtension(mimetype)
|
||||
const name = `${uuidv4()}.${ext}`
|
||||
const kategoriFix = kategori === 'pengaduan' ? 'pengaduan' : 'syarat-dokumen';
|
||||
|
||||
// Validasi file
|
||||
if (!data) {
|
||||
@@ -566,7 +657,8 @@ Respon:
|
||||
// const base64String = Buffer.from(buffer).toString("base64");
|
||||
|
||||
// (Opsional) jika perlu dikirim ke Seafile sebagai base64
|
||||
const result = await uploadFileBase64(defaultConfigSF, { name: name, data: data });
|
||||
// const result = await uploadFileBase64(defaultConfigSF, { name: name, data: data });
|
||||
const result = await uploadFileToFolder(defaultConfigSF, { name: name, data: data }, kategoriFix);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -575,17 +667,19 @@ Respon:
|
||||
name,
|
||||
mimetype,
|
||||
ext,
|
||||
kategori,
|
||||
}
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
data: t.String(),
|
||||
mimetype: t.String()
|
||||
mimetype: t.String(),
|
||||
kategori: t.String()
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload File (Base64)",
|
||||
description: "Tool untuk upload file ke Seafile dalam format Base64",
|
||||
tags: ["mcp"],
|
||||
// tags: ["mcp"],
|
||||
consumes: ["multipart/form-data"]
|
||||
},
|
||||
})
|
||||
@@ -632,6 +726,10 @@ Respon:
|
||||
}
|
||||
}
|
||||
|
||||
const totalData = await prisma.pengaduan.count({
|
||||
where
|
||||
});
|
||||
|
||||
const data = await prisma.pengaduan.findMany({
|
||||
skip,
|
||||
take: !take ? 10 : Number(take),
|
||||
@@ -669,12 +767,20 @@ Respon:
|
||||
detail: item.detail,
|
||||
status: item.status,
|
||||
location: item.location,
|
||||
createdAt: item.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
|
||||
}
|
||||
})
|
||||
|
||||
return dataFix
|
||||
const dataReturn = {
|
||||
data: dataFix,
|
||||
total: totalData,
|
||||
page: Number(page) || 1,
|
||||
pageSize: !take ? 10 : Number(take),
|
||||
totalPages: Math.ceil(totalData / (!take ? 10 : Number(take)))
|
||||
}
|
||||
|
||||
return dataReturn
|
||||
}, {
|
||||
query: t.Object({
|
||||
take: t.String({ optional: true }),
|
||||
@@ -776,8 +882,6 @@ Respon:
|
||||
description: "Tool untuk delete file Seafile",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
;
|
||||
|
||||
export default PengaduanRoute
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import type { User } from "generated/prisma";
|
||||
import _ from "lodash";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
const UserRoute = new Elysia({
|
||||
@@ -145,10 +146,31 @@ const UserRoute = new Elysia({
|
||||
NOT: {
|
||||
id: user.id
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
roleId: true,
|
||||
Role: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
const dataFix = data.map((item: any) => ({
|
||||
..._.omit(item, ["Role"]),
|
||||
nameRole: item.Role?.name,
|
||||
name: String(item.name),
|
||||
phone: String(item.phone),
|
||||
email: String(item.email),
|
||||
roleId: String(item.roleId),
|
||||
}))
|
||||
|
||||
return dataFix
|
||||
}, {
|
||||
detail: {
|
||||
summary: "list",
|
||||
@@ -159,6 +181,9 @@ const UserRoute = new Elysia({
|
||||
const data = await prisma.role.findMany({
|
||||
where: {
|
||||
isActive: true
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc"
|
||||
}
|
||||
})
|
||||
return data
|
||||
@@ -193,11 +218,11 @@ const UserRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
.post("role-create", async ({ body }) => {
|
||||
const { name, permission } = body;
|
||||
const { name, permissions } = body;
|
||||
const create = await prisma.role.create({
|
||||
data: {
|
||||
name,
|
||||
permissions: permission
|
||||
permissions: permissions
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,7 +233,7 @@ const UserRoute = new Elysia({
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, error: "name is required" }),
|
||||
permission: t.Array(t.Any(), { minItems: 1, error: "permission is required" })
|
||||
permissions: t.Any(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "create-role",
|
||||
@@ -216,14 +241,14 @@ const UserRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
.post("/role-update", async ({ body }) => {
|
||||
const { id, name, permission } = body;
|
||||
const { id, name, permissions } = body;
|
||||
const update = await prisma.role.update({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
permissions: permission
|
||||
permissions
|
||||
}
|
||||
});
|
||||
|
||||
@@ -235,7 +260,7 @@ const UserRoute = new Elysia({
|
||||
body: t.Object({
|
||||
id: t.String({ minLength: 1, error: "id is required" }),
|
||||
name: t.String({ minLength: 1, error: "name is required" }),
|
||||
permission: t.Array(t.String(), { minItems: 1, error: "permission is required" })
|
||||
permissions: t.Any()
|
||||
}),
|
||||
detail: {
|
||||
summary: "update-role",
|
||||
|
||||
@@ -9,9 +9,31 @@ const WargaRoute = new Elysia({
|
||||
})
|
||||
|
||||
.get("/list", async ({ query }) => {
|
||||
const { search } = query
|
||||
const { search, page = 1 } = query
|
||||
const dataSkip = page == null || page == undefined ? 0 : Number(page) * 10 - 10;
|
||||
|
||||
const totalData = await prisma.warga.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: search,
|
||||
mode: "insensitive"
|
||||
}
|
||||
},
|
||||
{
|
||||
phone: {
|
||||
contains: search,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const data = await prisma.warga.findMany({
|
||||
skip: dataSkip,
|
||||
take: 10,
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
@@ -33,7 +55,15 @@ const WargaRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
const dataFix = {
|
||||
data,
|
||||
total: totalData,
|
||||
page: Number(page) || 1,
|
||||
pageSize: 10,
|
||||
totalPages: Math.ceil(totalData / 10)
|
||||
};
|
||||
|
||||
return dataFix
|
||||
}, {
|
||||
detail: {
|
||||
summary: "List Warga",
|
||||
|
||||
Reference in New Issue
Block a user